المؤلف: جيو جيو
تحرير: 77
السياق
في 4 سبتمبر 2026، تعرض منصة الإقراض اللامركزية المعروفة Notional Finance لهجوم، وخسرت حوالي 1.73 مليون دولار أمريكي. فيما يلي التحليل التفصيلي من فريق الأمان Slow Mist بشأن هذا الحادث:
المعرفة المسبقة
في Notional Finance V1، يمكن فهم fCash كحق مالي له تاريخ استحقاق. يتلقى مستلم النقد المبلغ عند الاستحقاق، ويدفع مدفوع النقد المبلغ عند الاستحقاق. يسجل البروتوكول كلا الموضعين في المحفظة، ثم يقيّم صحة الحساب لإمكانية إنشاء موضع من خلال فحص الأصول الضامنة الحرة.
يقوم safeTransferFrom في ERC1155Trade هنا بوظيفة تسجيل الأصول. عندما يكون نوع الأصل المُدخل من نوع مستلم النقد، يُستدعى Portfolios.mintfCashPair، مع تسجيل مدين على الدافع ودين مكافئ على المستلم. يبدو هذا الاستدعاء وكأنه تحويل ERC1155، لكن النتيجة هي صب زوج fCash.
بعد كتابة المراكز الجديدة في المحفظة، يجب حساب والتحقق من الأصول الحرة. سيقوم عقد RiskFramework أولاً بجمع ديون fCash الخاصة بالمدفوعات كقيمة صحيحة موقعة int256، ثم يحول أرصدة كل عملة إلى ETH لجمعها. تمثل القيم السالبة النقد المطلوب دفعه، وتمثل القيم الموجبة النقد أو المطالبات التي يمتلكها الحساب. يحدد هذا الحساب ما إذا كان يمكن السماح بالمعاملة أم لا.
بعد انتهاء الوضعية، سيقوم Portfolio بدعوة دالة portfolioSettleCash في Escrow لتحويل fCash المنتهي إلى رصيد نقدي. ثم يقوم الحساب باستخراج الأصول المقابلة من DAI أو USDC عبر دالة السحب من Escrow.
السبب الجذري
يتمثل الثغرة الأساسية في هذا الهجوم في دالة ExchangeRate._convertToETH المستخدمة في عقد Escrow، حيث تحتوي على كود يقوم بتحويل القيمة المطلقة لعدد صحيح موقّع مباشرة إلى uint128.

قام المهاجم بإنشاء وضعي ديون متطابقين لنفس الدافع، بقيم 1 وuint128.max. عند جمع هذين الديون، ينتج المجموع بالضبط 2^128. نظرًا لأن دفعات الدافع تُسجل كقيم سالبة في حساب المخاطر، فإن المعلمة الأساسية المُمرَّرة إلى convertBalancesToETH في Escrow هي -2^128.
لكن إصدار Solidity المستخدم في هذا العقد هو 0.6.x، لذا فإن قيمته المطلقة 2^128 عند تحويلها إلى uint128 ستتعدى الحد وتصبح 0، وبالتالي لن تُدرج هذه الخسارة في النتيجة المقيّمة بـ ETH.
في النهاية، في دالة mintfCashPair، يُطلب فقط أن يكون الرصيد الحر للمدفوع أكبر من أو يساوي صفرًا، لذا يمكن تجاوز هذا الفحص بعد حدوث تجاوز الناتج إلى صفر، مما يسمح لحساب لا يمتلك أصولًا كافية لتغطية الالتزامات بفتح مركز.
تحليل خطوات الهجوم
1. في المعاملة المقدمة (0xe1589a19…d60a)، قام المهاجم أولاً بإنشاء عدة عقود مساعدة، ثم استدعى دالة setApprovalForAll في عقد ERC1155Trade لمنح الصلاحية للعقود المساعدة؛ بالإضافة إلى استعلام مسبق عن مواعيد استحقاق اثنين من CashMarket، وحساب قيم معلمات AssetId الثلاث المطلوبة للعمليات اللاحقة.

2. بعد ذلك، قام المهاجم بتنفيذ وظيفة safeTransferFrom في عقد ERC1155Trade لإنشاء زوج fCash بقيمة 1، حيث يكون cashGroupId للأصل السندات هو 2، ونقطة وقت الاستحقاق هي 1788480000 (4 سبتمبر 2026، 8:00 صباحًا). وسيتم استدعاء الوظيفة الداخلية _upsertAsset لتحديث الالتزامات والإيرادات المتوقعة لكل من العنوان from (عقد المهاجم) والعنوان to (عقد الاستقبال 1).

بعد إكمال صب الزوج، سيتحقق المحفظة فورًا من الأصول المرهونة الحرة للعقد الهجومي، وبسبب كمية الصب الأولى الصغيرة جدًا، فإن التحويل إلى ETH وفقًا للسعر الحالي والدقة سيُقرب إلى صفر، لذا فإن التحقق الأولي ينجح.
3. ثم استدعى المهاجم مرة أخرى وظيفة safeTransferFrom في عقد ERC1155Trade لصناعة زوج fCash بقيمة uint128.max، حيث يكون cashGroupId للأصل السندات هذا المرة 2، لكن طابع الوقت للاستحقاق هو 1796256000 (3 ديسمبر 2026، 8:00 صباحًا).
هناك تفصيل صغير، حيث قام المهاجم بسك أصول سندات بتواريخ استحقاق مختلفة لعنوانين مختلفين. وذلك لأن تحديث الالتزامات للعنوان من عند وجود أصول سندات متطابقة يؤدي إلى جمعها مباشرة، مما يتسبب في التراجع عند معالجة الجمع بسبب التدفق الزائد (يستخدم العقد مكتبة safeMath).

4. بعد تحديث الالتزامات والإيرادات المتوقعة لكل من عنوان المصدر (عقد الهجوم) وعنوان الوجهة (عقد الاستقبال 2) في عملية الصك الثانية، تقوم دالة mintfCashPair باستدعاء دالة freeCollateral لحساب مركز الضمان الصافي لعنوان المصدر والتحقق من أنه أكبر من أو يساوي صفرًا ليكون صحيًا.

سيتم أولاً جمع مجموع الديون الناتجة عن الصكين من عنوان from في دالة getRequirement في عقد RiskFramework:

النتيجة النهائية هي -2^128، ثم يتم استدعاء دالة convertBalancesToETH في عقد Escrow لتحويل هذا القيمة إلى تقييم ETH، وتقوم دالة convertBalancesToETH بدورها باستدعاء دالة _convertToETH في مكتبة ExchangeRate للحساب:


عند متابعة دالة _convertToETH، يمكن ملاحظة أنها تأخذ القيمة المطلقة لـ balance أولاً، ثم تستخدم uint128 لتحويل القيمة من 256 بت إلى 128 بت. أما مجموع الديون في "from" فهو -2^128، وعند أخذ القيمة المطلقة تصبح 2^128، وعند التحويل القسري إلى uint128، فإن هذا يؤدي إلى تجاوز الحد الأقصى وحدوث تدفق (overflow) ليصبح الناتج 0. وهذا يعني أن البروتوكول يفترض خطأً أن مركز الضمان الحر لعنوان "from" هو 0، وهو ما يُعتبر صحيحاً، وبالتالي يمر عبر الفحص النهائي في دالة mintfCashPair.

5. بعد ذلك، تلقى العقد 2 وقسّم مراكزه إلى عقدين مساعدين آخرين، وستدخل كلا عمليتي safeTransferFrom هذه إلى دالة mintfCashPair. لكن نظرًا لأن الدافع في هذه المرحلة هو العقد 2 الذي تلقى المبالغ المُستَحقة في عملية الصك الثانية، والذي يمتلك مركز إيرادات متوقع ضخم حصل عليه في الخطوة السابقة، فإن حساب المخاطر يصنفه كدين إيجابي، مما يسمح له بتجاوز فحص الضمانات، وبالتالي حصل العقدان المساعدان على receiver fCash القابل للتحويل إلى نقد عند الاستحقاق.

6. ثم قام المهاجم بتنفيذ صفقة ربح ثانية رسمية، حيث تم تسوية الديون المقابلة لوضعي الاحتفاظ في الصفقة السابقة، وزيادة الرصيد النقدي للأصول المقابلة، ثم استدعاء دالة withdraw في عقد Escrow لسحب أصوله وتحقيق الربح.
Summary
النقطة الرئيسية في حادثة الهجوم هي أن المهاجم استخدم أولاً مركزين منفصلين لجمع مجموع ديون المدفوع ليصل إلى 2^128، ثم جعل Escrow يحول هذا الدين إلى ETH. واستغل ثغرة التدفق الناتجة عن تحويل النوع لقص النتيجة إلى صفر، مما سمح لفحص المخاطر بالمرور دون اكتشاف.
يوصي فريق أمان Slow Mist بأن يقوم مقدمو المشاريع بإجراء فحص نطاق قبل إجراء تحويل النوع، ورفض أي نتيجة تتجاوز type(uint128).max، وفي الوقت نفسه، يجب إضافة اختبارات للقيم القصوى لجميع الحدود المتعلقة بالمبالغ الموقعة وضبط الدقة وقيمة الأصول، خاصةً مع تغطية مدخلات مثل uint128.max و uint128.max + 1 والقيم السالبة المطلقة. يمكن الاستعانة بمخزن SafeCast من OpenZeppelin أو استخدامه لمعالجة التحويلات.
