К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

Реентрантность, checks-effects-interactions и pull-платежи

Как работает атака повторного входа, взломавшая The DAO, и как от неё защищаться: порядок операций, ReentrancyGuard и отложенные платежи на примере эскроу.

5 мин чтенияОбновлено:
В этой статье

Если бы смарт-контракты страдали «любимой болезнью», это была бы реентрантность. С неё начались самые громкие потери в истории блокчейна, и она до сих пор регулярно всплывает в новых проектах. Идея простая и неприятная: когда контракт отправляет деньги, он отдаёт управление чужому коду, и этот код может вызвать его обратно в момент, когда состояние ещё не обновлено.

Атака на примере

Представьте наивный контракт вывода средств:

solidity
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(). Баланс ещё не обнулён - проверка проходит - деньги уходят второй раз, потом третий… пока не опустеет контракт.

solidity
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

Главное правило - порядок операций внутри функции:

  1. Checks - проверки: статус, права, суммы.
  2. Effects - эффекты: обновляем собственное состояние.
  3. Interactions - взаимодействия: только теперь вызываем внешние контракты и отправляем деньги.

Исправленная версия:

solidity
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 ставит флаг «внутри вызова» и отвергает любой повторный вход, пока он не снят.

solidity
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» (получатель сам приходит за деньгами). Если выплата не прошла, контракт не откатывает транзакцию, а записывает долг:

solidity
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);
    }
}

Сделка закрывается в любом случае, а проблемный получатель получает запись «вам причитается столько-то» и может забрать деньги вручную:

solidity
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(), сжигающим весь газ, не сможет затормозить закрытие сделки или вытянуть из неё лишнее: его выплата просто превратится в отложенную.

solidity
(bool ok, ) = to.call{value: amount, gas: PUSH_GAS_LIMIT}("");

Учёт обязательств

Чтобы «долги» не потерялись, контракт ведёт счётчик reserved[token] - сумма всего, что он должен кому-либо: деньги живых сделок, непогашенные кредиты и накопленные комиссии. Главный инвариант платёжеспособности:

text
balanceOf(контракт) >= reserved[token]

Владелец может вывести только разницу - «лишние» деньги, случайно присланные на адрес. Чужие деньги ему недоступны. О том, как проверяется такой инвариант, - в статье про тестирование.

Резюме

  • Внешний вызов передаёт управление чужому коду: считайте его враждебным.
  • Порядок «проверки → эффекты → взаимодействия».
  • Замок nonReentrant как вторая линия обороны.
  • Pull-платежи, чтобы один получатель не блокировал остальных.
  • Ограничение газа при автоматической отправке.
  • Строгий учёт обязательств и инвариант платёжеспособности.

Что почитать дальше

Токены ERC-20 - тоже внешние контракты, и они бывают самыми разными. Какие «сюрпризы» они приносят и как с ними справляться: нестандартные токены. А полный обзор типичных ошибок - в каталоге уязвимостей.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.