Checks-Effects-Interactions и ReentrancyGuard
Простое правило порядка, которое закрывает целый класс атак.
Checks-Effects-Interactions (CEI) — паттерн, требующий выполнять операции строго в порядке: сначала проверки (checks), затем изменения собственного состояния (effects) и только в конце — внешние вызовы (interactions).
Идея паттерна
Реентранси возможна потому, что внешний вызов происходит, пока состояние ещё «врёт». Лечение очевидно: обновите состояние до того, как отдадите управление наружу. Тогда, даже если получатель повторно войдёт в функцию, проверки уже увидят актуальную картину (баланс обнулён) и отклонят повторный вывод.
// БЕЗОПАСНО: effect до interaction
function withdraw() external {
uint256 amount = balances[msg.sender]; // checks
require(amount > 0, "nothing");
balances[msg.sender] = 0; // effect: состояние обновлено ПЕРВЫМ
(bool ok, ) = msg.sender.call{value: amount}(""); // interaction в конце
require(ok, "transfer failed");
}Теперь при повторном входе balances[msg.sender] уже равен нулю, и require(amount > 0) остановит атаку. Один лишь правильный порядок строк закрывает уязвимость.
Второй рубеж: ReentrancyGuard
CEI достаточно, но люди ошибаются, особенно в сложных функциях с несколькими внешними вызовами. Поэтому добавляют «замок» — модификатор nonReentrant из библиотеки OpenZeppelin. Он ставит флаг «вход» в начале функции и снимает в конце; повторный вход натыкается на флаг и откатывается.
// Эшелон 2: замок от повторного входа (OpenZeppelin)
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Vault is ReentrancyGuard {
function withdraw() external nonReentrant {
// ... CEI внутри + защита замком снаружи
}
}Как работает под капотом
Модификатор хранит переменную состояния (например, _status). Вход в защищённую функцию проверяет, что статус «не занят», ставит «занят», выполняет тело и в конце возвращает «свободно». Любой повторный вход внутри той же транзакции видит «занят» и падает с ошибкой. Это дешёвый, но мощный страховочный механизм поверх CEI.
enum { NOT_ENTERED, ENTERED }
status = NOT_ENTERED
function guarded():
require(status == NOT_ENTERED) // повторный вход -> revert
status = ENTERED
... тело ...
status = NOT_ENTEREDЧастые ошибки
- Полагаться только на guard, нарушая CEI. Guard не защищает от кросс-функциональной реентранси (повторный вход в другую функцию). Делайте и то, и другое.
- Забыть навесить
nonReentrantна одну из функций, читающих/меняющих общий баланс. - Считать
transfer()панацеей. Лимит газа 2300 уtransfer— ненадёжная защита; ориентируйтесь на CEI + guard.
Итоги
- CEI: проверки → изменение своего состояния → внешние вызовы. Это базовая защита.
- Обновляйте баланс ДО внешнего вызова — повторный вход тогда упрётся в проверку.
- ReentrancyGuard (
nonReentrant) — страховочный «замок» поверх CEI. - Применяйте оба рубежа и не забывайте про кросс-функциональную реентранси.