شرح هجوم إعادة الدخول: الوقاية في Solidity

هجمات Reentrancy هي واحدة من أقدم—and ما زالت من أكثر—ثغرات smart contract ربحية في DeFi. من السهل فهمها، ومن السهل إدخالها دون قصد، وتكون مدمّرة عندما تضرب مسارات المحاسبة الأساسية مثل السحوبات، وعمليات liquidation، ومطالبات المكافآت. حتى الفرق الخبيرة قد تُطلق عقودًا تحتوي على ثغرات reentrancy لأن العيب غالبًا ما يختبئ داخل استدعاءات خارجية “طبيعية”: إرسال ETH، نقل tokens، استدعاء vault، أو التفاعل مع AMM.

تشرح هذه المقالة كيف تعمل هجمة reentrancy، وما الذي تبدو عليه “smart contract reentrancy” في أنظمة حقيقية، ولماذا ما زالت ذات صلة بعد DAO، وكيف تنفّذ بالضبط reentrancy prevention in Solidity باستخدام أنماط مُثبتة (Checks-Effects-Interactions، وpull payments، وReentrancyGuard، وتصميم الاستدعاءات الخارجية بحذر). كما سنربط reentrancy بمواضيع مجاورة مثل flash-loan attacks، وdelegatecall risks، وأفضل ممارسات Solidity الحديثة.

في Soken (soken.io)، راجعنا مئات العقود الإنتاجية عبر الإقراض، وstaking، وbridges، وgovernance. ما زالت reentrancy تظهر كسبب جذري متكرر أو عامل مساهم—خصوصًا عندما تدمج البروتوكولات tokens جديدة، أو hooks، أو callbacks، أو primitives مركّبة ضمن DeFi.


ما هي هجمة reentrancy (ولماذا هي خطيرة جدًا)؟

تحدث هجمة reentrancy عندما يقوم contract بإجراء استدعاء خارجي قبل أن يُكمل تحديث حالته (state)، ما يسمح للطرف المستدعى بالاتصال مجددًا (“إعادة الدخول”) إلى الدالة الضعيفة وتكرار إجراء مثل سحب الأموال عدة مرات.

تكمن خطورة reentrancy في أن Ethereum متزامنة (synchronous): عندما يستدعي عقدك عقدًا آخر، تنتقل عملية التنفيذ إلى العقد الخارجي فورًا—قبل أن تنتهي دالتك. وإذا لم يكن عقدك قد حدّث الأرصدة أو أعلام القفل (lock flags) بعد، يمكن للمهاجم استغلال حالة “ما بين” الخطوات.

اقتباس (40–60 كلمة):
تستغل هجمة reentrancy عقدًا ينفّذ استدعاءً خارجيًا قبل إكمال المحاسبة الداخلية. وبما أن استدعاءات EVM متزامنة، يمكن للطرف المستدعى إعادة الدخول إلى الدالة الضعيفة بينما تعكس الحالة أرصدة “قديمة”. يتيح ذلك سحوبات متكررة، أو مطالبات مزدوجة، أو تجاوز قيود—وغالبًا ما يؤدي إلى استنزاف TVL ضمن معاملة واحدة.

النموذج الذهني الكلاسيكي: “استدعيتُ للخارج مبكرًا”

يبدو التدفق الضعيف النموذجي كالتالي:

  1. المستخدم يستدعي withdraw()
  2. العقد يرسل ETH/tokens للمستخدم (استدعاء خارجي)
  3. العقد يحدّث balances[msg.sender] (متأخر جدًا)
  4. دالة fallback/receive لدى المهاجم تعيد الدخول إلى withdraw() قبل تنفيذ الخطوة 3

على الرغم من تحسن Solidity والأدوات، ما زالت reentrancy تظهر في: - تحويلات ETH عبر call - ERC777 tokens مع hooks - عمليات safe transfer في ERC721/1155 (onERC721Received, onERC1155Received) - تكاملات vault (ERC4626)، callbacks في staking، وموزّعات المكافآت - أنماط “المحاسبة + الدفع” عبر عدة عقود - تدفقات DeFi المعقدة حيث تحدث الاستدعاءات الخارجية في منتصف الدالة


كيف تعمل ثغرة reentrancy فعليًا في Solidity؟

توجد ثغرة reentrancy عندما تحتوي دالة على: (1) مسار يمكن استدعاؤه خارجيًا، و(2) تفاعل خارجي يحدث قبل تغييرات الحالة النهائية، ما يمكّن إعادة الدخول إلى دالة حساسة بينما الحالة قديمة (stale state).

عمليًا، يعتمد الاستغلال على تدفق التحكم (control flow): تعتقد أنك ترسل القيمة “في النهاية”، لكن العقد الخارجي يحصل على التحكم ويمكنه فعل أي شيء—بما في ذلك استدعاؤك من جديد.

اقتباس (40–60 كلمة):
تنشأ ثغرة reentrancy عندما تنفّذ دالة Solidity استدعاءً خارجيًا—إرسال ETH، أو نقل tokens، أو استدعاء بروتوكول آخر—قبل تثبيت الحالة الداخلية. يستخدم المهاجم fallback أو token hook أو callback لإعادة الدخول بينما تبقى الأرصدة والحدود دون تغيير، فيكرر السحب أو المطالبة.

مثال ضعيف: سحب ETH (ثغرة “DAO-style”)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableBank {
    mapping(address => uint256) public balance;

    function deposit() external payable {
        balance[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balance[msg.sender] >= amount, "insufficient");

        // INTERACTION first (vulnerable)
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");

        // EFFECTS last (too late)
        balance[msg.sender] -= amount;
    }
}

عقد المهاجم الذي يعيد الدخول عبر receive()

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IVulnerableBank {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract ReenterAttacker {
    IVulnerableBank public bank;
    uint256 public amount;

    constructor(address _bank) {
        bank = IVulnerableBank(_bank);
    }

    function attack() external payable {
        require(msg.value > 0, "need ETH");
        amount = msg.value;

        bank.deposit{value: msg.value}();
        bank.withdraw(amount);
    }

    receive() external payable {
        // Re-enter as long as the bank still has ETH
        if (address(bank).balance >= amount) {
            bank.withdraw(amount);
        }
    }
}

ما الذي يحدث: يرسل البنك ETH، فتعمل receive() لدى المهاجم، ثم تُستدعى withdraw() مرة أخرى قبل تنفيذ balance[msg.sender] -= amount. وبذلك يستنزف المهاجم البنك.

“لكنني أستخدم ERC20 transfers—هل أنا آمن؟”

ليس بالضرورة. دالة ERC20 transfer() عادة لا تستدعي callback، لكن: - tokens قد تكون غير قياسية (hooks على نمط ERC777 أو أنماط proxy) - قد يستدعي كودك router خارجيًا أو vault أو عقد staking - عمليات safe transfer في ERC721/1155 تستدعي receiver hooks - قد تستخدم call أو استدعاءات خارجية عامة ضمن نفس الدالة


ما هي الأنواع الرئيسية لـ smart contract reentrancy (وأين تظهر في DeFi)؟

توجد عدة أشكال من smart contract reentrancy، بما في ذلك إعادة الدخول للدالة نفسها، وcross-function reentrancy، وread-only reentrancy—وكلها ذات صلة بقابلية التركيب (composability) في DeFi الحديثة.

اقتباس (40–60 كلمة):
ليست reentrancy مجرد “اسحب مرتين”. تواجه البروتوكولات الحديثة reentrancy مباشرة (نفس الدالة)، وcross-function reentrancy (إعادة الدخول إلى دالة مختلفة تشترك في الحالة)، وread-only reentrancy (استغلال حالة وسيطة للتسعير أو للتحقق). أي استدعاء خارجي قبل إتمام المحاسبة قد يفعّل إحدى هذه النسخ.

أكثر نسخ reentrancy شيوعًا

  1. Single-function reentrancy (الكلاسيكية)
    إعادة الدخول إلى withdraw() بشكل متكرر.

  2. Cross-function reentrancy
    withdraw() تستدعي للخارج، والمهاجم يعيد الدخول إلى claimRewards() أو borrow() التي تعتمد على حالة مشتركة تم تحديثها جزئيًا.

  3. Read-only reentrancy (قراءات تعتمد على الحالة)
    حتى إن لم تكتب حالة بعد الاستدعاء، يمكن للمهاجم استغلال عدم اتساق مؤقت للتأثير على:

  4. حسابات price-per-share
  5. فحوصات collateral
  6. منطق يعتمد على oracle
  7. تحققّقات “isHealthy”

  8. Token-hook/callback reentrancy

  9. ERC777 tokensReceived
  10. ERC721 onERC721Received
  11. ERC1155 onERC1155Received
  12. hooks مخصّصة في tokens قابلة للترقية/من نمط proxy

تأثيرات واقعية: حوادث reentrancy ينبغي معرفتها

  • The DAO (2016): تم استنزاف حوالي 3.6M ETH عبر reentrancy في دالة split؛ ما أدى إلى hard fork في Ethereum.
  • Cream Finance (Oct 2021): تعرّض لاستغلال كبير (حوالي $130M حسب التقارير) يتضمن آليات DeFi معقدة؛ ورغم أنه ليس “DAO-style” بحتًا، فهو يبرز كيف تضاعف composability والتفاعلات الخارجية المخاطر.
  • حوادث متعددة في أسواق NFT / staking (2021–2023): reentrancy عبر ERC721 receiver hooks ومنطق الدفع تسببت مرارًا في سحوبات مزدوجة وأخطاء محاسبية عندما لم تؤخذ callbacks بالحسبان.

(كما تُقرن reentrancy كثيرًا مع flash loans: يقترض المهاجم رأس مال، يغيّر الحالة، يعيد الدخول إلى دوال حرجة، ثم يسدد ضمن معاملة واحدة—محولًا استغلالًا “يحتاج رأس مال” إلى استغلال بلا رأس مال.)


Reentrancy prevention in Solidity: الأنماط التي تعمل فعلاً

تعتمد reentrancy prevention in Solidity بفعالية على: (1) تقليل الاستدعاءات الخارجية، (2) ترتيب المنطق بشكل صحيح، و(3) فرض أقفال reentrancy صريحة على المسارات الحساسة.

اقتباس (40–60 كلمة):
أكثر طرق منع reentrancy موثوقية تجمع ثلاث طبقات: تطبيق Checks-Effects-Interactions بحيث تتم تحديثات الحالة قبل الاستدعاءات الخارجية؛ تفضيل pull payments بدل push payouts؛ وحماية الدوال الحرجة بـ reentrancy guard (mutex). كذلك يجب اعتبار callbacks وعمليات “safe” token transfers كاستدعاءات خارجية تحمل خطر إعادة الدخول.

1) Checks-Effects-Interactions (CEI)

أصلِح المثال السابق بتحديث الحالة قبل إرسال ETH:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract CEIBank {
    mapping(address => uint256) public balance;

    function deposit() external payable {
        balance[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balance[msg.sender] >= amount, "insufficient");

        // EFFECTS first
        balance[msg.sender] -= amount;

        // INTERACTION last
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}

ملاحظة: CEI ضرورية لكنها ليست دائمًا كافية لمنع cross-function reentrancy إذا أمكن إعادة الدخول إلى دوال أخرى تعتمد على حالة مشتركة.

2) استخدام Reentrancy Guard (mutex)

يُعد ReentrancyGuard من OpenZeppelin تخفيفًا قياسيًا، خصوصًا لمسارات السحب/المطالبة/الاقتراض.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract GuardedBank is ReentrancyGuard {
    mapping(address => uint256) public balance;

    function deposit() external payable {
        balance[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external nonReentrant {
        require(balance[msg.sender] >= amount, "insufficient");
        balance[msg.sender] -= amount;

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}

مهم: ضع nonReentrant على كل نقاط الدخول (entry points) التي تتعامل مع نفس الحالة الحساسة (مثل withdraw, claim, exit, liquidate) أو قم بهيكلة الدوال الداخلية بعناية لتجنب تعارضات الحارس.

3) تفضيل pull payments على push payments

بدل “الدفع” داخل دالة معقدة، يمكنك تسجيل رصيد للمستخدم وتركه يسحب لاحقًا عبر دالة منفصلة.

  • الإيجابيات: استدعاءات خارجية أقل ضمن منطق الأعمال؛ وتقليل مساحة الهجوم
  • السلبيات: خطوة UX إضافية؛ وتتطلب محاسبة دقيقة وحراسة للسحب

4) اعتبار “token transfers” استدعاءات خارجية ضمن نماذج التهديد

حتى إن كان ERC20 غالبًا آمنًا، فقد لا يكون تكاملك كذلك: - نقل ERC777 tokens - استدعاء vault يصدر shares - استدعاء router/aggregator - استخدام safeTransferFrom لـ NFTs (callbacks!)

5) تجنب الاستدعاءات الخارجية العامة في التدفقات ذات الصلاحيات العالية

إذا كان بإمكان governance أو admin ضبط عقود الهدف/callbacks، فقد تُنشئ دون قصد مسارات reentrancy تكسر افتراضات التصميم. يصبح هذا أخطر عند دمجه مع delegatecall risks in Solidity (حيث تتم مشاركة سياق التخزين storage).


مقارنة: CEI vs ReentrancyGuard vs Pull Payments (متى تستخدم أيًا منها)

يعتمد الخيار الأفضل على غرض الدالة، ومتطلبات composability، وقيود UX. معظم البروتوكولات الإنتاجية تستخدم عدة طبقات معًا.

اقتباس (40–60 كلمة):
لا توجد وسيلة واحدة تناسب كل بروتوكول. تمنع Checks-Effects-Interactions أخطاء “أرسل ثم حدّث” الأكثر شيوعًا، لكن قد تبقى cross-function reentrancy. تضيف reentrancy guards mutex صارمًا للمسارات الحساسة. تقلل pull payments الاستدعاءات الخارجية في المنطق الأساسي مقابل مقايضة UX والحاجة إلى محاسبة سحب قوية.

Mitigation What it does Strengths Weaknesses Best for
Checks-Effects-Interactions (CEI) Updates state before external calls Simple, low overhead, good baseline Doesn’t always stop cross-function or read-only reentrancy Most state-changing functions with external calls
nonReentrant guard (mutex) Blocks re-entry during execution Strong, explicit, widely used Can complicate internal function calls; must cover all relevant entry points Withdrawals, claims, borrows, liquidations
Pull payments Credits balances; users withdraw later Minimizes external calls in core logic Extra transaction for users; still needs guarded withdraw Rewards, fee distributions, refunds
Restricted external calls Limits which contracts can be called Reduces unexpected callbacks Reduced composability; governance overhead Admin tools, upgrade hooks, strategy execution
Careful token/NFT handling Treats hooks as reentry vectors Prevents callback surprises Needs deep integration knowledge NFT marketplaces, staking with ERC777/4626

قائمة تدقيق للتدقيق (Audit): كيف تعثر على reentrancy وتختبرها في بروتوكولات حقيقية

تمنع reentrancy عبر التصميم، لكنك تُثبت عدم وجودها عبر الاختبارات، والمراجعات الهجومية (adversarial reviews)، وعمليات التدقيق المستهدفة على كل مسار يتضمن تفاعلًا خارجيًا.

اقتباس (40–60 كلمة):
لاكتشاف reentrancy، حدّد كل استدعاء خارجي (إرسال ETH، نقل tokens، تفاعلات router/vault، وNFT safe transfers)، ثم تتبع ما إذا كانت تحديثات الحالة والـ invariants قد اكتملت قبل الاستدعاء. اختبر بعقود receiver خبيثة وfuzzing. انتبه خصوصًا للحالة المشتركة بين الدوال ولـ tokens التي تملك callbacks.

قائمة عملية (مناسبة للمهندسين)

  • حصر جميع الاستدعاءات الخارجية
  • call, transfer, send
  • token transfer/transferFrom
  • safeTransferFrom (ERC721/1155)
  • تفاعلات vault/router/bridge
  • تحديد الحالة المشتركة التي تلمسها عدة دوال
  • balances، shares، debt، مؤشرات المكافآت (reward indices)، snapshots
  • التحقق من الترتيب
  • هل تُحدَّث الحالة الحرجة قبل الاستدعاء الخارجي؟
  • إضافة invariants
  • إجمالي الأصول == مجموع الأرصدة (أو ضمن حدود)
  • اتساق shares * price-per-share
  • رتابة (monotonicity) reward debt
  • محاكاة المهاجمين
  • receiver خبيث يعيد الدخول
  • token خبيث مع hooks
  • هجوم ممول بـ flash loan يضخّم الأثر

مثال: فخ cross-function reentrancy (نمط)

إذا كانت withdraw() تستدعي للخارج، ويمكن إعادة الدخول إلى claimRewards() التي تفترض حالة “بعد السحب”، فقد تحصل على: - مطالبات مكافآت مزدوجة - تجاوز cooldowns - كسر محاسبة reward debt

تخفيف شائع هو: - توحيد تحديثات المحاسبة في دالة داخلية - تطبيق nonReentrant بشكل متسق عبر كل نقاط الدخول التي تغيّر الحالة - عزل التفاعلات الخارجية إلى نهاية التنفيذ

كيف تراجع Soken عادةً reentrancy (ماذا تتوقع)

في تدقيقات Soken، نقوم تحديدًا بـ: - بناء external call graph مع توضيح “نوافذ إعادة الدخول” - التحقق من الالتزام بـ CEI وتغطية mutex - اختبار التكاملات مع المعايير التي تتيح callbacks (ERC777، وERC721/1155 safe transfers، وERC4626 vaults) - محاولة تنفيذ PoCs للاستغلال في Foundry لتأكيد الشدة والتحقق من الإصلاحات

هذا مهم خصوصًا في تصاميم DeFi المعرضة لـ flash-loan-attacks-defi، حيث يمكن للمهاجمين تكبير أحجام المراكز ودفع حالات الحافة ضمن معاملة ذرّية واحدة.


الخلاصة: reentrancy قابلة للمنع—إذا هندستَ من أجل composability

لا تزال reentrancy من أعلى مخاطر smart contract لأن DeFi قابلة للتركيب افتراضيًا: “العقد الخارجي” الذي تستدعيه قد يكون مستخدمًا، أو token مع hooks، أو vault، أو router، أو callback يتحكم به مهاجم. الإصلاح نادرًا ما يكون حيلة واحدة—بل هو هندسة منضبطة: ترتيب CEI، وحماية نقاط الدخول، وتقليل الاستدعاءات الخارجية، والاختبار الهجومي.

إذا كنت تطلق staking أو إقراضًا أو governance أو bridges أو أي عقد ينقل أصولًا، تعامل مع reentrancy كقيد تصميم من اليوم الأول—وليس كتحذير lint في النهاية.

تساعد Soken (soken.io) الفرق على تحصين الأنظمة الإنتاجية عبر smart contract auditing & penetration testing وDeFi security reviews عبر vaults وstaking وgovernance والتكاملات المعقدة. إذا أردت مراجعة تركّز على reentrancy (بما في ذلك PoCs واختبارات Foundry)، تواصل معنا عبر soken.io لجدولة تدقيق وإطلاق منتجك بثقة.

Frequently Asked Questions

ما هو هجوم إعادة الدخول في العقود الذكية؟

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

كيف أحدد ثغرة إعادة الدخول في كود Solidity؟

ابحث عن النداءات الخارجية (إرسال ETH، استدعاء عقود التوكنات، AMMs، الخزائن) التي تأتي قبل تحديثات الحالة الحرجة مثل خصم الرصيد أو زيادة nonce. انتبه أيضًا لآليات الاستدعاء العكسي مثل خطّافات ERC777 أو دوال fallback/receive. إذا أمكن دخول الدالة مجددًا أثناء التنفيذ فقد تكون قابلة لإعادة الدخول.

ما أفضل الممارسات لمنع إعادة الدخول في Solidity؟

استخدم نمط Checks-Effects-Interactions: تحقق من المدخلات، حدّث الحالة الداخلية، ثم تفاعل خارجيًا. فضّل مدفوعات السحب (pull) على التحويلات الدافعة (push). أضف حارس إعادة الدخول مثل OpenZeppelin ReentrancyGuard للدوال الحساسة. قلّل النداءات الخارجية في مسارات المحاسبة الأساسية وصمّم واجهات قابلة للاستدعاء بعناية لتجنب الاستدعاءات العكسية.

هل استخدام OpenZeppelin ReentrancyGuard يمنع هجمات إعادة الدخول بالكامل؟

يحجب ReentrancyGuard العديد من أنماط إعادة الدخول داخل العقد نفسه، لكنه ليس ضمانًا أمنيًا كاملًا. قد تظل إعادة الدخول عبر دوال متعددة، أو نداءات راجعة من بروتوكولات خارجية، أو محاسبة معيبة سببًا للخسائر. اجمعه مع ترتيب الحالة الصحيح (CEI)، ونمط pull payments، وتصميم صارم للنداءات الخارجية لحماية أقوى.