Реентранси: как повторный вход опустошает контракт

Почему отправка денег ДО обновления баланса — это открытая дверь.

Реентранси (reentrancy) — уязвимость, при которой внешний вызов из контракта возвращает управление чужому коду, и тот успевает повторно войти в ту же функцию, пока её состояние ещё не обновлено.

Откуда берётся проблема

В Ethereum отправка эфира внешнему адресу — это не пассивная операция. Если получатель сам является контрактом, при получении средств у него срабатывает код (receive/fallback). Значит, в момент, когда ваш контракт «просто переводит деньги», он на самом деле отдаёт управление коду получателя. Если к этому моменту вы ещё не записали факт списания в своё состояние, получатель может снова вызвать вашу функцию вывода — а ваш контракт всё ещё «думает», что баланс на месте.

Уязвимый шаблон (для понимания, не для использования)

Классический антипаттерн — сначала отправить деньги, потом обнулить баланс:

// УЯЗВИМО: interaction идёт до effect
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "nothing");

    (bool ok, ) = msg.sender.call{value: amount}(""); // отдаём управление!
    require(ok, "transfer failed");

    balances[msg.sender] = 0; // обнуление СЛИШКОМ ПОЗДНО
}

Проблема концептуальна: между строкой call и строкой обнуления баланса исполняется чужой код. Если он повторно зайдёт в withdraw, проверка amount > 0 снова пройдёт, потому что баланс ещё не обнулён. Это и есть «повторный вход».

Почему это историческая классика

Именно реентранси стоит за знаменитым взломом The DAO в 2016 году, который привёл к хардфорку Ethereum. С тех пор это уязвимость номер один в учебниках — не потому что её сложно понять, а потому что её легко случайно допустить: «перевести деньги, потом обновить запись» кажется естественным порядком, но он обратный безопасному.

Как работает под капотом

Ключ — в EVM-семантике низкоуровневого call: он синхронно исполняет код получателя в рамках той же транзакции и возвращает управление только после его завершения. Глубина повторных входов ограничена лишь газом. Поэтому любой внешний вызов нужно воспринимать как «здесь может исполниться произвольный недоверенный код».

withdraw()  --call-->  fallback атакующего
                          \--> снова withdraw()
                                   \--> снова withdraw()
         (баланс ещё не обнулён на каждом уровне)

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

  • Считать, что «перевод денег» безопасен. Любой call на адрес-контракт исполняет его код.
  • Обновлять состояние после внешнего вызова. Это и открывает окно.
  • Думать, что баг только про эфир. Реентранси возможна и через колбэки токенов (ERC-777, хуки), и кросс-функционально.

Итоги

  • Внешний вызов на адрес-контракт исполняет его код и возвращает ему управление.
  • Если состояние обновляется после вызова, атакующий может повторно войти в функцию.
  • Это уязвимость The DAO и до сих пор самая частая ловушка.
  • Любой внешний вызов = «здесь может исполниться недоверенный код».
Проверьте себя
1. Почему отправка эфира контракту-получателю опасна?
AОна необратима
BПри получении у контракта-получателя срабатывает код, и он получает управление
CОна стоит слишком много газа
DОна шифрует баланс
2. В чём суть повторного входа (reentrancy)?
AТранзакция отправлена дважды по ошибке
BЧужой код повторно вызывает функцию, пока её состояние ещё не обновлено
CКонтракт сам себя удаляет
DГаз закончился
3. С каким историческим событием связана реентранси?
AЗапуском Bitcoin
BВзломом The DAO и хардфорком Ethereum
CПоявлением стейблкоинов
DИзобретением AMM