السياق
تعرض بروتوكول zkLink متعدد السلاسل (zk-rollup) لخسارة تقارب 783,000 دولار في 29 أكتوبر 2026، من خلال استغلال تلاعب في التحقق من الرسائل عبر السلاسل. كشف فحص الأدلة الجنائية الذي أجرته SlowMist أن الحادث نتج عن عيوب في آلية المطالبة بالرسائل في عقد Arbitrator، مما سمح للمهاجم بتزوير جذور الدُفعات وتنفيذ عمليات سحب غير مصرح بها.
هيكلية البروتوكول
على شبكة Ethereum، يعمل zkLink من خلال عقد ZkLink على العنوان 0x5fd9f73286b7e8683bab45019c94553b93e015cf، والذي يعمل كمركز رسائل الطبقة الأولى (L1) وخزينة ETH. ويحتفظ عقد L1ERC20Bridge على 0xad16edcf7deb7e90096a259c81269d811544b6b6 بأصول ERC-20.
يعتمد تفويض السحب على جذور الدُفعات: كل عملية سحب من الطبقة الثانية (L2) تولد سجل انتقال من L2 إلى L1، حيث تشكل السجلات من دفعة واحدة شجرة ميركل. بعد اكتمال تنفيذ الدُفعة في L2، يتم مزامنة جذر السجل الخاص بها—المحفوظ كـ l2LogsRootHashes[batchNumber]—إلى عقد zkLink في L1. يقدم المستخدمون رسائل السحب مع إثباتات ميركل أثناء السحب من L1؛ يتحقق zkLink من الإثباتات مقابل الجذر المخزن قبل صرف الأموال. إذا تم تزوير الجذر، تصبح عملية التحقق بلا معنى، حيث يمكن لأي رسالة مزيفة أن تصاحبها إثبات مطابق. يتتبع عداد totalBatchesExecuted أعلى رقم دفعة تم تنفيذها، مع ضرورة أن يكون _batchNumber ≤ totalBatchesExecuted أثناء التحقق.
يتم توجيه الرسائل عبر السلاسل من خلال بوابات وسيطة بدلاً من النقل المباشر بين السلاسل. تتولى EthereumGateway (0x83bc7394738a7a084081af22eec0051908c0055c) العمليات على جانب Ethereum، بينما تدير LineaL1Gateway (0x803460416c2682ac54fccf03ef77b10a12f2809b) جانب Linea. ينسق عقد Arbitrator على 0x1ee09a2caa0813a5183f90f5a6d0e4871f4c6002 هذه البوابات بشكل مركزي.
تتعامل دالة claimMessage في عقد Arbitrator مع معالجة الرسائل وتوجيهها عند استدعائها من قبل الوسطاء الموثوقين. تستخدم خدمة الرسائل على السلسلة المصدر التي يحددها الوسيط للمطالبة بالرسائل عبر السلاسل، ثم توجهها إلى بوابة الوجهة. لضمان مطابقة المحتوى الموجه مع الرسالة المطالب بها، يكتب Arbitrator هاش الرسالة في تخزين مؤقت (transient storage) عبر رد الاتصال receiveMessage أثناء المطالبة، والذي تتحقق منه claimMessage لاحقًا.
التخزين المؤقت، الذي قُدم في Solidity 0.8.24 عبر EIP-1153، يستمر طوال مدة المعاملة بدلاً من النداء الوظيفي الفردي: القيم المكتوبة في نداء واحد تظل متاحة للنداءات التالية داخل نفس المعاملة، ولا تُزال إلا عند اكتمال المعاملة.
نشأ مسار الهجوم في عقد SyncL2TxHashRelayer (0x0fdc28e64772a70109d10fd068f859cfa7dea2c6). دالته claimPrimaryChainSyncL2TxHashMessage تفتقر إلى قيود وصول وتعمل كقناة تمرير، حيث تنقل المعلمات التي يوفرها المتصل مباشرة إلى Arbitrator.claimMessage. ويؤكد استدعاؤها الناجح لـ claimMessage تسجيلها كوسيط معتمد داخل Arbitrator.
آلية الثغرة
تركز الاستغلال على نقطتي ضعف مترابطتين في دالة claimMessage في عقد Arbitrator، والتي تتعامل مع كل من المطالبة بالرسائل وتوجيهها لكنها احتوت على افتراضات ثقة معيبة في كلا العمليتين.
النقطة الأولى: تمنح الدالة الوسطاء سيطرة كاملة على أهداف التنفيذ الخارجية. كل من _sourceChainCanonicalMessageService و _sourceChainClaimCallData هما معلمتان يقدمهما الوسيط، وينفذ Arbitrator النداء تحت سلطته الخاصة. عادةً ما تستهدف الاستخدامات المشروعة واجهات المطالبة بخدمات الرسائل على السلسلة المصدر، لكن المهاجم يمكنه إعادة توجيه هذا إلى أي بوابة مسجلة في Arbitrator، مما يتيح له استدعاء وظائف البوابة المحمية بمعامل onlyArbitrator.
النقطة الثانية: يمكن تجاوز التحقق من الاتساق بسبب سلوك التخزين المؤقت. يقرأ التحقق finalizeMessageHash من التخزين المؤقت، لكن هذه الخانة لا تُزال داخل المعاملة وتبقى متاحة بعد حدود النداء الوظيفي الفردي. تؤدي المطالبة الحقيقية التي تحدث سابقًا في المعاملة إلى كتابة قيمة في هذه الخانة، والتي يمكن لاستدعاء claimMessage لاحق إعادة استخدامها لاجتياز الفحص—حتى إذا لم يقم النداء الثاني بالمطالبة بأي رسالة على الإطلاق. يحتاج المهاجم فقط إلى ضمان تطابق معلمات (gateway, value, callData) عبر كلتا النداءين. وبالتالي يؤكد التحقق فقط أن "مطابقة حدثت في مكان ما ضمن هذه المعاملة"، وليس أن "هذا الاستدعاء طالب بهذه الرسالة".
يصبح تزوير جذر الدُفعة ممكنًا من خلال إعادة توجيه النداء الخارجي إلى EthereumGateway، مما يجعل Arbitrator يستدعي دالته sendMessage. تتحقق هذه الدالة فقط من أن المتصل هو عقد Arbitrator قبل أن توجه بيانات النداء مباشرة إلى عقد zkLink. وبما أن النداء صادر من Arbitrator، يمر الفحص. يملأ المهاجم _callData بـ syncBatchRoot(116409, forgedRoot, 0).
تستخدم دالة syncBatchRoot في zkLink معامل onlyGateway الذي يشترط أن يكون المتصل بوابة معدة مسبقًا. وبما أن EthereumGateway يوجه النداء، يتحقق هذا الشرط. تقبل الدالة أي رقم دفعة وقيمة جذر دون التحقق من إثباتات الدُفعة، وتقوم باستبدال l2LogsRootHashes[_batchNumber] بالجذر المزود مباشرة. إذا تجاوز _batchNumber قيمة totalBatchesExecuted، يتقدم العداد إلى _batchNumber. اختار المهاجم 116409—الدفعة التالية غير المنفذة آنذاك—مقترنة بجذر سجل من إنشائه، مما سمح باستبدال l2LogsRootHashes[116409] من الصفر إلى القيمة المزورة مع زيادة totalBatchesExecuted من 116408 إلى 116409. مع تزوير الجذر، أصبحت إثباتات ميركل تمر بشكل حتمي، ما يسمح بإنشاء رسائل سحب عشوائية وسحب الأموال عبر إثباتات مزيفة.
تسلسل التنفيذ
معاملة الهجوم: 0x4f3c23e75b9134e8cae5a462550daad42fbbc19242567fcc34c0a035e297462c
عنوان المهاجم (EOA): 0xaca0655a11200a80d33d0e48786317304cde5718
عقد الهجوم: 0xd5f162b68a0281686e8dc76db8d67d2f3e6aff07
- استعلم عقد الهجوم عن totalBatchesExecuted() في zkLink لاسترجاع آخر رقم دفعة منفذة، ثم أضاف واحدًا للحصول على 116409 كهدف للتزوير. تتطلب إثباتات السحب اللاحقة أرقام دفعات لا تتجاوز totalBatchesExecuted، لذا يجب تسجيل الدفعة المزورة كـ "منفذة". تقبل دالة syncBatchRoot أرقام دفعات وجذور عشوائية مع تقدم totalBatchesExecuted إلى الدفعة المحددة، ما يعني أن تحكم المهاجم في استدعاء البوابة يحدد أي دفعة تعتبر منفذة.
- بدأ المهاجم مطالبة حقيقية برسالة عبر السلاسل من Linea عبر استدعاء Arbitrator.claimMessage باستخدام SyncL2TxHashRelayer. تضمن التقديم إثباتًا صالحًا لرسالة حقيقية من Linea، محددًا عنوان خدمة الرسائل على السلسلة المصدر كـ Linea Rollup مع تقديم إثبات claimMessageWithProof. تضمنت هذه الرسالة من Linea إلى Ethereum، التي أنشأها المهاجم على Linea، تعيين LineaL1Gateway كمتلقٍ مع محتوى يوجهه لتمرير استدعاء syncL2TxHash(_l2TxHash,_primaryChainL2TxHash) إلى بوابة الوجهة. بعد أن تحقق Linea Rollup من الإثبات، استدعى رد الاتصال في LineaL1Gateway، مما أدى إلى استدعاء دالة receiveMessage في Arbitrator التي كتبت هاش الرسالة في خانة finalizeMessageHash في التخزين المؤقت. ثم قارنت دالة claimMessage مع هذه القيمة. هكذا حصل المهاجم على قيمة التحقق finalizeMessageHash من عملية مطالبة حقيقية، وبقيت مخزنة طوال مدة المعاملة. وبما أن SyncL2TxHashRelayer مسجل كوسيط في Arbitrator وتفتقد دالته claimPrimaryChainSyncL2TxHashMessage للقيود، يمكن لأي عنوان استخدامها لاستدعاء claimMessage في Arbitrator. وفي الوقت نفسه، تتيح claimMessage للوسطاء تحديد عناوين خدمات الرسائل بأنفسهم، وتبقى قيمة التحقق المكتوبة أثناء المطالبة الحقيقية في التخزين المؤقت حتى نهاية المعاملة.
- استدعى عقد الهجوم نفس الوسيط مباشرة مرة أخرى، مع تعديل معلمين فقط: استبدال EthereumGateway (المسجل في Arbitrator) بـ Linea Rollup كخدمة الرسائل على السلسلة المصدر، وتبديل _sourceChainClaimCallData إلى sendMessage(0, syncBatchRoot(116409, 0x1934a1...a8, 0), ""). جميع المعلمات الأخرى—بوابة السلسلة المصدر، مقدار الاستلام المعلن، بيانات الاستلام، ومعلمات إعادة التوجيه—تطابق الاستدعاء السابق بايت مقابل بايت. تشكل هذه المعلمات أساس التحقق من finalizeMessageHash. وبما أن finalizeMessageHash موجود في التخزين المؤقت، فإنه لا يُزال أثناء المعاملة ويبقى بغض النظر عن ما إذا كان الاستدعاء الحالي قد طالب برسالة بالفعل. كذلك، لا يشترك عنوان الهدف الخارجي أو بيانات النداء في الفحص، مما يتيح إعادة استخدام القيمة المتبقية من المطالبة السابقة مباشرة. أيضًا، تسمح Address.functionCall أولاً لـ Arbitrator بتنفيذ أي استدعاء يحدده المهاجم تحت هويته الخاصة—وهي نقطة الضعف الأساسية في الاستغلال. لم يطالب هذا النداء الثاني بأي رسالة، لكنه اجتاز تحقق claimMessage في حين أن النداء الخارجي للمهاجم إلى sendMessage في EthereumGateway كان قد تم تنفيذه بالفعل قبل إجراء الفحص.
- عندما استدعى Arbitrator دالة sendMessage في EthereumGateway، تضمنت بيانات النداء تحديدًا استدعاء syncBatchRoot(116409, forgedRoot, 0) المقدم من المهاجم. نتيجة لذلك، سجل zkLink الجذر المزور كجذر سجل هذه الدُفعة. تظهر الأدلة على السلسلة أن zkLink أصدر حدث SyncBatchRoot(116409, 0x1934a1c3…)، وتغيرت l2LogsRootHashes[116409] من الصفر إلى الجذر الذي أنشأه المهاجم، وارتفع totalBatchesExecuted من 116408 إلى 116409. أصبح الجذر المزور الآن مسجلاً "بشكل شرعي" في دفتر البروتوكول. كما تم تمرير استدعاء syncL2TxHash في كلا النداءين، مما أدى إلى تعيين l2TxHashMap[0x2575…] إلى 0xe9ec…—وهو أثر جانبي غير ضار نتيجة إعادة استخدام قيمة التحقق. ولأن EthereumGateway.sendMessage يتحقق فقط من أن المتصل هو Arbitrator قبل توجيه بيانات النداء إلى zkLink، وzkLink.syncBatchRoot يتحقق فقط من أن المتصل بوابة دون فحص إثباتات الدُفعة، مرت كلتا طبقتي "التحقق من المتصل دون التحقق من المحتوى"، ما سمح بتسجيل الجذر المزور كجذر شرعي.
- بعد استبدال الجذر، يمكن لأي رسالة سحب تقدم إثباتًا صحيحًا مقابل هذا الجذر أن تجتاز التحقق. نفذ عقد الهجوم 12 استدعاء سحب نهائي متتالية، جميعها تستهدف الدُفعة 116409: سحب واحد لـ ETH يليه 11 سحبًا لـ ERC-20. تضمنت كل رسالة أربع عقد ميركل أنشأها المهاجم بالاعتماد على الجذر المزور، ما يضمن تحقق الإثباتات. عالج عقد zkLink السحب لـ ETH مباشرة، بينما تعامل L1ERC20Bridge مع عمليات السحب لـ ERC-20. بعد التحقق من الإثبات، تم تحويل الرموز مباشرة من عقد الجسر إلى EOA الخاص بالمهاجم. سجل كلا العقدين الرسائل كمُسحوبة، لكن هذا المؤشر يمنع فقط التقديم المكرر ولا يمكنه التحقق مما إذا كانت الرسائل حدثت فعلاً على L2. تتحقق عقود السحب فقط من "إدراج الرسالة في الشجرة المقابلة لجذر الدُفعة المخزن"، دون التحقق مما إذا كان الجذر نفسه ناتجًا عن تنفيذ حقيقي على L2. بمجرد تزوير الجذر، يمكن إرفاق أي رسالة بإثبات صالح.
متطلبات المعالجة
جمع هذا الحادث بين قدرة على إجراء استدعاءات خارجية عشوائية في عقد Arbitrator مع إعادة استخدام القيم غير المزالة في التخزين المؤقت لأغراض التحقق. تسمح Arbitrator.claimMessage للوسطاء بتحديد أهداف وبيانات نداء عشوائية، وتنفيذ استدعاءات خارجية عشوائية تحت سلطتها الخاصة. الفحص النهائي الوحيد يقارن مع قيمة متبقية في التخزين المؤقت دون إزالتها أو التأكد من أن الاستدعاء الحالي أكمل المطالبة. استخدم المهاجم مطالبة شرعية لكتابة قيمة التحقق، ثم أعاد استخدامها في استدعاء claimMessage ثانٍ، مما أدى إلى استدعاء Arbitrator لواجهة onlyArbitrator في البوابة نيابة عن المهاجم، وفي النهاية كتابة جذر الدُفعة المزور في zkLink وجعل الأصول الموجودة في الجسر قابلة للسحب "بإثبات".
يجب أن تغطي المعالجة ثلاث طبقات. أولاً، يجب على claimMessage إزالة finalizeMessageHash قبل استدعاء Address.functionCall، ثم تتطلب أن تكون غير صفرية ومتطابقة مع إعلان الاستدعاء الحالي، أو استخدام عداد متزايد لكل استدعاء لربط القيمة، لضمان أن قيمة التحقق لا يمكن كتابتها إلا من خلال receiveMessage أثناء نفس الاستدعاء. ثانيًا، تنفيذ قائمة بيضاء لـ _sourceChainCanonicalMessageService لمنع البوابات المسجلة في Arbitrator من العمل كـ "خدمات رسائل على السلسلة المصدر". يجب ألا تُسجل عقود المساعدة التطويرية كوسطاء، أو يجب أن تنفذ قيود وصول تمنع العناوين العشوائية من الحصول بشكل غير مباشر على قدرة استدعاء claimMessage. ثالثًا، يجب على zkLink.syncBatchRoot رفض محاولات استبدال جذور الدُفعات الموجودة، ويجب إضافة طبقة إثبات مستقلة على مستوى البوابة أو الرسائل لضمان أن تحديثات جذور الدُفعات لا يمكن أن تنجح فقط لأن "المتصل هو بوابة".
الأكثر رواجًا
- الأخبار FBI ينسب سرقة أصول Binance إلى كوريا الشمالية
- الأخبار Chainalysis تكشف وصولاً غير مصرح به إلى بيانات عملاء
- الأخبار Binance تعلن تخصيص 6.34 مليارات دولار بعد الاختراق
- التحقيقات تشيناليسيس تستخدم الذكاء الاصطناعي لتتبع 387 مليون دولار مسروقة من بيتجت
- الأخبار Binance تقدّر حجم حادث أمني بنحو 6.3 مليارات دولار
- التحقيقات هاكر جسر Cysic حوّل 744 ألف دولار إلى Tornado Cash بعد مكافأة 10%
- التحقيقات هاكرز استغلال Drift نقلوا 109 مليون دولار من ETH إلى خلاط Tornado Cash
- الأخبار خسائر العملات الرقمية تصل إلى 1.18 مليار دولار في الربع الثالث من 2026 معظمها بسبب ثغرات الموردين
- التحقيقات سرقة محافظ أجهزة CryptoBilis تُقدّر بين 93 و94.5 مليون دولار
- التحقيقات رجل من فيرجينيا يصادر منه بيتكوين بقيمة 101 مليون دولار بعد حكم بالسجن لمدة 40 عامًا
