هک DAO و درس ۶۰ میلیون دلاری: راهنمای کامل جلوگیری از حملات بازورود در Solidity

هفدهم ژوئن ۲۰۱۶. جامعه نوپای اتریوم با کابوسی بیدار میشود که آینده این پلتفرم را برای همیشه تغییر داد. The DAO، بزرگترین پروژه سرمایهگذاری جمعی در تاریخ تا آن زمان و نماد آینده سازمانهای خودگردان، در حال خالی شدن بود. به صورت زنده، در مقابل چشمان هزاران سرمایهگذار، یک مهاجم ناشناس در حال برداشت مکرر میلیونها دلار اتر بود و هیچکس نمیتوانست جلوی آن را بگیرد. این یک سرقت عادی نبود؛ این خود قرارداد هوشمند بود که به عنوان سلاحی علیه خودش استفاده میشد. این داستان، داستان حمله بازورود (Reentrancy) است.
چه اتفاقی برای The DAO افتاد؟ روایت یک سرقت دیجیتال
The DAO (Decentralized Autonomous Organization) یک صندوق سرمایهگذاری جسورانه بود که بر بستر اتریوم ساخته شده بود. ایده انقلابی بود: به جای مدیران و هیئت مدیره، دارندگان توکنهای DAO به طور دموکراتیک تصمیم میگرفتند که سرمایه جمعآوری شده در کدام پروژهها سرمایهگذاری شود. این پروژه بیش از ۱۵۰ میلیون دلار اتر جمعآوری کرد که در آن زمان بیش از ۱۰٪ کل اتر در گردش بود.
اما در کد آن، یک نقص مرگبار پنهان بود. یک تابع به کاربران اجازه میداد تا سرمایه خود را از DAO خارج کنند. مهاجم راهی پیدا کرد تا این تابع را به طور مکرر فراخوانی کند، پیش از آنکه قرارداد فرصت کند موجودی حساب او را بهروزرسانی کند. در واقع، مهاجم بارها و بارها درخواست برداشت یک مبلغ ثابت را میداد و قرارداد، که حافظهاش هنوز آپدیت نشده بود، هر بار با این درخواست موافقت میکرد. نتیجه یک فاجعه بود: حدود ۳.۶ میلیون اتر، معادل ۶۰ میلیون دلار در آن زمان، به سرقت رفت و جامعه اتریوم را مجبور به یک تصمیم تاریخی و جنجالی کرد: یک «هارد فورک» که زنجیره را به دو بخش Ethereum (زنجیره جدید و اصلاحشده) و Ethereum Classic (زنجیره اصلی و دستنخورده) تقسیم کرد.
کالبدشکافی حمله: چگونه یک قرارداد به خودش خیانت میکند؟
برای درک دقیق این حمله، باید به زبان کد صحبت کنیم. حمله بازورود زمانی اتفاق میافتد که یک قرارداد (قربانی) تابعی را در یک قرارداد دیگر (مهاجم) فراخوانی میکند، پیش از آنکه وضعیت داخلی خود را تکمیل و بهروزرسانی کند. اگر قرارداد مهاجم طوری طراحی شده باشد که بتواند دوباره همان تابع را در قرارداد قربانی فراخوانی کند، یک حلقه مخرب ایجاد میشود.
این مانند یک کارمند بانک است که به شما پول نقد میدهد اما قبل از ثبت تراکنش در سیستم، تلفنش زنگ میخورد و حواسش پرت میشود. شما بلافاصله دوباره به باجه مراجعه کرده و همان مبلغ را درخواست میکنید. چون تراکنش قبلی هنوز ثبت نشده، او دوباره به شما پول میدهد و این چرخه ادامه پیدا میکند.
قرارداد آسیبپذیر: یک مثال ساده
یک قرارداد بانکی ساده را تصور کنید که به کاربران اجازه میدهد اتر واریز و برداشت کنند. نسخه آسیبپذیر آن به شکل زیر است:
```solidity
// WARNING: This code is vulnerable to a reentrancy attack.
// DO NOT USE THIS IN PRODUCTION.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract EtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint userBalance = balances[msg.sender];
require(userBalance > 0, "No balance to withdraw");
// FLAW: Interaction (sending Ether) comes BEFORE the Effect (updating state)
(bool sent, ) = msg.sender.call{value: userBalance}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
}نقطه ضعف کلیدی در تابع withdraw است. کد ابتدا موجودی کاربر را بررسی میکند (Check)، سپس اقدام به ارسال اتر به آدرس کاربر میکند (Interaction) و در نهایت، موجودی کاربر را در قرارداد صفر میکند (Effect). این ترتیب، درگاه حمله را باز میکند.
قرارداد مهاجم: سلاح جرم
حالا قرارداد مهاجم را ببینید. این قرارداد برای بهرهبرداری از ضعف EtherStore طراحی شده است.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./EtherStore.sol";
contract Attacker {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// Fallback function is called when the contract receives Ether
receive() external payable {
// As long as there is Ether in the victim contract, re-enter the withdraw function
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 Ether to attack");
etherStore.deposit{value: 1 ether}();
etherStore.withdraw();
}
}سناریوی حمله به این صورت است:
1. مهاجم قرارداد Attacker را با آدرس قرارداد EtherStore مستقر میکند.
2. مهاجم تابع attack را با ارسال ۱ اتر فراخوانی میکند. این اتر در قرارداد EtherStore به نام قرارداد Attacker واریز میشود.
3. سپس تابع attack، تابع withdraw را در EtherStore فراخوانی میکند.
4. قرارداد EtherStore موجودی (۱ اتر) را بررسی کرده و با استفاده از msg.sender.call{...} اقدام به ارسال آن به قرارداد Attacker میکند.
5. اینجا نقطه کلیدی است: به محض دریافت اتر، تابع receive() در قرارداد Attacker فعال میشود. این تابع قبل از اینکه EtherStore فرصت کند خط balances[msg.sender] = 0; را اجرا کند، دوباره تابع withdraw را فراخوانی میکند.
6. از آنجایی که موجودی هنوز صفر نشده است، EtherStore دوباره ۱ اتر دیگر به مهاجم ارسال میکند. این حلقه تا زمانی که موجودی EtherStore خالی شود، ادامه پیدا میکند.
راهکارها و تدابیر دفاعی: ساختن دژهای نفوذناپذیر
خوشبختانه، دفاع در برابر این حمله پیچیده نیست و بر پایه یک اصل بنیادین و چند ابزار قدرتمند استوار است.
الگوی طلایی: Checks-Effects-Interactions (CEI)
موثرترین راه برای جلوگیری از حملات بازورود، پیروی از الگوی «بررسیها-تأثیرات-تعاملات» (Checks-Effects-Interactions) است. این الگو دیکته میکند که ترتیب عملیات در توابع شما باید همیشه به این صورت باشد:
- بررسیها (Checks): ابتدا تمام پیششرطها و الزامات را بررسی کنید (مثلاً با استفاده از
require). آیا کاربر مجاز است؟ آیا موجودی کافی دارد؟ - تأثیرات (Effects): بلافاصله پس از بررسیها، تمام تغییرات را بر روی متغیرهای حالت (State Variables) قرارداد اعمال کنید. موجودیها را بهروز کنید، مالکیت را تغییر دهید و…
- تعاملات (Interactions): در آخرین مرحله، با قراردادها یا آدرسهای خارجی تعامل کنید (مثلاً ارسال اتر یا فراخوانی توابع دیگر).
با این الگو، حتی اگر قرارداد خارجی دوباره تابع شما را فراخوانی کند، چون شما قبلاً تأثیرات (مثلاً صفر کردن موجودی) را اعمال کردهاید، بررسیهای اولیه (Checks) با شکست مواجه شده و حلقه مخرب شکل نمیگیرد.
قرارداد امن: بازنویسی کد با تفکر امنیتی
بیایید قرارداد EtherStore را با استفاده از الگوی CEI بازنویسی کنیم:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SecureEtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint userBalance = balances[msg.sender];
// 1. Check
require(userBalance > 0, "No balance to withdraw");
// 2. Effect
balances[msg.sender] = 0;
// 3. Interaction
(bool sent, ) = msg.sender.call{value: userBalance}("");
require(sent, "Failed to send Ether");
}
}تنها با جابجا کردن یک خط کد، قرارداد از یک هدف آسیبپذیر به یک دژ امن تبدیل شد. اکنون اگر مهاجم تلاش کند دوباره وارد تابع withdraw شود، با require(userBalance > 0) مواجه میشود که به دلیل صفر شدن موجودی در مرحله قبل، با شکست مواجه خواهد شد.
استفاده از Reentrancy Guard: نگهبان همیشه بیدار
برای توابع پیچیدهتر، یا برای افزودن یک لایه امنیتی اضافی، میتوانید از یک قفل یا “Mutex” استفاده کنید. این مکانیزم تضمین میکند که یک تابع نمیتواند دوباره اجرا شود تا زمانی که اجرای اولیه آن به پایان نرسیده باشد. کتابخانه معتبر OpenZeppelin یک ابزار عالی به نام ReentrancyGuard برای این کار ارائه میدهد.
برای استفاده از آن، کافیست قرارداد خود را از ReentrancyGuard به ارث ببرید و اصلاحگر nonReentrant را به توابع حساس خود اضافه کنید:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedEtherStore is ReentrancyGuard {
// … (mapping and deposit function remain the same)
// By adding the nonReentrant modifier, we protect this function from reentrancy
function withdraw() public nonReentrant {
// … (logic of the function)
}
}اصلاحگر nonReentrant به طور خودکار قبل از اجرای تابع، یک قفل را فعال کرده و پس از پایان اجرا، آن را غیرفعال میکند. هرگونه تلاش برای ورود مجدد به تابع در حین فعال بودن قفل، با شکست مواجه خواهد شد.
یک اشتباه رایج + راهکار استراتژیک
اشتباه رایج: تکیه بر transfer() و send() به عنوان راهحل جادویی. در نسخههای قدیمیتر Solidity، توسعهدهندگان برای جلوگیری از بازورود به توابع .transfer() و .send() روی میآوردند، زیرا این توابع یک محدودیت گس (Gas) ثابت (۲۳۰۰) داشتند که برای اجرای یک فراخوانی مجدد کافی نبود. این یک راهحل موقتی و شکننده بود.
راهکار استراتژیک: ذهنیت «عدم اعتماد به فراخوانیهای خارجی» را درونی کنید. تغییرات در هزینه گس عملیاتهای اتریوم (EIPs) میتواند این محدودیت را بیاثر کند و قرارداد شما را ناگهان آسیبپذیر سازد. راهکار استراتژیک این است که هرگز امنیت خود را به جزئیات پیادهسازی شبکه مانند میزان گس گره نزنید. همیشه فرض کنید هر فراخوانی خارجی (external call) میتواند مخرب باشد و از الگوهای قوی و اثباتشده مانند CEI و قفلهای Reentrancy Guard استفاده کنید. امنیت باید در منطق قرارداد شما تعبیه شود، نه در فرضیات مربوط به محیط اجرایی آن.
نکات کلیدی این مقاله (Key Takeaways)
- نکته اول: حمله بازورود (Reentrancy) زمانی رخ میدهد که یک قرارداد، قبل از بهروزرسانی وضعیت داخلی خود، یک تابع در قرارداد خارجی را فراخوانی میکند و آن قرارداد خارجی دوباره تابع اصلی را فراخوانی میکند.
- نکته دوم: الگوی طلایی برای پیشگیری، Checks-Effects-Interactions (CEI) است. همیشه ابتدا بررسی کنید، سپس وضعیت را تغییر دهید و در آخر با خارج از قرارداد تعامل کنید.
- نکته سوم: برای امنیت بیشتر و در کدهای پیچیده، از قفلهای حفاظتی مانند
ReentrancyGuardکتابخانه OpenZeppelin استفاده کنید تا از اجرای مجدد توابع حساس جلوگیری شود.
برای مطالعه عمیقتر:
فراتر از بازورود: ۷ آسیبپذیری مرگبار دیگر در قراردادهای هوشمند که باید بشناسید.
این مقاله شما را در برابر یک تهدید بزرگ ایمن میکند؛ اکنون آسیبپذیریهای دیگر مانند سرریز عدد صحیح (Integer Overflow) و فرانت-رانینگ (Front-running) را کشف کنید تا برنامههای غیرمتمرکز واقعاً قوی بسازید.
ممیزی امنیتی قرارداد هوشمند (Audit): سرمایهگذاری ضروری یا هزینه اضافی؟
حالا که با یک آسیبپذیری حیاتی آشنا شدید، در مورد فرآیند حرفهای که این نواقص را قبل از آنکه میلیونها دلار برای شما هزینه داشته باشند پیدا میکند، بیاموزید.
سوالات متداول (FAQ)
آیا حمله بازورود فقط در تابع برداشت وجه (withdraw) رخ میدهد؟
خیر. این حمله میتواند در هر تابعی که یک فراخوانی خارجی به یک قرارداد غیرقابل اعتماد را قبل از بهروزرسانی کامل وضعیت داخلی خود انجام دهد، رخ دهد. هر تعامل با یک قرارداد خارجی یک نقطه ریسک بالقوه است.
آیا استفاده از `nonReentrant` از کتابخانه OpenZeppelin کاملاً امن است؟
این ابزار یک دفاع بسیار قدرتمند در برابر حملات بازورود تک-تابعی (single-function) است. با این حال، توسعهدهندگان باید همچنان مراقب سناریوهای پیچیدهتر مانند حملات بازورود بین-تابعی (cross-function) یا بین-قراردادی (cross-contract) باشند که در آن مهاجم از یک تابع برای ورود به تابع دیگری در همان قرارداد استفاده میکند.
تفاوت بین `call()`، `transfer()` و `send()` در Solidity چیست؟
transfer() و send() توابع قدیمیتری هستند که مقدار گس ارسالی را به ۲۳۰۰ محدود میکنند و در صورت شکست، transfer() تراکنش را بازمیگرداند (revert) در حالی که send() فقط false برمیگرداند. روش مدرن و توصیهشده، استفاده از .call{value: ...}("") است که تمام گس موجود را ارسال میکند و به توسعهدهنده کنترل کامل میدهد، اما مستلزم آن است که امنیت (مانند الگوی CEI) به صورت دستی پیادهسازی شود.



