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

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

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

بعد اكتمال سك الزوج، يقوم Portfolios فورًا بالتحقق من الضمان الحر للعقد المهاجم. وبما أن كمية السك الأولى كانت صغيرة جدًا، فإنه بعد تحويلها إلى ETH وفق السعر الحالي والدقة يتم تقريبها إلى صفر، لذا يمكن أن يمر الفحص الأول.
3. قام المهاجم لاحقًا مجددًا باستدعاء دالة safeTransferFrom في عقد ERC1155Trade لسكّ زوج fCash بمقدار uint128.max؛ وهذه المرة يكون cashGroupId للأصل السندي هو 2، لكن طابع زمن تاريخ الاستحقاق هو 1796256000 (3 ديسمبر 2026 الساعة 8 صباحًا).
هناك تفصيل: قام المهاجم بعمل سكّ لأصول سندية مختلفة بتاريخ استحقاق مختلف لكل من عنوانين مختلفين. وذلك لأن عند تحديث الالتزامات لعـنوان from، إذا كانت أصول السندات متطابقة فسيتم إضافتها مباشرة؛ وهذا يؤدي إلى revert عند معالجة الجمع بسبب overflow (حيث يستخدم العقد مكتبة safeMath).

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

يتم ذلك أولًا في دالة getRequirement داخل عقد RiskFrameork، حيث يتم جمع مجموع الالتزامات الناتج عن عمليتي السك لـ from:

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


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

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

6. بعد ذلك، قام المهاجم بإطلاق المعاملة الثانية الرسمية لتحقيق الربح. في هذه المعاملة، تم تسوية حقوق السندات المقابلة للمراكز التي تم الاحتفاظ بها في العقدين المساعدين في المعاملة السابقة، وزيادة أرصدة النقود للأصول المقابلة. بعد ذلك، قام باستدعاء دالة withdraw في عقد Escrow لسحب أصوله وتحقيق الربح منها.

ملخص
تتمثل النقطة المحورية في هذه الحادثة الهجومية في أن المهاجم يستخدم أولًا موقعين منفصلين لجمع إجمالي التزام payer ليصل إلى 2^128، ثم يجعل Escrow يحوّل هذا الالتزام إلى ETH. وبسبب خلل تجاوز (overflow) في تحويل النوع، يتم اقتطاع النتيجة لتصبح صفرًا؛ لذلك تُمنح فحوصات المخاطر الإذن بالمرور.
فريق الأمان SlowMist ينصح بأن يقوم المشروع بإجراء فحص نطاق قبل أي تحويل من نوع. يجب رفض أي نتيجة تتجاوز type(uint128).max مباشرةً. وفي الوقت نفسه، ينبغي إضافة اختبارات قيم حدّية (极值测试) لكل الحدود المتعلقة بالمبالغ الموقعة، وتقسيم/تصغير الدقة (precision scaling)، والأوجه المرتبطة بالأصول؛ وبالأخص تغطية حالات مثل uint128.max و uint128.max + 1 وقيمة سالبة لِـ absolute value. يمكن الرجوع إلى مكتبة Openzeppelin SafeCast أو استخدامها لمعالجة التحويلات.

