أولاً، سأساعدك على فهم ما هو هجوم إعادة الدخول بطريقة بسيطة وكيف يمكنك منعه، وبعد ذلك، سأتعمق في أمثلة التعليمات البرمجية لإظهار أين توجد نقاط الضعف، وما هو رمز المهاجم والأهم من ذلك أنني سأعرض لك أحدث الطرق التي تم التحقق منها لحماية ليس فقط عقدًا واحدًا بل جميع العقود الذكية في مشروعك.

حرق: إذا كنت قد سمعت بالفعل عن معدل NonReentrant()، فاستمر في القراءة لأنك على وشك اكتشاف بضعة أسطر أسفل معدل globalNonReentrant() ونمط التحقق من التأثيرات والتفاعلات.



في الصورة أعلاه لدينا ContractA وContractB. والآن، كما تعرف، يمكن لعقد ذكي أن يتفاعل مع عقد ذكي آخر، مثلما في هذا المثال: يمكن لـ ContractA استدعاء ContractB. لذا فإن الفكرة الأساسية لإعادة الدخول هي أن ContractB قادر على العودة (call back) إلى ContractA بينما ما يزال ContractA ينفّذ.

فكيف يمكن للمهاجم استخدام ذلك؟

لدينا هنا ContractA يحتوي على 10 إيثر، ونرى أن ContractB قام بتخزين 1 إيثر داخل ContractA. في هذه الحالة، سيستطيع ContractB استخدام دالة withdraw من ContractA وإرسال الإيثر إلى نفسه مجددًا، لأنه يمر عبر شرط أن رصيده أكبر من 0، ثم يتم تعديل إجمالي رصيده ليصبح 0.



لنر الآن كيف يمكن لـ ContractB استخدام إعادة الدخول لاستغلال دالة withdraw وسرقة كل الإيثر من ContractA. بشكل أساسي، سيحتاج المهاجم إلى دالتين: attack() و fallback().

في Solidity، دالة fallback هي دالة خارجية (external) بدون اسم ودون معاملات (parameters) أو قيم إرجاع. يمكن لأي شخص استدعاء دالة fallback عبر: استدعاء دالة غير موجودة داخل العقد؛ استدعاء دالة دون تمرير البيانات المطلوبة؛ إرسال الإيثر إلى العقد دون أي بيانات.

طريقة عمل إعادة الدخول (لنَتبع الأسهم خطوة بخطوة) هي أن المهاجم يستدعي دالة attack() والتي يقوم بداخلها باستدعاء دالة withdraw() من ContractA. داخل الدالة، ستتحقق مما إذا كان رصيد ContractB أكبر من 0، وإذا كان كذلك فسيستمر تنفيذ الكود.



بما أن رصيد ContractB أكبر من 0، فإنه يرسل ذلك الـ 1 إيثر إلى الوراء ويُشغّل دالة fallback. لاحظ أنه في هذه اللحظة لدى ContractA 9 إيثرات ولدى ContractB بالفعل 1 إيثر.



بعد ذلك، عندما يتم تنفيذ دالة fallback، فإنها تُشغل مرة أخرى دالة ContractA withdraw، لتعيد التحقق مما إذا كان رصيد ContractB أكبر من 0. إذا راجعت الصورة أعلاه مرة أخرى فستلاحظ أن رصيده لا يزال 1 إيثر.



هذا يعني أن الشيك يمرّ، فيرسل إيثرًا آخر إلى ContractB، والذي بدوره يستدعي دالة fallback. لاحظ أنه بما أن السطر الذي لدينا فيه “balance=0” لن يتم تنفيذه أبدًا، فسيستمر ذلك حتى يختفي كل الإيثر من ContractA.

___________

دعنا الآن نلقي نظرة على عقد ذكي يمكننا فيه تحديد إعادة الدخول باستخدام كود Solidity.



في عقد EtherStore، لدينا الدالة deposit() التي تخزن وتحدّث أرصدة المرسل، ثم لدينا الدالة withdrawAll() التي ستأخذ كل الأرصدة المخزنة مرة واحدة. الرجاء ملاحظة تنفيذ withdrawAll() حيث تتحقق أولًا باستخدام require أن الرصيد أكبر من 0، وبعد إرسال الإيثر مباشرةً، تترك لنهاية التنفيذ تحديث رصيد المرسل إلى 0.



لدينا هنا عقد Attack الذي سيستخدم إعادة الدخول لتفريغ عقد EtherStore من الإيثر. دعنا نحلل كوده:

  • في مُنشئه، سيمرر المهاجم عنوان EtherStore من أجل إنشاء نسخة (instance) وبالتالي يصبح قادرًا على استخدام دواله.

  • هناك نرى دالة fallback() التي سيتم استدعاؤها عندما يقوم EtherStore بإرسال الإيثر إلى هذا العقد. بداخلها ستستدعي withdraw من EtherStore طالما أن الرصيد يساوي 1 أو أكبر.

  • وبداخل دالة attack() لدينا منطق يستغل EtherStore. كما نرى، أولًا سنبدأ الهجوم عبر التأكد من أننا نملك كمية كافية من الإيثر، ثم سنقوم بإيداع 1 إيثر لكي يكون لدينا رصيد أكبر من 0 في EtherStore، وبالتالي نمر عبر الشيكات قبل بدء سحب (withdraw).

لقد شرحتُ أعلاه في مثال ContractA وContractB خطوة بخطوة كيف سيعمل الكود، لذا الآن، دعنا نلخص كيف ستكون العملية. أولًا سيستدعي المهاجم attack()، والتي بداخلها ستستدعي withdrawAll() من EtherStore، ثم سيقوم EtherStore بإرسال الإيثر إلى دالة fallback الخاصة بعقد Attack. ومن هناك سيبدأ إعادة الدخول وسيستنزف رصيد EtherStore.

إذًا كيف يمكننا حماية عقودنا من هجمات إعادة الدخول (reentrancy)؟

سأعرض لك ثلاث تقنيات وقائية لحمايتها بالكامل. سأغطي كيفية منع إعادة الدخول داخل دالة واحدة، وإعادة الدخول بين عدة دوال، وإعادة الدخول عبر العقود.



التقنية الأولى لحماية دالة واحدة هي استخدام مُعدل يسمى noReentrant.

المُعدل (modifier) هو نوع خاص من الدوال تستخدمه لتعديل سلوك الدوال الأخرى. تتيح لك المُعدلات إضافة شروط إضافية أو وظائف إلى دالة دون الحاجة إلى إعادة كتابة الدالة كاملة.

ما نفعله هنا هو قفل العقد (lock) أثناء تنفيذ الدالة. بهذه الطريقة لن يكون قادرًا على إعادة الدخول إلى الدالة نفسها لأن عليه المرور عبر كود الدالة ثم تغيير متغير حالة القفل (locked state variable) إلى false حتى يتمكن من اجتياز الشيك الموجود داخل require.

___________



التقنية الثانية تتم عبر استخدام نمط Checks-Effects-Interactions والذي سيحمي عقودنا من إعادة الدخول عبر الدوال (cross-function reentrancy). هل يمكنك ملاحظة ما الذي تغيّر في عقد EtherStore المحدّث أعلاه؟

للتعمق في نمط Check-Effects-Interaction أنصح بقراءة https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html





أعلاه نرى المقارنة بين الكود القابل للضعف الموجود في الصورة على اليسار حيث تم تحديث الرصيد بعد إرسال الإيثر؛ كما رأينا يمكن أن لا يتم الوصول إليه مطلقًا. وعلى اليمين، ما تم فعله هو نقل السطر balances[msg.sender] = 0 (أي effect) مباشرةً بعد require(bal > 0) (أي check) ولكن قبل إرسال الإيثر (interaction).

بهذا سنضمن أنه حتى إذا كانت دالة أخرى تستدعي withdrawAll()، فسيظل هذا العقد محميًا من المهاجم لأن الرصيد سيتم تحديثه دائمًا قبل إرسال الإيثر.

النمط تم إنشاؤه بواسطة https://twitter.com/GMX_IO

التقنية الثالثة التي سأعرضها لك هي إنشاء عقد GlobalReentrancyGuard لحمايتك من إعادة الدخول عبر العقود (cross-contract reentrancy). من المهم فهم أن هذا ينطبق على المشاريع التي تحتوي على عدة عقود تتفاعل مع بعضها البعض.

تكون الفكرة هنا مماثلة لتلك الموجودة في مُعدل noReentrant الذي شرحتُه في التقنية الأولى: يدخل المُعدل، ويقوم بتحديث متغير لقفل العقد، ولا يقوم بفتحه حتى ينتهي من تنفيذ الكود. والفرق الكبير هنا هو أننا نستخدم متغيرًا مخزنًا في عقد منفصل يُستخدم كالمكان للتحقق مما إذا كانت الدالة قد تم إدخالها أم لا.







لقد أنشأت هنا مثالًا بدون أي كود فعلي، فقط بأسماء الدوال للرجوع إليها لفهم الفكرة؛ لأنه من واقع خبرتي يمكن أن يساعد على تصور الموقف أكثر من مجرد كتابته بالكلمات.

هنا، سيقوم المهاجم باستدعاء الدالة داخل عقد ScheduledTransfer، والتي بعد استيفاء الشروط سترسل الإيثر المحدد إلى عقد AttackTransfer، والذي سيؤدي بالتالي إلى دخول دالة fallback، ومن ثم “إلغاء” المعاملة من وجهة نظر عقد ScheduledTransfer واستلام الإيثر رغم ذلك. وبهذه الطريقة سيبدأ في القيام بنظرة/دورة حتى استنزاف كل الإيثر من ScheduledTransfer.

حسنًا، باستخدام GlobalReentrancyGuard الذي ذكرته أعلاه، فسيتجنب سيناريو الهجوم هذا.

__________________

Twitter @TheBlockChainer للحصول على المزيد من التحديثات اليومية حول Smart Contracts وWeb3 Security وSolidity وعمليات تدقيق العقود الذكية والمزيد.

__________________