Перейти к содержимому
Все статьи
БлокчейнБезопасность сайта

Reentrancy в Solidity: как устроена уязвимость и как защититься

·30 октября 2026 г.· 11 мин чтения
Нужен смарт-контракт или dApp?Разработка смарт-контрактов

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. Сначала проверяем все условия, затем записываем изменения в состояние, и только в конце вызываем внешние адреса. Если повторный вход случится, контракт увидит уже обновлённые данные.

Небезопасный порядок выглядит так: прочитать баланс, отправить эфир, обнулить баланс. Безопасный — так:

  1. uint256 amount = balanceOf[msg.sender];
  2. if (amount == 0) revert NothingToWithdraw();
  3. balanceOf[msg.sender] = 0; — состояние обновлено до перевода.
  4. (bool ok, ) = payable(msg.sender).call{value: amount}("");
  5. 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) часто оказывается проще и надёжнее, чем попытка учесть все варианты.

Как проверить свой контракт

  1. Найдите все внешние вызовы: call, переводы токенов, safeTransferFrom, вызовы чужих контрактов.
  2. Для каждого убедитесь, что все изменения состояния сделаны до него.
  3. Проверьте, что все функции, которые меняют общее состояние, используют одну блокировку.
  4. Запустите Slither: детекторы повторного входа находят большинство очевидных случаев.
  5. Напишите тест с контрактом-получателем, который пытается вызвать вашу функцию из колбэка, и убедитесь, что транзакция откатывается.
  6. Добавьте инвариант: баланс контракта не меньше суммы обязательств. Как его написать — в статье о тестировании смарт-контрактов.

Повторный вход — одна из первых вещей, которые смотрит аудитор. Подготовить код к проверке поможет наш чек-лист перед аудитом. Если сомневаетесь в своём контракте, закажите аудит смарт-контракта или обратитесь к команде разработки смарт-контрактов.

Вопросы и ответы

Что такое reentrancy простыми словами?

Это когда контракт отдаёт управление внешнему адресу, не закончив обновлять свои данные, и тот успевает снова вызвать контракт, пока данные устаревшие. В результате контракт может выдать средства больше одного раза.

Достаточно ли модификатора nonReentrant?

Не всегда. Он защищает функции, на которых стоит, но не спасает от read-only повторного входа и ошибок в порядке действий. Лучше сочетать его с правилом checks-effects-interactions.

Чем ReentrancyGuardTransient лучше ReentrancyGuard?

Он хранит флаг блокировки в transient storage, который живёт только в рамках транзакции, поэтому дешевле по газу. В OpenZeppelin 5.7 классическая версия помечена устаревшей.

Защищает ли проверка tx.origin от повторного входа?

Нет. Проверка ломает совместимость со смарт-кошельками, а после EIP-7702 обычный адрес пользователя может иметь делегированный код. Используйте порядок действий и блокировку.

Бывает ли reentrancy без перевода эфира?

Да. Любой внешний вызов, включая перевод токенов с колбэком и вызов чужого контракта, передаёт управление и может привести к повторному входу.

Нужна помощь с проектом?

Обсудим задачу и предложим решение — от сайта до SaaS и защиты.

Связаться