اختراق Aztec Connect وسرقة 2.19 مليون دولار عبر ثغرة في ZKRollup

في 14 يونيو 2026، تعرّض عقد RollupProcessor الخاص بخدمة Aztec Connect (0xff1f2b4adb9df6fc8eafecdcbf96a2b351680455) للاستغلال رغم كونه "مُهمَلًا" منذ مارس 2024. وتمكّن المهاجم من سحب ما يقارب 2.19 مليون دولار من أصول مجمّع الطبقة الأولى L1 ضمن معاملة ذرّية واحدة، مستفيدًا من ثغرة منطقية نشأت عن فجوة حدودية بين قيمتي numRealTxs و decoded_slots. وبقاء هذا العقد غير القابل للتعديل مكشوفًا كان مرتبطًا باحتفاظه ببقايا أصول لمستخدمين. تفاصيل الخلل تشير إلى وجود عدم اتساق بنيوي بين نطاق دورات التسوية على L1 التي يمرّ عليها RollupProcessorV3، وبين النطاق الذي يتم الالتزام به داخل تجزئة مدخلات ZK العامة (ZK public input hash). استغل المهاجم هذه الفجوة لتمرير محتوى 31 فتحة من أصل 32 فتحة ضمن مدخلات عامة يتم تثبيتها في جذر حالة L2 عبر براهين ZK، من دون أن تمر هذه الفتحات بأي تحقق تسوية على مستوى عقد L1. في Decoder.sol، تُقرأ numRealTxs من calldata عند الإزاحة 4516 من دون قيود على السلسلة، ما يجعلها تحت تحكم كامل للمهاجم. وفي المقابل يتم تقريب decoded_slots إلى أقرب مضاعف لقيمة numTxsPerRollup تماشيًا مع تنسيق بيانات precompile الخاص بـ SHA256. عملية التقريب هذه هي التي تخلق منطقة فجوة بين numRealTxs و decoded_slots يمكن للمهاجم حشوها بحرية. وفي RollupProcessorV3.sol، تغطي دورة التسوية فقط عدد فتحات يساوي numRealTxs، ما يعني أن الفتحات الواقعة في منطقة الفجوة لا تُعالج على L1. افتراضات الأمان المعتادة تفترض أن كل فتحة من المدخلات العامة يتم إما التحقق منها على L1 (مثل خفض pendingDepositBalance عند الإيداع) أو تقييدها داخل دائرة ZK بحيث تكون publicValue == 0. لكن في هذا السيناريو: التزام SHA256 شمل الفتحات الـ32 كاملة (مع اختبار إدخال 8192 بايت = 32 × 256 بايت)، وتم تثبيت بيانات فتحات الفجوة عبر برهان ZK. حلقة التسوية على L1 عالجت الفتحة الأولى فقط، بينما لم تخضع فتحات الفجوة [2..32] لأي تحقق على مستوى L1. كما تم تجاوز أو غياب قيد الدائرة الذي يفترض أن يضمن أن publicValue في فتحات الفجوة يساوي 0. النتيجة أن خطوط الدفاع الثلاثة كانت مترابطة، لكن أيًا منها لم يوفر أمانًا مستقلًا لفتحات الفجوة عند غياب قيود الدائرة، ولم يتمكن مستوى L1 من اكتشاف التجاوز. من منظور "تباعد المسارين"، استُهلكت calldata نفسها عبر مسارين بحدود عليا مختلفة: ZK تتعامل مع 32 فتحة، بينما L1 تعترف بفتحة واحدة فقط. هذا التعارض في تعريف "أي الفتحات تُحتسب" أتاح فعليًا توليد أرصدة من العدم. المعاملة المهاجمة 0x074ec931…aee1 تضمنت 14 استدعاءً لدالة processRollup() ضمن نمط من مرحلتين: سبع عمليات "سك" تلتها سبع عمليات سحب، جميعها داخل معاملة ذرّية واحدة (gasUsed = 4,513,539)، ما يعني عدم إمكانية تنفيذ تراجع جزئي على مستوى العقد. المرحلة الأولى: السك على L2 (Rollup #13277–13283، سبع مرات) - العنوان 0x0f18d8b44a740272f0be4d08338d2b165b7edd17 استدعى عقد التحكم 0x06f585f74e0da633ae813a0f23fb9900b61d0fcd عبر المعرّف 0x6f3ce701. - عقد التحكم استدعى بالتتابع ثلاثة عقود ترحيل تحتوي calldata خبيثة مُشفّرة مسبقًا. - معلمات رئيسية: numRealTxs = 1، و rollupSize = 1024، و numInnerRollups = 32. - الفتحة 1 (مرئية لـ L1): proofId = 0 (noop) و publicValue = 0. - الفتحات 2–32 (31 فتحة فجوة غير مرئية لـ L1): proofId = 1 (deposit) و publicValue = N و publicOwner = عنوان المهاجم على L2، مع إرفاق برهان ZK (مع غياب قيد يفرض publicValue = 0 لفتحات الفجوة). - عقد الترحيل A نفّذ RollupProcessor.processRollup() خمس مرات (Rollup #13277–13281): المُتحقق أكد نجاح برهان ZK والتزام SHA256 غطى 32 فتحة، لكن دورة تسوية L1 توقفت عند فتحة واحدة فقط (1 × TX_PUBLIC_INPUT_LENGTH)، فجرى تمرير noops فقط. الإيداعات الوهمية في فتحات الفجوة [2..32] ثُبّتت في جذر Merkle جديد، فارتفع رصيد المهاجم على L2 بمقدار 5 × 31N. - عقد الترحيل B نفّذ Rollup #13282–13283 بالطريقة نفسها مرتين، مضيفًا 2 × 31N إضافية. عند هذه النقطة تراكم لدى حساب المهاجم على L2 إجمالي 7 × 31N من "إيداعات" غير مدعومة، بينما بقي خزّان L1 دون تغيير. المرحلة الثانية: السحب وتحويل الأرصدة إلى أصول على L1 (Rollup #13284–13290، سبع مرات) حوّل المهاجم كامل رصيده المتضخم على L2 إلى أصول حقيقية على L1 عبر سبع دفعات سحب: - Rollup #13284 (DAI): withdraw() ونقل مباشر بقيمة 270,513.054 DAI إلى 0x0f18…edd17. - Rollup #13285 (wstETH): نقل 167.890 wstETH إلى المهاجم. - Rollup #13286 (yvDAI): نقل 4,873.857 yvDAI إلى المهاجم. - Rollup #13287 (yvWETH، تولّى عقد الترحيل C): نقل 16.570 yvWETH إلى المهاجم. - Rollup #13288 (LUSD): نقل 9,273.734 LUSD إلى المهاجم. - Rollup #13289 (yvLUSD): نقل 359.047 yvLUSD إلى المهاجم. - Rollup #13290 (ETH): نقل 908.987 ETH عبر CALL داخلي إلى المهاجم. تتبّع الأموال حتى 15 يونيو 2026 يُظهر أن الأصول سُحبت في معاملة واحدة من RollupProcessor مرورًا بعقد وسيط 0x06f585…d0fcD مباشرة إلى محفظة المهاجم 0x0F18D8b44a740272f0be4d08338d2b165b7EdD17. العقد الوسيط لم يحتفظ بأي أرصدة متبقية. وحتى وقت الرصد، بقيت الأموال المسروقة كاملة وغير مُحرّكة، دون مؤشرات على بدء عمليات غسل. الخلاصة التي يبرزها هذا الحادث هي ضرورة التطابق الصارم بين الحد الأعلى لحلقة التسوية في عقد ZKRollup وبين نطاق الالتزامات الخاصة بمدخلات ZK العامة. أي فجوة بين حد الحلقة numRealTxs على L1 وبين decoded_slots التي تغطيها التزامات SHA256 تخلق مساحة يمكن للمهاجم استغلالها لتجاوز قيود الدائرة، ما يتطلب أن يقوم L1 بالتحقق المستقل من كل فتحة من المدخلات العامة التي يلتزم بها برهان ZK، بدل ترحيل هذه المسؤولية إلى طبقة الدائرة. فريق SlowMist الأمني أوصى بإجراء تدقيق أمني خارجي شامل قبل نشر أنظمة Rollup، مع التركيز على الاتساق المنطقي عند حدود الحالة بين L1 وL2، وحدود الثقة في فك ترميز calldata، والتحقق الثانوي على السلسلة لمدخلات ZK العامة. وبالنسبة للعقود المُهمَلة التي ما زالت تحتفظ بأصول قديمة، يُنصح بتنفيذ ترحيل منظم للأصول أو إتلافها لتصفير مخاطر التعرض المستمر. أعدّ هذا التقرير فريق استخبارات التهديدات لدى SlowMist بالاعتماد على نظام MistEye، ومنصة التتبع MistTrack، وتحليلات SlowMist Agent المعتمدة على الذكاء الاصطناعي. للاستفسارات أو الملاحظات يمكن التواصل مع الفريق.