Reentrancy (реэнтрантность) — это класс уязвимостей смарт-контрактов, при котором внешний контракт или адрес может повторно вызвать уязвимый контракт до завершения предыдущего вызова, пока его состояние ещё не приведено в консистентный вид. Это позволяет многократно провести вывод средств или изменить состояние в свою пользу.
Исторически самая известная атака reentrancy — взлом DAO в 2016 году на раннем этапе развития Ethereum, но реэнтрантность остаётся актуальным риском для DeFi-протоколов, особенно с богатыми callback-возможностями, как у ERC-777.
Как работает классическая reentrancy-атака
Сценарий в упрощённом виде:
- Есть контракт-хранилище (vault/bank), который хранит депозиты пользователей и реализует функцию withdraw():
- отправляет пользователю ETH или токен;
- затем уменьшает его баланс в своём внутреннем учёте.
- Злоумышленник разворачивает злой контракт, который:
- делает начальный депозит;
- при получении средств во встроенном callback (fallback/receive или hook) снова вызывает withdraw().
Если уязвимый контракт:
- сначала переводит средства наружу;
- а только потом обновляет внутренний баланс,
то при повторном вызове внутри одного и того же стека выполнения он видит старый баланс и позволяет вывести деньги ещё и ещё. В итоге:
- баланс в contract-storage становится отрицательно неконсистентным;
- средства других пользователей могут быть массово выкачаны.
Где чаще всего возникает реэнтрантность
Reentrancy-риски особенно велики там, где:
- контракт делает внешние вызовы (call, delegatecall, send) до обновления своего состояния;
- используется сложная логика смарт-контрактов и многократные цепочки вызовов;
- есть callback-хуки:
- токен-стандарты с hook-функциями (например, ERC-777 с tokensReceived);
- протоколы с «плагинами»/стратегиями, которые могут вызывать исходный контракт обратно.
Типичные кейсы:
- контракты-банки/хранилища (depositor/withdrawal);
- пулы ликвидности и лендинговые протоколы;
- NFT- и токен-контракты с хуками на отправку/получение;
- мультисиг-кошельки и DAO-трезори с кастомной логикой.
Виды reentrancy
Выделяют несколько подтипов:
- Классическая (single-function) reentrancy.
Повторный вход в ту же самую функцию до её завершения.
- Cross-function reentrancy.
Атакующий повторно входит в контракт через другую функцию, которая использует те же переменные состояния.
- Cross-contract reentrancy.
Рекурсивный цикл идёт через цепочку контрактов: A → B → C → A, где состояние A оказывается некорректно защищено.
- Read-only reentrancy.
Момент, когда «читающий» контракт (например, оракул или аналитический сервис) делает вывод о состоянии на основе данных, которые могут быть временно искажены реэнтрантным поведением. Само состояние не нарушается, но downstream-логика может принимать неверные решения (например, оценка TVL, цены, лимитов).
Reentrancy и стандарты токенов (ERC-20, ERC-777 и др.)
Классический ERC-20 относительно прост: передачи/аппрувы не имеют встроенных callback’ов, и риск реэнтрантности в основном связан с тем, как протоколы *используют* токен.
ERC-777 и похожие стандарты добавляют:
- хуки tokensToSend / tokensReceived;
- возможность автоматических ответных вызовов при отправке/получении токенов.
Проблема:
- если контракт-приёмник токенов реализует hook и внутри него вызывает обратно контракт-отправитель,
- а тот ещё не обновил своё состояние (балансы, учёт долей, лимиты),
— то возникает окно для reentrancy-атаки. Поэтому в документации по ERC-777 и смарт-контрактам на его основе всегда подчёркивают необходимость reentrancy-защиты.
Паттерны защиты от reentrancy
Базовые стратегии, которые рекомендуют аудиторские компании и best practices:
- Checks–Effects–Interactions.
Классический порядок действий внутри функции:
- сначала проверки (require / revert);
- затем изменение внутреннего состояния (effects);
- и только в конце — внешние вызовы (interactions: call, transfer, mint, burn и т.п.).
- Reentrancy-guard (mutex).
Ввести булевый флаг «выполняется ли сейчас функция»:
- перед входом проверять, что флаг не установлен;
- при входе устанавливать флаг;
- при выходе сбрасывать.
В Solidity такой паттерн реализован, например, в ReentrancyGuard из OpenZeppelin.
- Pull over push.
Вместо активных «выплат» при каждом событии (push):
- протокол записывает сумму к выплате;
- пользователь сам активирует отдельную функцию claim() (pull), которая специально спроектирована с учётом безопасности и лимитов.
- Минимизация доверия к внешним контрактам.
Избегать сложной логики в хуках и callback’ах, по возможности работать с простыми интерфейсами:
- не полагаться на то, что вызываемый контракт «ведение себя честно»;
- всегда рассматривать внешний вызов как потенциальный вход злоумышленника.
- Ограничение reentrancy на уровне протокола.
Для некоторых операций можно:
- вводить временные лимиты (cooldown);
- запрещать повторный вызов того же действия в рамках одного блока/эпохи;
- разделять функции «изменения состояния» и «вывода средств».
Практика: как протоколам снижать reentrancy-риски
Для разработчиков DeFi и других протоколов на EVM разумный чек-лист выглядит так:
- Обозначить все функции, которые:
- делают внешние вызовы;
- работают с балансами, лимитами и «чужими» активами.
- Проверить порядок checks → effects → interactions во всех таких функциях.
- Использовать ReentrancyGuard или аналогичный механизм там, где логика сложная и есть цепочки вызовов.
- Минимизировать использование «богатых» callback’ов там, где они не нужны.
- Покрыть ключевые сценарии unit-тестами и property-based тестами, моделирующими злоумышленника.
- Заказать внешний аудит смарт-контрактов у профильных команд безопасности.
Для пользователей и DAO логично:
- внимательно относиться к upgrade-правам протокола (кто может изменить код, добавив уязвимый путь);
- следить за отчётами аудиторов и bug bounty-программами;
- оценивать, как протокол хранит средства и есть ли механизмы «паузы» и восстановления после инцидентов.
