Reentrancy в Solidity: разбор локальной модели
В CEIRefundLedger мы уменьшали credit до внешней передачи. Теперь намеренно нарушим этот порядок в собственной лабораторной модели. Цель — увидеть момент, когда старое обязательство ещё доступно повторному пути. Разбор ограничен нашим кодом; мы не ищем чужие экземпляры и не выполняем операции со средствами.
Повторный вход означает, что внешний получатель возвращается к доступному методу до завершения первоначального вызова. Он не обязан быть отдельной поздней транзакцией. Поэтому финальное значение, выглядящее правильным после одного обычного сценария, может скрывать неправильные промежуточные состояния.
Специально ошибочная версия
Ниже полный минимальный BadRefundLedger для чтения. Название и комментарий намеренно обозначают его непригодность для использования. Он отделён от CourseCatalog и исправленного CEIRefundLedger.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
// Учебный контрпример с ошибкой порядка записи.
contract BadRefundLedger {
error InvalidAmount();
error TransferFailed();
mapping(address => uint256) public credit;
function deposit() external payable {
if (msg.value == 0) revert InvalidAmount();
credit[msg.sender] += msg.value;
}
function withdrawAll() external {
uint256 amount = credit[msg.sender];
if (amount == 0) revert InvalidAmount();
(bool ok,) = payable(msg.sender).call{value: amount}("");
if (!ok) revert TransferFailed();
credit[msg.sender] = 0;
}
}
Снимок amount берётся до call, но credit остаётся прежним до его возврата. Если msg.sender — наш собственный учебный контракт R, его обработчик приёма может ещё раз вызвать withdrawAll. Второй вход снова прочитает тот же credit[R], хотя первоначальная сумма уже передана.
Два обязательства
Для ясного числового разбора зададим воображаемое начальное состояние: R имеет credit 3, Q имеет credit 3, баланс ledger равен 6. Это условные wei, не реальные средства. R входит withdrawAll, затем его собственный callback делает ровно один повтор и после него больше не повторяет.
до вызова: balance=6, credit[R]=3, credit[Q]=3
первый call R: передано 3, balance=3, credit[R] всё ещё 3
повторный вход: amount снова 3
второй call R: передано ещё 3, balance=0
завершение двух frames: credit[R]=0, credit[Q]=3
Последний credit[R] выглядит ожидаемым нулём, но Q остаётся необеспеченное обязательство 3 при пустом балансе. Именно этот нарушенный общий ресурс объясняет ошибку. Смотреть только итоговый getter одного участника недостаточно для оценки перехода.
Мы ограничили мысленную вложенность одним повтором, чтобы она завершалась без недостатка баланса. Если получатель повторяет бесконечно либо вызывает третий возврат при пустом балансе, ошибки могут откатить всю цепочку. Наличие такого неуспешного сценария не доказывает безопасность другого конечного сценария.
Порядок исправления
Исправленный withdrawAll должен взять amount, проверить ненулевой credit, затем поставить credit[msg.sender]=0 и только потом сделать call. Если второй вход прочитает ноль, он не сможет потребовать прежнее обязательство. При отклонённом внешнем call revert восстановит исходную запись.
// Фрагмент исправления внутри отдельной модели с теми же объявлениями.
uint256 amount = credit[msg.sender];
if (amount == 0) revert InvalidAmount();
credit[msg.sender] = 0;
(bool ok,) = payable(msg.sender).call{value: amount}("");
if (!ok) revert TransferFailed();
Это фрагмент тела, а не самостоятельный полный контракт. Он демонстрирует новую точку записи. Получатель может попытаться повторить вызов, но его outcome будет зависеть от обработки ожидаемого отклонения в собственном callback. Нельзя объявить, что любой callback обязательно заставит успешный возврат откатиться.
Проверка ресурса вместо названия метода
В этой главе повторяется один withdrawAll. Но в реальном приложении тот же credit может использовать transferCredit, покупка услуги либо выдача токена. Если основной метод защищён, а другой путь видит старый ресурс, возможна иная форма повторного расходования. Следующая глава разберёт именно такую комбинацию.
Поэтому аудит не останавливается на поиске строки call перед одной строкой присваивания. Нужно знать, какие записи общий ресурс связывает, кто может читать их внутри взаимодействия и какие методы достижимы. Старое значение может быть опасно даже при отсутствии повторного вызова того же selector.
ReentrancyGuard и область применения
OpenZeppelin ReentrancyGuard 5.4.0 предоставляет nonReentrant для ограничения вложенного входа в защищённые методы одного экземпляра. Это полезное дополнительное средство. Оно не делает ошибочную модель правильной во всех будущих расширениях и не защищает автоматически каждый новый внешний метод.
Если две защищённые внешние функции должны разделять логику, обычно выделяют подходящий внутренний helper, чтобы не заставлять один nonReentrant метод входить в другой таким же внешним маршрутом. Конкретная композиция модификаторов должна сохранять ожидаемое разрешение обычных вызовов и отклонение нежелательных вложенных.
Также guard не исправляет доверие к произвольному token contract, неверное округление или утраченный административный ключ. Он относится к определённому виду контроля вложенности. Модель угроз остаётся нужна, даже когда библиотечный модификатор применён без синтаксической ошибки.
Что наблюдается в будущей лаборатории
Для будущего локального сценария нужны состояние до, порядок входов, условие завершения получателя и состояние после. Достаточно собственных фикстур; не нужны адреса чужих систем. Ожидаемый результат сравнивается с инвариантом обеспечения всех обязательств, а не только с количеством сообщений в console.
В текущей задаче такие программы и проверки не запускаются. Поэтому мы описали условный trace по исходнику и арифметике. Нельзя подписать эту таблицу «полученный лог» или заявить, что конкретная компиляция подтвердила достижимость, когда она не выполнялась.
Самостоятельное изменение условий
Начните с баланса 6 и двух credit по 3. Затем оставьте только credit R=3 и баланс 3. Объясните, почему повторный call может не завершить тот же успешный trace при нехватке средств. Отдельно добавьте неучтённый излишек и определите, что произойдёт с обязательством Q.
После этого переставьте credit=0 до взаимодействия и повторите карту состояний. Отметьте разницу между успешным одним возвратом, отвергнутым повтором и откатом при отклоняющем получателе. Именно промежуточное состояние и согласованные эффекты, а не слово safe в имени, дают основание для следующего анализа общего ресурса.
Последняя запись и старый снимок
Особенно обратите внимание на credit[msg.sender]=0 после взаимодействия. Она не вычитает каждую реально выполненную выплату. Оба frames в контрпримере просто ставят один и тот же ноль, поэтому итог не хранит число повторных расходов. Так обычное наблюдение «обязательство списано» способно скрыть нарушение обеспечения другого участника.
Если заменить эту строку на credit-=amount после call, второй вход может вызвать checked-underflow при возвращении первого frame и откатить конкретный trace. Это другое ошибочное поведение, которое не следует путать с нашим успешным конечным контрпримером. Исправление состоит в правильной подготовке ресурса до внешней границы, а не в случайном получении поздней арифметической ошибки.
Для самостоятельного уточнения: security-considerations.html, ReentrancyGuard.sol (5.4.0).
Оглавление курса. Листинги, расчёты и наблюдения здесь разобраны как ожидаемое поведение; компиляция, учебные программы и проверки при написании не запускались.