یکی از معروفترین آسیبپذیریهای دنیای قراردادهای هوشمند، Reentrancy است. این آسیبپذیری زمانی اتفاق میافتد که یک قرارداد، قبل از اینکه وضعیت داخلی خودش را بهروزرسانی کند، به یک آدرس خارجی call میزند و کنترل اجرای تراکنش را موقتاً به آن آدرس میدهد.
در ظاهر، این موضوع ساده به نظر میرسد؛ اما همین اشتباه یکی از تاریخیترین هکهای اتریوم، یعنی The DAO Hack در سال ۲۰۱۶ را رقم زد.
Reentrancy یعنی چی؟
فرض کنیم یک قرارداد Vault داریم که کاربران میتوانند داخل آن ETH واریز کنند و بعداً برداشت کنند.
منطق ساده است:
-
کاربر deposit میکند.
-
قرارداد مقدار واریزی او را در mapping ذخیره میکند.
-
کاربر withdraw میزند.
-
قرارداد ETH را برای او میفرستد.
-
سپس موجودی او را صفر میکند.
مشکل دقیقاً در مرحلهی چهارم و پنجم است.
اگر قرارداد اول ETH را بفرستد و بعد موجودی کاربر را صفر کند، یعنی در لحظهای که ETH برای کاربر ارسال میشود، هنوز موجودی او داخل storage معتبر است. اگر گیرنده یک قرارداد مخرب باشد، میتواند از داخل تابع receive یا fallback دوباره تابع withdraw را صدا بزند.
یعنی قبل از اینکه اجرای اول withdraw تمام شود، اجرای دوم withdraw شروع میشود.
به همین دلیل به آن Reentrancy میگوییم؛ یعنی ورود دوباره به قرارداد، قبل از پایان اجرای قبلی.
نمونهی سادهی کد آسیبپذیر
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 ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
balances[msg.sender] = 0;
}
}
در نگاه اول شاید کد درست به نظر برسد. قرارداد مقدار موجودی کاربر را میخواند، همان مقدار ETH برای او میفرستد و بعد موجودی را صفر میکند.
اما ترتیب عملیات اشتباه است.
خط خطرناک اینجاست:
(bool ok, ) = msg.sender.call{value: amount}("");
این خط فقط انتقال ETH نیست. اگر msg.sender یک قرارداد باشد، اجرای کد قرارداد گیرنده هم فعال میشود. یعنی قرارداد Vault کنترل اجرا را به بیرون میدهد.
در حالی که هنوز این خط اجرا نشده:
balances[msg.sender] = 0;
پس در لحظهی external call، موجودی مهاجم هنوز صفر نشده است.
قرارداد مهاجم چطور عمل میکند؟
مهاجم یک قرارداد میسازد که اول مقدار کمی ETH داخل Vault واریز میکند. سپس withdraw را صدا میزند.
وقتی Vault میخواهد ETH را به مهاجم برگرداند، تابع receive قرارداد مهاجم اجرا میشود. مهاجم از داخل همین receive دوباره withdraw را صدا میزند.
نمونهی ساده:
contract Attacker {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance > 0) {
vault.withdraw();
}
}
}
جریان حمله اینطور است:
-
مهاجم ۱ ETH داخل Vault واریز میکند.
-
Vault برای مهاجم
balances[attacker] = 1 ETHثبت میکند. -
مهاجم
withdrawرا صدا میزند. -
Vault مقدار
1 ETHرا میخواند. -
Vault قبل از صفر کردن موجودی، ETH را با
callبرای مهاجم میفرستد. -
تابع
receiveقرارداد مهاجم اجرا میشود. -
مهاجم دوباره
withdrawرا صدا میزند. -
چون موجودی هنوز صفر نشده، Vault دوباره ۱ ETH دیگر میفرستد.
-
این چرخه تا خالی شدن Vault ادامه پیدا میکند.
مشکل اصلی این نیست که call همیشه بد است. مشکل این است که قرارداد قبل از امن کردن state خودش، به بیرون call زده است.
مثال واقعی: The DAO Hack
یکی از معروفترین مثالهای واقعی Classic Reentrancy، هک The DAO در سال ۲۰۱۶ بود.
The DAO یک سازمان غیرمتمرکز سرمایهگذاری روی اتریوم بود. کاربران ETH واریز میکردند و در تصمیمگیریهای سرمایهگذاری مشارکت میکردند. اما در کد قرارداد، مسیری وجود داشت که مهاجم میتوانست قبل از اینکه موجودی یا وضعیت داخلیاش بهدرستی آپدیت شود، دوباره وارد قرارداد شود و برداشت را تکرار کند.
ایدهی حمله شبیه همین مثال ساده بود:
قرارداد The DAO ابتدا ETH را به مهاجم منتقل میکرد، اما قبل از اینکه وضعیت حساب مهاجم را کامل آپدیت کند، کنترل اجرا به قرارداد مهاجم برمیگشت. مهاجم از همان نقطه دوباره همان مسیر را صدا میزد و چندین بار برداشت انجام میداد.
در نتیجه، قرارداد قربانی با یک state قدیمی تصمیم میگرفت. از دید قرارداد، مهاجم هنوز حق برداشت داشت، چون آپدیت نهایی هنوز انجام نشده بود.
این همان قلب Classic Reentrancy است:
استفاده از state قدیمی، بعد از external call و قبل از update شدن storage.
چرا این باگ خطرناک است؟
Reentrancy خطرناک است چون در Solidity، وقتی به یک قرارداد خارجی call میزنیم، فقط پول یا توکن منتقل نمیکنیم؛ بلکه ممکن است کد طرف مقابل هم اجرا شود.
این یعنی هر external call میتواند یک نقطهی ورود مجدد باشد.
نمونههایی از جاهایی که باید حساس شویم:
msg.sender.call{value: amount}("")
token.safeTransfer(user, amount)
token.safeTransferFrom(user, address(this), amount)
externalContract.doSomething()
receiver.onERC721Received(...)
receiver.onERC1155Received(...)
flashLoanReceiver.executeOperation(...)
البته همهی اینها الزاماً آسیبپذیر نیستند. سؤال اصلی این است:
در لحظهای که این external call انجام میشود، چه stateهایی هنوز آپدیت نشدهاند؟
اگر جواب این باشد که موجودی، بدهی، سهم، reserve، collateral، یا وضعیت مهمی هنوز قدیمی است، باید احتمال reentrancy را جدی بررسی کرد.
راهحل: Checks-Effects-Interactions
معروفترین الگوی دفاعی در برابر Classic Reentrancy، الگوی Checks-Effects-Interactions است.
یعنی:
-
اول شرطها را بررسی کن.
-
بعد state داخلی را آپدیت کن.
-
در آخر external call بزن.
نسخهی امنتر تابع withdraw اینطوری است:
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
اینجا حتی اگر مهاجم از داخل receive دوباره withdraw را صدا بزند، دیگر موجودی او صفر شده است. پس اجرای دوم نمیتواند دوباره برداشت کند.
استفاده از ReentrancyGuard
راه دفاعی دیگر استفاده از nonReentrant است. این modifier اجازه نمیدهد یک تابع در حالی که هنوز اجرای قبلی آن تمام نشده، دوباره وارد شود.
مثلاً:
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
اما نکتهی مهم این است که nonReentrant جای فکر کردن را نمیگیرد. یک قرارداد امن باید هم ترتیب state update درستی داشته باشد، هم external callهایش با دقت بررسی شده باشند.
چطور به عنوان Hunter این باگ را پیدا کنیم؟
برای پیدا کردن Classic Reentrancy، دنبال این الگو بگرد:
external call
بعدش
state update
مثلاً:
user.call(...)
balances[user] = 0;
یا:
token.safeTransfer(user, amount);
userDebt[user] -= amount;
یا:
receiver.executeOperation(...);
burn(userShares);
بعد از پیدا کردن external call، این سؤالها را بپرس:
-
آیا قبل از call، موجودی کاربر کم شده؟
-
آیا debt یا collateral آپدیت شده؟
-
آیا share یا position هنوز معتبر است؟
-
آیا مهاجم از callback میتواند دوباره همین تابع را صدا بزند؟
-
آیا میتواند تابع دیگری را صدا بزند که به همان state وابسته است؟
-
آیا قرارداد از
nonReentrantاستفاده کرده؟ -
آیا external call به توکن، receiver، router، adapter یا callback قابل کنترل توسط کاربر است؟
Classic Reentrancy معمولاً از همین نقطه شروع میشود: یک state قدیمی، یک external call، و یک مسیر برگشت به قرارداد.
جمعبندی
Classic Reentrancy یکی از سادهترین اما تاریخیترین آسیبپذیریهای قراردادهای هوشمند است. ایدهی اصلی آن این است که قرارداد قبل از اینکه وضعیت داخلی خودش را آپدیت کند، کنترل اجرا را به یک قرارداد خارجی میدهد.
در هک The DAO، همین الگو باعث شد مهاجم بتواند قبل از نهایی شدن آپدیت state، چندین بار وارد قرارداد شود و برداشت را تکرار کند.
قانون ذهنی ساده است:
قبل از external call، state باید امن شده باشد.
اگر قراردادی اول پول یا توکن را ارسال میکند و بعد موجودی، بدهی، share یا position را آپدیت میکند، آن نقطه ارزش بررسی عمیق دارد.