Реентрантность, checks-effects-interactions и pull-платежи
Как работает атака повторного входа, взломавшая The DAO, и как от неё защищаться: порядок операций, ReentrancyGuard и отложенные платежи на примере эскроу.
В этой статье
Если бы смарт-контракты страдали «любимой болезнью», это была бы реентрантность. С неё начались самые громкие потери в истории блокчейна, и она до сих пор регулярно всплывает в новых проектах. Идея простая и неприятная: когда контракт отправляет деньги, он отдаёт управление чужому коду, и этот код может вызвать его обратно в момент, когда состояние ещё не обновлено.
Атака на примере
Представьте наивный контракт вывода средств:
mapping(address => uint256) public balance;
function withdraw() external {
uint256 amount = balance[msg.sender];
require(amount > 0, "nothing");
(bool ok, ) = msg.sender.call{value: amount}(""); // 1. отправляем деньги
require(ok, "failed");
balance[msg.sender] = 0; // 2. обнуляем баланс
}Что здесь не так: баланс обнуляется после отправки денег. Если получатель - не человек, а контракт, то при получении ETH сработает его функция receive(). Внутри неё злоумышленник снова вызывает withdraw(). Баланс ещё не обнулён - проверка проходит - деньги уходят второй раз, потом третий… пока не опустеет контракт.
contract Attacker {
Victim victim;
receive() external payable {
if (address(victim).balance >= 1 ether) victim.withdraw(); // повторный вход
}
function attack() external payable {
victim.deposit{value: 1 ether}();
victim.withdraw();
}
}Именно так в 2016 году был взломан The DAO - проект с суммой в десятки миллионов долларов в ETH. Последствия оказались настолько серьёзными, что сообщество Ethereum раскололось, и появилась сеть Ethereum Classic.
Защита 1: checks-effects-interactions
Главное правило - порядок операций внутри функции:
- Checks - проверки: статус, права, суммы.
- Effects - эффекты: обновляем собственное состояние.
- Interactions - взаимодействия: только теперь вызываем внешние контракты и отправляем деньги.
Исправленная версия:
function withdraw() external {
uint256 amount = balance[msg.sender]; // проверка
require(amount > 0, "nothing");
balance[msg.sender] = 0; // эффект: сначала обнуляем
(bool ok, ) = msg.sender.call{value: amount}(""); // взаимодействие: потом отправляем
require(ok, "failed");
}Теперь повторный вход бесполезен: при втором вызове баланс уже ноль, проверка отвергнет его. В нашем эскроу так устроена каждая выплата: функция _settle сначала переводит сделку в Settled и пишет событие, и только потом отправляет деньги.
Защита 2: ReentrancyGuard
Дисциплина порядка хороша, но человек ошибается. Поэтому добавляют замок: модификатор nonReentrant ставит флаг «внутри вызова» и отвергает любой повторный вход, пока он не снят.
modifier nonReentrant() {
require(_status != ENTERED, "reentrant call");
_status = ENTERED;
_;
_status = NOT_ENTERED;
}В нашем контракте nonReentrant стоит на каждой функции, которая двигает деньги: createOffer, acceptOffer, cancelOffer, confirmDelivery, setCancelIntent, claimExpired, openDispute, resolveDispute, claimArbiterTimeout, withdrawCredit. Используется проверенная реализация из библиотеки OpenZeppelin, а не самописная.
Реентрантность бывает хитрее
Бывают и более тонкие виды: кросс-функциональная (повторный вход в другую функцию, использующую то же состояние) и read-only реентрантность (внешний контракт читает ваше состояние в момент, когда оно временно некорректно). Общий принцип защиты один: не оставляйте состояние «наполовину обновлённым» в момент внешнего вызова и считайте любой внешний вызов потенциально враждебным.
Вторая проблема: получатель может заблокировать выплату
Допустим, при завершении сделки контракт отправляет деньги продавцу, а продавец - контракт, который всегда откатывает приём денег. Если выплата сделана через require(ok), откатится вся транзакция, и сделка завершиться не сможет никогда. Один недобросовестный участник замораживает деньги остальных.
Похожая ситуация: адрес получателя заблокирован эмитентом токена (так бывает с USDT и USDC). Любая попытка перевода откатывается.
Защита 3: pull-платежи
Решение - перевернуть модель. Вместо «push» (контракт отправляет деньги получателю) использовать «pull» (получатель сам приходит за деньгами). Если выплата не прошла, контракт не откатывает транзакцию, а записывает долг:
function _payout(address token, address to, uint256 amount) private {
if (_trySend(token, to, amount)) {
reserved[token] -= amount;
emit Paid(token, to, amount);
} else {
credits[token][to] += amount; // обязательство остаётся в контракте
emit PaymentDeferred(token, to, amount);
}
}Сделка закрывается в любом случае, а проблемный получатель получает запись «вам причитается столько-то» и может забрать деньги вручную:
function withdrawCredit(address token) external nonReentrant {
uint256 amount = credits[token][msg.sender];
if (amount == 0) revert NothingToWithdraw();
credits[token][msg.sender] = 0; // сначала обнуляем (CEI)
reserved[token] -= amount;
_sendOrRevert(token, msg.sender, amount);
emit CreditWithdrawn(token, msg.sender, amount);
}При ручном выводе газ не ограничен - получатель сам инициирует вызов и сам платит. А если вывод снова не получился, транзакция откатывается и записи остаются нетронутыми.
Защита 4: ограничение газа при отправке
Ещё один приём: при автоматической отправке нативной монеты контракт выделяет получателю ограниченный газ (в нашем контракте - 50 000). Получатель с «жадным» receive(), сжигающим весь газ, не сможет затормозить закрытие сделки или вытянуть из неё лишнее: его выплата просто превратится в отложенную.
(bool ok, ) = to.call{value: amount, gas: PUSH_GAS_LIMIT}("");Учёт обязательств
Чтобы «долги» не потерялись, контракт ведёт счётчик reserved[token] - сумма всего, что он должен кому-либо: деньги живых сделок, непогашенные кредиты и накопленные комиссии. Главный инвариант платёжеспособности:
balanceOf(контракт) >= reserved[token]Владелец может вывести только разницу - «лишние» деньги, случайно присланные на адрес. Чужие деньги ему недоступны. О том, как проверяется такой инвариант, - в статье про тестирование.
Резюме
- Внешний вызов передаёт управление чужому коду: считайте его враждебным.
- Порядок «проверки → эффекты → взаимодействия».
- Замок
nonReentrantкак вторая линия обороны. - Pull-платежи, чтобы один получатель не блокировал остальных.
- Ограничение газа при автоматической отправке.
- Строгий учёт обязательств и инвариант платёжеспособности.
Что почитать дальше
Токены ERC-20 - тоже внешние контракты, и они бывают самыми разными. Какие «сюрпризы» они приносят и как с ними справляться: нестандартные токены. А полный обзор типичных ошибок - в каталоге уязвимостей.
Читайте также
- 3 · Под капотом
Каталог уязвимостей смарт-контрактов: 12 классических ошибок и защита от них
Обзор частых уязвимостей контрактов: реентерабельность, контроль доступа, переполнение, оракулы, повтор подписей, DoS. С примерами и защитой в эскроу.
4 мин чтения - 3 · Под капотом
Нестандартные токены: USDT без return, fee-on-transfer, блэклисты и decimals
Какие сюрпризы скрывают токены ERC-20: USDT без возврата значения, комиссия при переводе, блокировка адресов, разные decimals - и как защищается эскроу.
4 мин чтения
Упоминается в статьях
- Словарь терминов: блокчейн, кошельки, смарт-контракты и эскроу от A до Я
- Мультиподпись и таймлок: как управлять контрактом без единой точки отказа
- EVM изнутри: стек, память, storage, calldata и стоимость газа
- Обзор языка Solidity: типы, функции, ошибки и события на примерах
- Пишем эскроу на Solidity: конечный автомат и разбор реального контракта
- Шаблоны контроля доступа в смарт-контрактах: owner, роли и стороны сделки
- Нестандартные токены: USDT без return, fee-on-transfer, блэклисты и decimals
- Каталог уязвимостей смарт-контрактов: 12 классических ошибок и защита от них
- Оптимизация газа в Solidity: 12 приёмов и когда они уместны
- Абстракция аккаунтов (ERC-4337): умные кошельки и спонсирование газа
- Тестирование смарт-контрактов: unit, fuzz, инварианты и статический анализ
Готовы попробовать?
Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.

