ReentrancyGuard در سالیدیتی یک قفل ساده اما موثر است که با مودفایر nonReentrant جلوی ورود دوباره به تابع را می‌گیرد. برای کاهش ریسک حمله Reentrancy کافی است از قرارداد ReentrancyGuard ارث‌بری کنید، به توابع حساس nonReentrant بدهید و منطق را با الگوی Checks-Effects-Interactions (اول بررسی، بعد تغییر حالت، سپس تعامل خارجی) بچینید.

Reentrancy چیست و چرا رخ می‌دهد؟

Reentrancy زمانی اتفاق می‌افتد که قرارداد شما قبل از نهایی کردن تغییرات حالت، به آدرس یا قراردادی خارجی اتر یا کنترل اجرا بدهد و همان قرارداد خارجی دوباره همان تابع (یا تابع مرتبط) را صدا بزند. نتیجه می‌تواند برداشت چندباره وجوه، دور زدن محدودیت‌ها یا تخلیه موجودی باشد.

نمونه کد آسیب‌پذیر (آموزشی)

در مثال زیر، اثرات بعد از تعامل خارجی اعمال می‌شوند؛ همین ترتیب اشتباه، دریچه حمله را باز می‌کند.

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

contract VulnerableBank {
    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");
        // تعامل خارجی: کنترل به msg.sender منتقل مي‌شود
        (bool ok, ) = payable(msg.sender).call{value: amount}("");
        require(ok, "send failed");
        // اثرات با تاخير
        balances[msg.sender] = 0;
    }
}

مهاجم با قراردادی که در fallback یا receive دوباره withdraw را صدا می‌زند، قبل از صفر شدن موجودی، چندین بار برداشت را تکرار می‌کند.

اصلاح با ReentrancyGuard و الگوی CEI

در نسخه امن، اول اثرات را اعمال می‌کنیم، بعد تعامل خارجی را انجام می‌دهیم و برای اطمینان، nonReentrant هم اضافه می‌کنیم.

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

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

contract SafeBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

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

    // امن‌تر: nonReentrant + CEI
    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "no balance");

        // Effects: حالت را قبل از تعامل خارجی به‌روز کنید
        balances[msg.sender] = 0;

        // Interactions: سپس ارسال اتر
        (bool ok, ) = payable(msg.sender).call{value: amount}("");
        require(ok, "transfer failed");
    }
}

ReentrancyGuard با یک قفل داخلی، هر تلاش برای ورود دوباره به هر تابع nonReentrant همین قرارداد را رد می‌کند. ترکیب این قفل با الگوی CEI باعث می‌شود حتی اگر تماس خارجی رخ دهد، حالت بحرانی قبلا امن شده باشد.

الگوی تفکیک تابع بیرونی/درونی

اگر یک تابع nonReentrant باید منطق مشترک تابع دیگری را هم صدا بزند، الگوی رایج این است که تابع بیرونی nonReentrant باشد و کار را به یک تابع داخلی یا private بدون مودفایر بسپارد:

// الگوی پیشنهادی برای اجتناب از فراخوانی غيرمستقيم تابع nonReentrant
function withdraw() external nonReentrant {
    _withdraw(msg.sender);
}

function _withdraw(address to) private {
    uint256 amount = balances[to];
    require(amount > 0, "no balance");
    balances[to] = 0;
    (bool ok, ) = payable(to).call{value: amount}("");
    require(ok, "transfer failed");
}

نکات مهم و اشتباه‌های رایج در استفاده از nonReentrant

  • یک تابع nonReentrant را از تابع nonReentrant دیگر به صورت خارجی صدا نزنید؛ این کار به علت قفل فعال، ریورت می‌دهد. اگر نیاز دارید، منطق مشترک را به تابع private منتقل کنید.
  • ترتیب مودفایرها مهم است؛ معمولا قراردادن nonReentrant در ابتدای لیست مودفایرها باعث می‌شود مسیر خطا سریع‌تر متوقف شود و گس کمتری تلف شود.
  • به ارسال اتر با transfer/send تکیه نکنید. محدودیت گس ثابت آن‌ها و تغییرات شبکه باعث می‌شود نتوانند به تنهایی جلوی Reentrancy را بگیرند. از call به همراه nonReentrant و CEI استفاده کنید.
  • هر تابعی که به قراردادهای ناشناخته (مثل آدرس کاربر یا توکن‌های دارای هوک مانند ERC777) «کنترل» می‌دهد باید از نظر Reentrancy بررسی شود. روی چنین توابعی nonReentrant بگذارید یا پرداخت را به الگوی برداشت (withdraw) موکول کنید.
  • receive/fallback را اگر تغییردهنده حالت هستند یا فراخوانی خارجی دارند، با قفل محافظت کنید یا منطق را ساده و بدون تعامل خارجی نگه دارید.
  • ReentrancyGuard فقط از ورود مجدد به «همین قرارداد» جلوگیری می‌کند. اگر در زنجیره‌ای از قراردادها هستید، هر کدام باید به‌طور مستقل امن باشند.
  • در قراردادهای قابل ارتقا (Upgradeable)، از نسخه مربوطه ReentrancyGuardUpgradeable استفاده کنید و الگوی initialize را رعایت کنید.

جایگزین‌ها و تکمیل راهکار: چه زمانی nonReentrant کافی نیست؟

  • الگوی Pull Payment (پرداخت قابل برداشت): به جای ارسال اتر داخل همان تراکنش، مبلغ را برای کاربر ثبت کنید تا خودش بعدا با یک فراخوانی مجزا برداشت کند. این کار سطح تماس‌های خارجی را کم می‌کند و تست‌پذیری را بالا می‌برد. راهنمای گام‌به‌گام را در آموزش Pull Payment در سالیدیتی ببینید.
  • کاهش سطح اعتماد: تا حد امکان به قراردادهای ناشناخته اتر یا کنترل ندهید. اگر لازم است، مقدارهای کوچک و با محدودکردن مسیر اجرا این کار را انجام دهید.
  • اعتبارسنجی ورودی و محدودسازی دسترسی: حتی با nonReentrant، بررسی مجوزها، حدهای روزانه/تراکنشی و invariantها ضروری است.

چگونه در عمل آسیب‌پذیری Reentrancy را تست کنیم؟

  1. نقاط تماس خارجی را لیست کنید: هر جا از call، delegatecall، توابع انتقال توکن یا فراخوانی قرارداد دیگر استفاده شده است.
  2. ترتیب منطق را بازبینی کنید: در هر تابع، قبل از تماس خارجی، همه موجودی‌ها و شمارنده‌های بحرانی را به‌روز کنید.
  3. با یک قرارداد مهاجم ساده، تست واحد بنویسید: مهاجم در fallback/receive دوباره همان تابع را صدا بزند. اگر بدون nonReentrant و بدون CEI بتوانید چند بار برداشت کنید، آسیب‌پذیری تایید می‌شود.
// نمونه مینیمال مهاجم آموزشی
pragma solidity ^0.8.20;

interface IBank {
    function deposit() external payable;
    function withdraw() external;
}

contract Attacker {
    IBank public target;
    bool public attacking;

    constructor(address _target) {
        target = IBank(_target);
    }

    function attack() external payable {
        require(!attacking, "already");
        attacking = true;
        target.deposit{value: msg.value}();
        target.withdraw(); // شروع Reentrancy
        attacking = false;
    }

    receive() external payable {
        if (address(target).balance > 0) {
            target.withdraw(); // ورود دوباره
        }
    }
}

وقتی نسخه امن با nonReentrant و CEI را تست کنید، فراخوانی مجدد در receive مهاجم ریورت می‌شود و برداشت دوباره ممکن نیست.

سناریوی رایج: بازپرداخت در حراج

در قرارداد حراج، اگر به پیشنهاددهنده قبلی در همان لحظه پیشنهاد جدید، اتر را مستقیما برگردانید، ریسک Reentrancy بالا می‌رود. راه حل امن این است که بازپرداخت‌ها را ذخیره کنید تا کاربر بعدا برداشت کند (Pull Payment) یا از nonReentrant و CEI استفاده کنید. برای ساخت یک حراج کامل و امن، به راهنمای Auction Contract با سالیدیتی از صفر تا دیپلوی مراجعه کنید.

چه زمانی از ReentrancyGuard استفاده کنیم؟

  • هر زمان که تابع شما اتر می‌فرستد یا می‌تواند به کد ناشناخته کنترل بدهد.
  • هنگامی که با توکن‌های دارای هوک (مثل ERC777) یا کانترکت‌های پلاگین‌پذیر کار می‌کنید.
  • وقتی در حال پیاده‌سازی الگوهایی هستید که امکان تماس برگشتی (callback) دارند.

گام بعدی

اگر با مفاهیم پایه سالیدیتی آشنا هستید و می‌خواهید امنیت قراردادها را همراه با پروژه‌های عملی بسازید، مسیر منسجم را در آموزش جامع سالیدیتی ادامه دهید. برای پرداخت‌های امن نیز الگوی برداشت را از آموزش Pull Payment در سالیدیتی تمرین کنید.