Реентранси: как повторный вход опустошает контракт
Почему отправка денег ДО обновления баланса — это открытая дверь.
Реентранси (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 и до сих пор самая частая ловушка.
- Любой внешний вызов = «здесь может исполниться недоверенный код».