Reentrancy в Solidity: как устроена уязвимость и как защититься
Reentrancy (повторный вход) — это ситуация, когда контракт передаёт управление внешнему адресу до того, как закончил обновлять своё состояние, а внешний адрес успевает снова вызвать контракт и воспользоваться устаревшими данными. Защита строится на трёх приёмах: сначала проверки, потом изменение состояния, внешние вызовы в самом конце; блокировка повторного входа модификатором nonReentrant; и схема, при которой пользователь сам забирает средства. Ниже — суть уязвимости без инструкций по атаке и защитный код.
Почему о reentrancy говорят так много
Повторный вход — одна из самых известных уязвимостей в истории Ethereum. В 2016 году из-за неё пострадал проект The DAO, и последствия инцидента привели к разделению сети на Ethereum и Ethereum Classic. С тех пор появились стандартные защиты, но уязвимость продолжает встречаться в новых формах: через колбэки токенов, через несколько связанных контрактов, через функции чтения. Поэтому в любом аудите повторный вход проверяют одним из первых.
Почему это возможно
Когда контракт отправляет эфир через call или вызывает функцию другого контракта, исполнение переходит к получателю. Если получатель — контракт, у него выполняется код: функция receive, fallback или колбэк токена. Этот код может вызвать исходный контракт ещё раз, пока первая транзакция не завершилась. Если исходный контракт ещё не уменьшил баланс пользователя, он «думает», что деньги по-прежнему причитаются.
Колбэки встречаются чаще, чем кажется. safeTransferFrom у NFT вызывает onERC721Received у получателя, ERC-1155 — onERC1155Received, токены ERC-777 и ERC-1363 тоже вызывают код получателя. После обновления Pectra (EIP-7702) обычный кошелёк пользователя может иметь делегированный код, поэтому проверка «вызывающий — не контракт» больше не защищает.
Виды повторного входа
| Вид | Суть | Чем закрывают |
|---|---|---|
| В ту же функцию | Внешний вызов происходит до обнуления баланса, функция вызывается повторно | Порядок checks-effects-interactions, nonReentrant |
| Кросс-функциональный | Повторный вход через другую функцию, которая читает то же состояние | Общая блокировка на все функции, работающие с этим состоянием |
| Межконтрактный | Несколько контрактов разделяют состояние, повторный вход через соседний контракт | Общая блокировка или порядок действий во всей системе |
| Read-only | Внешний протокол читает view-функцию, пока ваше состояние не согласовано | Проверка блокировки в view-функциях, согласованное состояние до внешних вызовов |
Защита 1. Порядок действий
Правило checks-effects-interactions описано в разделе безопасности документации Solidity. Сначала проверяем все условия, затем записываем изменения в состояние, и только в конце вызываем внешние адреса. Если повторный вход случится, контракт увидит уже обновлённые данные.
Небезопасный порядок выглядит так: прочитать баланс, отправить эфир, обнулить баланс. Безопасный — так:
uint256 amount = balanceOf[msg.sender];if (amount == 0) revert NothingToWithdraw();balanceOf[msg.sender] = 0;— состояние обновлено до перевода.(bool ok, ) = payable(msg.sender).call{value: amount}("");if (!ok) revert TransferFailed();
Защита 2. Блокировка повторного входа
Модификатор nonReentrant выставляет флаг при входе в функцию и снимает при выходе. Любой повторный вход в функцию с тем же модификатором откатывается. OpenZeppelin предлагает две версии. Классический ReentrancyGuard хранит флаг в storage. ReentrancyGuardTransient хранит его в transient storage (EIP-1153), это дешевле. В OpenZeppelin 5.7 классическая версия помечена устаревшей и в 6.0 будет заменена транзиентной, поэтому для новых контрактов в сетях с поддержкой EIP-1153 берите ReentrancyGuardTransient.
import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";contract Vault is ReentrancyGuardTransient {function withdraw() external nonReentrant { ... }function claimRewards() external nonReentrant { ... }— та же блокировка на соседнюю функцию.
Read-only reentrancy
Этот вид опасен не для вашего контракта, а для тех, кто читает ваши данные. Например, функция getPrice() возвращает цену доли в пуле, а во время вывода средств ваше состояние кратко рассогласовано. Если внешний протокол в этот момент читает цену, он получает неверное значение. Защита: делать состояние согласованным до любых внешних вызовов, а в критичных view-функциях проверять, не активна ли блокировка — для этого у OpenZeppelin есть внутренняя функция _reentrancyGuardEntered().
Защита 3. Pull вместо push
Вместо того чтобы самому рассылать деньги многим получателям в одной транзакции, контракт записывает, кому сколько причитается, а каждый получатель забирает свою долю отдельным вызовом. Это уменьшает число внешних вызовов, исключает ситуацию, когда один «сломанный» получатель блокирует выплаты всем, и упрощает защиту. Так устроены выплаты в аукционах, раздачах и распределении выручки.
Где прячутся внешние вызовы
| Операция | Вызывает код получателя? | На что обратить внимание |
|---|---|---|
call{value: x} на адрес | Да, receive или fallback | Порядок действий и блокировка |
transfer у стандартного ERC-20 | Нет, если токен стандартный | Нестандартные токены могут вести себя иначе |
safeTransferFrom у ERC-721 | Да, onERC721Received | Минт и перевод NFT в контракт |
| Переводы ERC-1155 | Да, onERC1155Received или батч-версия | Батч-переводы и минт |
| Токены ERC-777 и ERC-1363 | Да, хуки и колбэки | Учитывайте при работе с произвольными токенами |
| Вызов функции чужого контракта | Да, его код целиком | Доверяете ли вы этому контракту |
Типичные ошибки при защите
- Модификатор стоит на
withdraw, но не наtransferилиclaim, которые меняют те же балансы. - Проверка
msg.sender == tx.originкак защита от контрактов. После EIP-7702 она не гарантирует отсутствие кода у отправителя и ломает совместимость со смарт-кошельками. - Использование
transferс лимитом 2 300 газа как защиты. Стоимость операций в сети меняется, и полагаться на этот лимит не рекомендуется. - Работа с ERC-721 и ERC-1155 через
safeTransferFromбез учёта колбэка получателя. - Внешний вызов внутри цикла, где состояние обновляется после цикла.
Отдельно стоит сказать про контракты, которые принимают произвольные токены от пользователей. Если вы не знаете заранее, как поведёт себя токен при переводе, считайте каждый перевод внешним вызовом с непредсказуемым кодом. Для таких контрактов список поддерживаемых токенов (allowlist) часто оказывается проще и надёжнее, чем попытка учесть все варианты.
Как проверить свой контракт
- Найдите все внешние вызовы:
call, переводы токенов,safeTransferFrom, вызовы чужих контрактов. - Для каждого убедитесь, что все изменения состояния сделаны до него.
- Проверьте, что все функции, которые меняют общее состояние, используют одну блокировку.
- Запустите Slither: детекторы повторного входа находят большинство очевидных случаев.
- Напишите тест с контрактом-получателем, который пытается вызвать вашу функцию из колбэка, и убедитесь, что транзакция откатывается.
- Добавьте инвариант: баланс контракта не меньше суммы обязательств. Как его написать — в статье о тестировании смарт-контрактов.
Повторный вход — одна из первых вещей, которые смотрит аудитор. Подготовить код к проверке поможет наш чек-лист перед аудитом. Если сомневаетесь в своём контракте, закажите аудит смарт-контракта или обратитесь к команде разработки смарт-контрактов.
Вопросы и ответы
Что такое reentrancy простыми словами?
Это когда контракт отдаёт управление внешнему адресу, не закончив обновлять свои данные, и тот успевает снова вызвать контракт, пока данные устаревшие. В результате контракт может выдать средства больше одного раза.
Достаточно ли модификатора nonReentrant?
Не всегда. Он защищает функции, на которых стоит, но не спасает от read-only повторного входа и ошибок в порядке действий. Лучше сочетать его с правилом checks-effects-interactions.
Чем ReentrancyGuardTransient лучше ReentrancyGuard?
Он хранит флаг блокировки в transient storage, который живёт только в рамках транзакции, поэтому дешевле по газу. В OpenZeppelin 5.7 классическая версия помечена устаревшей.
Защищает ли проверка tx.origin от повторного входа?
Нет. Проверка ломает совместимость со смарт-кошельками, а после EIP-7702 обычный адрес пользователя может иметь делегированный код. Используйте порядок действий и блокировку.
Бывает ли reentrancy без перевода эфира?
Да. Любой внешний вызов, включая перевод токенов с колбэком и вызов чужого контракта, передаёт управление и может привести к повторному входу.
Читайте также
Railway: взломщики охотились за ключами ИИ-API. Как защитить свои ключи
БезопасностьSecurity.txt: куда сообщать об уязвимости вашего сайта
НовостиЯдро Linux: отчёты о шести уязвимостях утекли до патчей — что проверить
ОбзорыУязвимости WordPress за 23–24 октября: Prime Mover 9.8 и что обновить
Новостиcurl 8.23.0: уязвимость HTTP/2 высокой важности, кого касается
НовостиNextcloud: уязвимость во встроенном Collabora, ставят бэкдоры