أمن العقود الذكية (3): ثغرة التلاعب بالأرقام الصحيحة (Integer Overflow/Underflow)
أمن العقود الذكية (3): ثغرة التلاعب بالأرقام الصحيحة Integer Overflow/Underflow
تُعد ثغرة Integer Overflow وInteger Underflow من أشهر الأخطاء التاريخية في تطوير Smart Contracts. خطورتها لا تأتي من تعقيدها، بل من بساطة العمليات الحسابية التي تبدو آمنة ظاهرياً، بينما تؤدي عملياً إلى قلب منطق العقد بالكامل إذا لم يتم ضبط الحدود الرقمية بشكل صحيح.
في بيئة Blockchain لا توجد فرصة لتعديل قاعدة البيانات يدوياً بعد النشر، لذلك فإن خطأ جمع أو طرح واحد في رصيد أو عدّاد أو كمية Token قد ينتج عنه تضخم مزيف في الأرصدة أو تجاوز للقيود المنطقية. وإذا كنت قد قرأت أساسيات لغة Solidity: أنواع البيانات والمتغيرات (State Variables) فستتذكر أن اختيار نوع البيانات الرقمي ليس مجرد تفصيل نحوي، بل قرار أمني مباشر.
ما المقصود بـ Overflow و Underflow؟
الأعداد الصحيحة في Solidity تُخزَّن ضمن أحجام ثابتة مثل uint8 أو uint256. لكل نوع حد أعلى وحد أدنى. عندما نحاول تجاوز الحد الأعلى يحدث Overflow، وعندما ننقص قيمة أقل من الحد الأدنى يحدث Underflow.
في الإصدارات القديمة قبل Solidity 0.8.0 كانت هذه العمليات تلتف رقمياً Wrap Around. مثلاً إذا كان الحد الأعلى لـ uint8 هو 255 ثم أضفت 1 تصبح القيمة 0 بدل الفشل. والعكس صحيح مع الطرح من الصفر، حيث قد تتحول القيمة إلى رقم ضخم جداً.
كيف تتحول المشكلة إلى ثغرة حقيقية؟
المطور غالباً يستخدم العمليات الحسابية في إدارة الأرصدة، العدادات، حدود السك، وحساب الرسوم. إذا كان منطق العقد يعتمد على أن الطرح لا يمكن أن ينتج قيمة سالبة في نوع uint، فقد يستغل المهاجم هذا الافتراض ويحول الرصيد من 0 إلى قيمة هائلة عبر عملية طرح واحدة فقط.
هذا السيناريو كان شائعاً في عقود ERC-20 البدائية، خصوصاً عند كتابة دوال نقل أو حرق أو خصم أرصدة بدون حماية. ولأن هذه الدوال غالباً ترتبط أيضاً بالتحكم بالوصول وصحة المدخلات، فمن المفيد مراجعة مقال التعامل مع الأخطاء وإرجاع الأموال: استخدام require, assert, revert لفهم كيف يُبنى الشرط الوقائي قبل التنفيذ.
مثال عملي على Underflow في إصدار قديم
في المثال التالي نستخدم عقداً مبسطاً يعتمد على نسخة قديمة من Solidity. إذا حاول مستخدم سحب كمية أكبر من رصيده، فإن الرصيد لا يفشل تلقائياً، بل قد يلتف إلى قيمة كبيرة جداً.
pragma solidity ^0.7.6;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function unsafeWithdraw(uint256 amount) external {
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
المشكلة هنا أن الدالة unsafeWithdraw تطرح من رصيد المستخدم مباشرة دون التحقق من أن الرصيد يكفي. إذا كان الرصيد 0 وتم تمرير قيمة مثل 1 ether، فإن الناتج في الإصدارات القديمة قد يصبح رقماً هائلاً داخل balances[msg.sender].
لماذا يعد هذا خطيراً داخل EVM؟
- لأن الرصيد الداخلي في التخزين يصبح مزيفاً لكنه صالح منطقياً للعقد.
- لأن أي دوال لاحقة تعتمد على هذا الرصيد ستتعامل معه كقيمة صحيحة.
- لأن المهاجم قد يتجاوز قيود السحب أو التحويل أو الحرق أو السك.
- لأن الخطأ يحدث على مستوى الحساب نفسه، وليس في واجهة الاستخدام فقط.
مثال على Overflow في العدادات والكميات
لا تقتصر الثغرة على الأرصدة المالية. أحياناً يستخدم المطور عداداً بعدد صغير مثل uint8 لتقليل الاستهلاك أو لتنفيذ منطق محدود، لكن ذلك قد يفتح باب الالتفاف إذا وصل العداد إلى الحد الأقصى ثم زاد مرة إضافية.
pragma solidity ^0.7.6;
contract CounterOverflow {
uint8 public counter = 255;
function increment() external {
counter += 1;
}
}
بعد استدعاء increment ستعود القيمة إلى 0. إذا كان هذا العداد يمثل عدد التذاكر أو الحد الأقصى للسك أو ترتيب العمليات، فقد يتسبب الالتفاف في كسر المنطق التجاري للعقد بأكمله.
أفضل ممارسة أمنية هي افتراض أن أي عملية جمع أو طرح أو ضرب في العقد الذكي قد تكون نقطة هجوم محتملة، خصوصاً عندما تؤثر في الأرصدة أو العرض الكلي أو شروط الوصول إلى الدوال الحساسة.
كيف عالجت Solidity 0.8+ هذه المشكلة؟
ابتداءً من الإصدار 0.8.0 أصبحت العمليات الحسابية على الأعداد الصحيحة تتضمن فحصاً تلقائياً. إذا حدث Overflow أو Underflow فإن المعاملة تفشل تلقائياً Revert. هذا التطور خفّض مساحة الهجوم بشكل كبير، لكنه لم يلغ الحاجة إلى كتابة منطق سليم.
السبب أن الحماية التلقائية تمنع الالتفاف الرقمي، لكنها لا تمنع الأخطاء المنطقية الأخرى مثل ترتيب العمليات الخاطئ أو استخدام متغيرات بحجوم غير مناسبة أو تصميم آلية حساب غير دقيقة. لذلك يبقى الفهم العميق لنموذج البيانات مهماً، خاصة عند بناء عقود رموز أو خزائن أو أنظمة توزيع مكافآت.
النسخة الآمنة من الكود
pragma solidity ^0.8.20;
contract SafeVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
في هذا المثال لدينا مستويان من الحماية. الأول أن الإصدار الحديث من Solidity سيرفض أي طرح غير صالح تلقائياً. والثاني أن الشرط require يجعل سبب الفشل واضحاً للمستخدم والمطور وأدوات الاختبار.
متى نستخدم unchecked؟
توفّر Solidity كتلة unchecked لتعطيل فحص الزيادة والنقصان في حالات محسوبة بهدف تقليل التكاليف (Gas Fees): كيف يحسب البلوكتشين تكلفة تنفيذ الأكواد؟. لكن استخدامها يجب أن يكون استثناءً مدروساً، لا قاعدة عامة.
function incrementSafe(uint256 x) external pure returns (uint256) {
require(x < type(uint256).max, "Max reached");
unchecked {
return x + 1;
}
}
استخدم
uncheckedفقط بعد وجود شرط سابق يثبت رياضياً استحالة تجاوز الحدود. أي استخدام عشوائي له قد يعيد الثغرة التاريخية بشكل مقصود أو غير مقصود.
دور OpenZeppelin في الوقاية
قبل Solidity 0.8 كان المطورون يعتمدون كثيراً على مكتبة SafeMath من استخدام مكتبة OpenZeppelin لكتابة عقود ذكية آمنة ومختبرة مسبقاً. هذه المكتبة تضيف طبقة تحقق صريحة قبل الجمع والطرح والضرب والقسمة. ومع أن الإصدارات الحديثة من اللغة دمجت هذا السلوك تلقائياً، إلا أن قيمة OpenZeppelin ما زالت كبيرة بسبب اعتماد عقود معيارية ومراجعة مجتمع واسعة.
كيف تختبر هذه الفئة من الثغرات عملياً؟
لا يكفي أن ينجح العقد في Remix IDE أو أن يترجم بدون أخطاء داخل إعداد مشروع Hardhat وكتابة أول سكربت JavaScript لترجمة (Compile) العقد الذكي. يجب كتابة اختبارات حدودية تتحقق من القيم الدنيا والعليا.
- اختبر الطرح من رصيد يساوي
0. - اختبر الجمع إلى قيمة تقترب من
type(uint256).max. - اختبر المدخلات غير المتوقعة في الحلقات والعدادات.
- استخدم اختبارات الوحدة كما في اختبار العقود الذكية محلياً: كتابة اختبارات الوحدة (Unit Tests) باستخدام Chai & Mocha.
- نفّذ مراجعة يدوية على كل موضع يحتوي
+أو-أو*.
أفضل الممارسات الوقائية
- اعتمد دائماً على إصدارات حديثة من
Solidityما لم يوجد سبب تقني قوي غير ذلك. - اكتب شروط تحقق صريحة حتى لو كانت اللغة ستفشل تلقائياً.
- لا تستخدم أحجاماً صغيرة مثل
uint8إلا عند الحاجة الدقيقة والمبررة. - راجع منطق الأرصدة والعرض الكلي والمكافآت بدقة مضاعفة في عقود
Tokens. - ادمج الاختبارات الحدودية مع المراجعة الأمنية الداخلية قبل النشر.
خلاصة الأمر أن ثغرة Integer Overflow/Underflow مثال واضح على أن أبسط العمليات الحسابية قد تتحول إلى باب اختراق كارثي داخل Smart Contracts. صحيح أن اللغة الحديثة أغلقت جزءاً كبيراً من الخطر تلقائياً، لكن الأمان الحقيقي ما زال يعتمد على فهم المطور، جودة الاختبارات، والانضباط في كتابة الشروط والمنطق. وفي سلسلة الأمن، بعد فهم ثغرة إعادة الدخول والحماية من ثغرة Reentrancy باستخدام ReentrancyGuard، تأتي هذه الثغرة لتؤكد أن الأمن ليس ميزة إضافية، بل أساس تصميم العقد الذكي منذ أول سطر برمجي.
3 comments