Reentrancy: атака и защита
Reentrancy — самая знаменитая уязвимость Ethereum. Именно она обрушила The DAO в 2016 и до сих пор крадёт миллионы.
Контракт отправляет вам ETH — и в этот момент управление переходит к вашему коду. А ваш баланс ещё не обнулён. Этого зазора достаточно, чтобы опустошить казну.
Reentrancy (повторный вход) возникает, когда контракт делает внешний вызов (например, отправляет ETH) до того, как обновил своё состояние. Внешний вызов передаёт управление другому контракту, и тот может снова вызвать уязвимую функцию — пока баланс ещё «старый». Так атакующий выводит средства многократно за одну транзакцию.
УЯЗВИМЫЙ withdraw (interactions ДО effects)
===========================================
1. require(balance[user] > 0)
2. send ETH user --------------+ <-- внешний вызов!
3. balance[user] = 0 |
v
Контракт атакующего (receive): СНОВА вызывает withdraw
balance ещё не обнулён -> шаг 1 проходит -> ещё ETH...
петля повторяется, пока в контракте есть деньги
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SafeVault {
mapping(address => uint256) public balance;
uint256 private locked = 1;
modifier nonReentrant() {
require(locked == 1, "reentrant");
locked = 2; // ставим замок
_;
locked = 1; // снимаем
}
function deposit() external payable { balance[msg.sender] += msg.value; }
// ПРАВИЛЬНО: checks -> effects -> interactions
function withdraw() external nonReentrant {
uint256 amount = balance[msg.sender]; // checks
require(amount > 0, "nothing");
balance[msg.sender] = 0; // effects (ДО вызова)
(bool ok, ) = msg.sender.call{value: amount}(""); // interactions
require(ok, "send failed");
}
}
Как работает под капотом (EVM/газ)
Когда контракт делает call с переводом ETH, EVM передаёт исполнение коду получателя (его функции receive/fallback). Если внутри обновление баланса ещё не произошло, повторный вход видит старое состояние. Защита двойная: паттерн checks-effects-interactions (сначала обнулить баланс, потом слать ETH — тогда повторный вход увидит ноль) и модификатор nonReentrant (storage-флаг-замок, который не даёт войти второй раз). Важно: одно не заменяет другое, их используют вместе.
# Та же логика на Python: почему порядок effects/interactions решает всё
class Vault:
def __init__(self):
self.balance = {"attacker": 100}
self.pool = 1000 # всего средств в контракте
def withdraw_BAD(self, user, attacker_reenter):
amount = self.balance[user]
if amount > 0:
self.pool -= amount
attacker_reenter(self, user) # ВНЕШНИЙ вызов до обнуления!
self.balance[user] = 0 # слишком поздно
def withdraw_GOOD(self, user):
amount = self.balance[user]
if amount > 0:
self.balance[user] = 0 # effects СНАЧАЛА
self.pool -= amount # потом отдаём
calls = {"n": 0}
def reenter(vault, user):
if calls["n"] < 3: # атакующий входит повторно
calls["n"] += 1
vault.withdraw_BAD(user, reenter)
v = Vault(); v.withdraw_BAD("attacker", reenter)
print("BAD: вывели лишнее, pool =", v.pool) # ушло больше, чем было у юзера
v2 = Vault(); v2.withdraw_GOOD("attacker")
print("GOOD: баланс обнулён до выдачи, pool =", v2.pool)
«Та же логика на Python ▶». В «плохой» версии повторный вход списывает средства снова и снова; в «хорошей» баланс обнулён до выдачи, и повтор уже видит ноль.
Частые ошибки и уязвимости
- Делать внешний вызов (отправку ETH,
transferFrom) до обновления состояния — главный источник reentrancy. - Полагаться только на
nonReentrantбез CEI — есть кросс-функциональные и read-only варианты атаки. - Считать, что
transfer()с лимитом 2300 газа «безопасен» — современная практика этого больше не гарантирует; защищайтесь паттерном, а не лимитом газа.
Best practices
- Всегда соблюдайте порядок checks → effects → interactions: обновляйте состояние до любых внешних вызовов.
- Добавляйте
nonReentrant(например из OpenZeppelinReentrancyGuard) на функции с внешними вызовами — как второй рубеж. - Минимизируйте внешние вызовы и используйте pull-модель вывода средств.
Итоги
Reentrancy эксплуатирует зазор между внешним вызовом и обновлением состояния. Защита — CEI плюс nonReentrant, вместе. Дальше — контроль доступа и опасность tx.origin.