Reentrancy (реэнтрантность): одна из главных уязвимостей смарт-контрактов

Reentrancy (реэнтрантность) — это класс уязвимостей смарт-контрактов, при котором внешний контракт или адрес может повторно вызвать уязвимый контракт до завершения предыдущего вызова, пока его состояние ещё не приведено в консистентный вид. Это позволяет многократно провести вывод средств или изменить состояние в свою пользу.

Исторически самая известная атака reentrancy — взлом DAO в 2016 году на раннем этапе развития Ethereum, но реэнтрантность остаётся актуальным риском для DeFi-протоколов, особенно с богатыми callback-возможностями, как у ERC-777.

Reentrancy (реэнтрантность): одна из главных уязвимостей смарт-контрактов

Как работает классическая 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-программами;
  • оценивать, как протокол хранит средства и есть ли механизмы «паузы» и восстановления после инцидентов.

См. также

Task Runner