Перейти к содержанию

Межфункциональная reentrancy в Solidity

Предположим, автор добавил nonReentrant к ошибочному withdrawAll и решил, что проблема исчезла. Прямой повтор в него отклонится. Но если credit используется другим открытым методом, внешний callback может войти именно туда. Область риска определяется общим ресурсом, а не только защищённым названием функции.

Для собственного лабораторного разбора добавим transferCredit. Он переносит часть обязательства от непосредственного sender другому адресу без движения ETH. Это новая отдельная модель, не функция исходного CourseCatalog. Мы рассматриваем её код локально, не запускаем контракт и не переводим средства.

Ошибка между двумя путями

В воображаемой BadSharedLedger withdrawAll защищён guard, но сохраняет credit=0 после call, как в предыдущем контрпримере. transferCredit не защищён и уменьшает доступный credit вызывающего, увеличивая credit получателя. Этот фрагмент показывает второй путь внутри такой специально ошибочной модели.

// Учебный контрпример: общий ресурс доступен во время неправильного withdraw.
function transferCredit(address to, uint256 amount) external {
    uint256 available = credit[msg.sender];
    if (to == address(0) || amount == 0 || amount > available)
        revert InvalidAmount();
    credit[msg.sender] = available - amount;
    credit[to] += amount;
}

Пусть R имеет credit 3, другой участник Q имеет 3, balance равен 6. R вызывает защищённый withdrawAll. Во время получения трёх wei он вызывает transferCredit(S,3). Guard на withdrawAll не действует на этот другой незащищённый метод.

Transfer видит старый credit R=3 и переносит обязательство S. После возврата withdrawAll записывает R=0. Получается balance 3, credit S=3 и credit Q=3. Прежние три единицы R уже выплачены, но соответствующее обязательство осталось у S. Инвариант обеспечения нарушен без второго входа в withdrawAll.

Подготовка состояния и общий запрет вложенности

Исправленная модель сначала уменьшает ресурс до любого call. Дополнительно все внешние изменяющие credit пути используют один ReentrancyGuard. В следующем полном учебном исходнике есть deposit, transferCredit и частичный withdraw. Getter остаётся публичным наблюдением и не получает административной авторизации.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SharedRefundLedger is ReentrancyGuard {
    error InvalidAmount();
    error TransferFailed();
    mapping(address => uint256) public credit;
    function deposit() external payable nonReentrant {
        if (msg.value == 0) revert InvalidAmount();
        credit[msg.sender] += msg.value;
    }
    function transferCredit(address to, uint256 amount) external nonReentrant {
        uint256 available = credit[msg.sender];
        if (to == address(0) || amount == 0 || amount > available)
            revert InvalidAmount();
        credit[msg.sender] = available - amount;
        credit[to] += amount;
    }
    function withdraw(uint256 amount, address payable to) external nonReentrant {
        uint256 available = credit[msg.sender];
        if (to == address(0) || amount == 0 || amount > available)
            revert InvalidAmount();
        credit[msg.sender] = available - amount;
        (bool ok,) = to.call{value: amount}("");
        if (!ok) revert TransferFailed();
    }
}

Здесь политика блокирует вложенный deposit тоже. Это конкретный выбор данной маленькой модели, а не утверждение, что безопасный deposit всегда должен запрещаться внутри callback любой системы. Его собственные эффекты могли бы сохранять инвариант, но мы выбираем простой единый договор вложенности всех mutators.

Частичный вывод

Пусть R имеет 5 и выводит 3. До call его credit становится 2. Даже без попытки нарушить guard прежние пять уже не доступны. При обычном позднем transferCredit(S,2) остаток можно законно перенести, поскольку первая транзакция завершилась. Так запрет вложенного изменения не становится запретом всей будущей работы ресурса.

Если R переводит credit самому себе, две записи в показанном transferCredit вернут его исходное значение. Метод допускает такой случай, хотя он не приносит практической пользы. Нельзя объявить, что модель запрещает self-transfer, когда соответствующей проверки нет. Ожидаемый результат следует из реального порядка mapping-записей.

При отказе получателя withdraw откатывается вместе с уменьшением credit и состоянием guard. Участник может позднее выбрать другого подходящего получателя. Отклонённый внешний call не должен оставлять ledger навсегда «занятым» только потому, что modifier выставил флаг перед телом.

Чтение промежуточного состояния

Guard запрещает отмеченные изменяющие пути, но публичный getter credit можно читать внутри callback. Он покажет уже уменьшенное значение после CEI. Это согласованное промежуточное состояние данного обязательства. Если внешняя зависимость использует другой getter как источник доверенного расчёта, его смысл тоже следует определить.

Сама возможность view чтения не всегда безопасна для всех бизнес-сценариев. В сложной системе несколько взаимосвязанных полей могут обновляться в разные моменты, а внешняя программа принять временную комбинацию за финальную. Поэтому effects должны готовить все значимые условия до передачи управления, не только одну удобную запись.

Несколько контрактов

ReentrancyGuard относится к storage конкретного SharedRefundLedger. Он не является глобальным замком всех контрактов библиотеки. Внешний получатель может изменить отдельный другой экземпляр, если его интерфейс это разрешает. Если ledger зависит от такого состояния, нужно включить зависимость в модель ресурса.

Для нашего простого примера credit не зависит от внешней цены, роли или курсного active. Поэтому не придумываем связанную уязвимость оракула внутри этой модели. Когда позднее добавим reader внешних данных, будем рассматривать новую границу явно, с отдельными предпосылками и последствиями.

Карта всех маршрутов

Запишите для каждого метода, какое поле он читает и меняет и способен ли взаимодействовать с внешним кодом. Withdraw имеет call, transferCredit работает только с mapping, deposit получает значение текущего вызова. На карте видно, почему анализ одного selector не покрывает всё обязательство.

При добавлении новой функции списания в будущем она должна войти в ту же карту. Нельзя считать её автоматически защищённой из-за наследования ReentrancyGuard: modifier нужен на выбранном пути, а корректные effects нужны в его теле. Изменение интерфейса увеличивает объём требуемого анализа.

Для задания сравните два сценария: callback пытается transferCredit в ошибочной модели и в SharedRefundLedger. Затем выполните ту же передачу мысленно после успешного завершения withdraw. Объясните, почему ожидаемые результаты различаются по времени входа, хотя параметры и участники одинаковы. Сейчас всё остаётся описанием локального поведения без выполненных проверок.

Одна роль не заменяет согласованность

Даже если transferCredit разрешён только определённым участникам, callback такого участника может оказаться достижимым. Авторизация и порядок effects проверяются отдельно. Уменьшение круга вызовов снижает область угроз, но не делает несогласованное обязательство математически правильным для разрешённого caller.

Тот же принцип относится к административному восстановлению. Если будущий owner метод переписывает credit во время внешнего взаимодействия, он входит в общий анализ. Название аварийного действия не устанавливает автоматического исключения из инварианта обеспечения. Нужно определить его предметные последствия и допустимый момент, а не надеяться на редкость использования.

Для самостоятельного уточнения: security-considerations.html, ReentrancyGuard.sol (5.4.0).

Оглавление курса. Листинги, расчёты и наблюдения здесь разобраны как ожидаемое поведение; компиляция, учебные программы и проверки при написании не запускались.

Оглавление серии