Блокчейн

Reentrancy и классические атаки

17 июня 2016 года одна функция в смарт-контракте позволила украсть 60 миллионов за несколько часов. Атакующий не взламывал криптографию и не подбирал ключи. Он просто вызвал функцию вывода средств, а когда контракт отправил ему ETH, его код вызвал ту же функцию снова, и снова, и снова пока деньги не закончились. Баланс не обнулялся, потому что строка обнуления стояла после отправки. Одна строка кода не на том месте расколола Ethereum на два блокчейна и навсегда изменила подход к безопасности смарт-контрактов.

  • **The DAO (2016, 60M)** - первый и самый известный reentrancy-взлом. Функция splitDAO() отправляла ETH до обнуления баланса. Последствия настолько масштабны, что привели к hard fork и расколу Ethereum на ETH и Ethereum Classic
  • **Curve Finance (июль 2023, 70M)** - reentrancy через уязвимость в компиляторе Vyper. Баг в версиях 0.2.15-0.3.0 нарушал работу reentrancy lock, позволяя повторный вход несмотря на наличие защиты в коде
  • **Rari Capital / Fei Protocol (2022, 80M)** - cross-function reentrancy через CToken compound fork. Атакующий эксплуатировал взаимодействие между borrow и другими функциями, которые не были защищены единым reentrancy guard

Предварительные знания

  • Solidity: Language Basics
  • Smart Contract Patterns

Reentrancy: повторный вход в контракт

Представьте банковский кассир, который выдаёт деньги со счёта, но обновляет остаток **после** выдачи. Пока он записывает новый баланс, вы подходите к соседнему окну и снимаете ту же сумму ещё раз - старый баланс ещё не изменился. Именно так работает **reentrancy** в смарт-контрактах: внешний вызов передаёт управление другому контракту, и тот **повторно входит** в вызывающий контракт до того, как состояние было обновлено.

В EVM существует три способа отправить ETH: `transfer()` (2300 gas limit, deprecated), `send()` (2300 gas limit, deprecated) и `call{value: ...}("")` (рекомендуемый, **без лимита gas**). Именно `call` делает reentrancy возможной: получатель-контракт получает достаточно gas для выполнения произвольного кода в своей `receive()` или `fallback()` функции - включая повторный вызов отправителя.

Reentrancy - не теоретическая угроза. По данным **Rekt Leaderboard**, reentrancy-атаки входят в топ-5 причин потерь в DeFi. Уязвимость работает не только с ETH: любой external call к недоверенному адресу - потенциальный вектор reentrancy, включая `safeTransferFrom()` в ERC-721 и ERC-1155, вызовы callback-функций и даже обращения к oracle-контрактам.

Контракт содержит функцию withdraw(), которая сначала отправляет ETH через call{value}(""), а затем обнуляет баланс пользователя. Почему атакующий контракт может вызвать withdraw() повторно до обнуления баланса?

The DAO Hack: 60M и раскол Ethereum

17 июня 2016 года. **The DAO** - первый крупный децентрализованный инвестиционный фонд на Ethereum - управляет $150M (около 14% всего ETH в обращении). В 3:34 UTC неизвестный начинает серию транзакций, которые за несколько часов выводят **$60 миллионов**. Атакующий не взламывал криптографию, не подбирал ключи, не эксплуатировал баг в EVM. Он использовал reentrancy в функции `splitDAO()` - механизме выхода из фонда с забиранием своей доли.

Решение было беспрецедентным: Ethereum Foundation провела **hard fork** - изменение протокола, которое вернуло украденные средства владельцам. Но часть сообщества категорически отвергла это решение: если блокчейн можно "откатить" по решению группы людей, в чём его смысл? Несогласные продолжили работу на оригинальной цепи - так родился **Ethereum Classic (ETC)**, а форк стал **Ethereum (ETH)**, каким мы его знаем. Один баг в смарт-контракте расколол второй по величине блокчейн-проект на два.

Интересная деталь: средства атакующего были заблокированы в "child DAO" на 27 дней (встроенная задержка в протоколе The DAO). Это дало сообществу время на принятие решения о hard fork. Без этой задержки 60M были бы безвозвратно потеряны. Сегодня протоколы используют **Timelock** для аналогичной цели - дать время на реакцию при обнаружении атаки.

The DAO Hack стал переломным моментом для безопасности смарт-контрактов. До него аудит кода не был стандартной практикой. После - появились Trail of Bits, OpenZeppelin (библиотеки безопасных контрактов), формальная верификация, bug bounty программы. Каждый доллар, вложенный в аудит, потенциально предотвращает потери в тысячи раз большие.

Какова была корневая причина уязвимости The DAO, позволившая вывести 60M?

Checks-Effects-Interactions: паттерн безопасного порядка

После The DAO Hack сообщество Solidity формализовало главное правило предотвращения reentrancy - **Checks-Effects-Interactions (CEI)**. Идея проста: каждая функция должна выполнять операции в строгом порядке. Сначала **Checks** - все проверки условий (`require`, `if...revert`). Затем **Effects** - все изменения state (`balances[user] = 0`). И только в самом конце **Interactions** - внешние вызовы (`call`, `transfer`, вызовы других контрактов). Если The DAO следовала бы CEI, взлом бы не состоялся.

CEI решает single-function reentrancy, но существуют более изощрённые варианты. **Cross-function reentrancy** - атакующий из callback вызывает не ту же функцию, а другую функцию того же контракта, которая зависит от ещё не обновлённого состояния. **Read-only reentrancy** - атакующий из callback вызывает `view`-функцию, которая возвращает устаревшие данные, и эти данные используются в другом контракте (например, price oracle).

**Read-only reentrancy** - относительно новый вектор атаки (2022-2023). Если контракт A вычисляет цену актива вызовом `view`-функции контракта B, а контракт B находится в середине транзакции с незавершённым state, контракт A получит **устаревшую цену**. Curve pool + Balancer стали жертвами этого вектора. CEI не защищает от read-only reentrancy - нужен ReentrancyGuard на `view`-функциях или проверка lock-статуса.

Контракт имеет две функции: withdraw() (следует CEI - обнуляет баланс ДО external call) и transfer() (переводит баланс другому пользователю). Может ли атакующий эксплуатировать cross-function reentrancy?

ReentrancyGuard: блокировка повторного входа

CEI - необходимая дисциплина, но в сложных контрактах с десятками функций и взаимосвязанными state-переменными полагаться только на порядок операций рискованно. **ReentrancyGuard** - это mutex (mutual exclusion lock) для смарт-контрактов: он физически блокирует повторный вход в любую защищённую функцию, пока текущий вызов не завершился. OpenZeppelin предоставляет готовую реализацию, которую используют Aave, Compound, Uniswap и сотни других протоколов.

С Dencun upgrade (март 2024) Ethereum поддерживает **transient storage** (EIP-1153) - хранилище, которое автоматически очищается в конце транзакции. Для ReentrancyGuard это идеально: lock нужен только на время одной транзакции, и transient storage стоит всего **100 gas** за запись вместо 5,000. OpenZeppelin 5.1+ предоставляет `ReentrancyGuardTransient` - замена с экономией ~4,900 gas на каждый вызов.

Важно понимать, когда ReentrancyGuard **недостаточен**. Он защищает от повторного входа в **тот же контракт**, но **cross-contract reentrancy** - когда атакующий из callback вызывает **другой контракт**, который обращается к данным первого - ReentrancyGuard не предотвратит. Для этого нужна стратегия **defense in depth**: CEI + ReentrancyGuard + Pausable + правильная архитектура.

**Чеклист безопасности при работе с external calls:** 1. CEI - обновляй state до вызова. 2. nonReentrant на всех функциях с external calls. 3. Pausable на критических функциях. 4. Минимальный gas forwarding, где возможно. 5. Pull Payment вместо Push, когда архитектура позволяет. 6. Слежение за read-only reentrancy, если контракт предоставляет `view`-функции, используемые другими протоколами как price oracle.

ReentrancyGuard полностью защищает контракт от всех видов reentrancy-атак, поэтому CEI-паттерн не обязателен при его использовании

ReentrancyGuard защищает от повторного входа в тот же контракт, но бессилен против cross-contract reentrancy: атакующий из callback может вызвать другой контракт, который взаимодействует с незавершённым состоянием первого. CEI остаётся фундаментальным требованием, а ReentrancyGuard - дополнительный слой защиты

В экосистеме DeFi контракты постоянно взаимодействуют друг с другом: lending-протокол вызывает oracle, oracle вызывает DEX, DEX вызывает pool. ReentrancyGuard одного контракта не может контролировать, какие вызовы происходят между другими контрактами. Поэтому production-протоколы применяют defense in depth: CEI + ReentrancyGuard + Pausable + rate limiting + мониторинг аномалий. Ни один отдельный механизм не является достаточным.

OpenZeppelin ReentrancyGuard использует uint256 (значения 1 и 2) вместо bool (false/true) для хранения lock-статуса. Какова основная причина?

Итоги

  • **Reentrancy** возникает, когда external call передаёт управление недоверенному контракту до обновления state. Через `receive()`/`fallback()` атакующий повторно вызывает уязвимую функцию - state ещё не обновлён, проверки проходят, средства выводятся повторно
  • **The DAO Hack (2016)** - 60M украдено через reentrancy в splitDAO(). Последствия вышли за рамки одного контракта: hard fork, раскол на ETH и ETC, рождение индустрии аудита смарт-контрактов
  • **Checks-Effects-Interactions (CEI)** - фундаментальный паттерн: все проверки, затем все изменения state, и только потом external calls. Защищает от single-function reentrancy, но cross-function и read-only reentrancy требуют дополнительных мер
  • **ReentrancyGuard** - mutex через uint256 (1↔2), который блокирует повторный вход в контракт. С EIP-1153 (transient storage) стоимость снижается с ~10,000 до ~200 gas
  • Ни одно средство не является абсолютной защитой - одна неправильно расположенная строка в The DAO расколола Ethereum надвое. **Defense in depth**: CEI + ReentrancyGuard + Pausable + мониторинг - единственный надёжный подход

Связанные темы

Reentrancy - фундаментальная тема безопасности, которая связана с паттернами Solidity и более продвинутыми атаками:

  • Паттерны смарт-контрактов — Pull Payment, CEI и ReentrancyGuard из этого урока впервые представлены как паттерны. Здесь мы разбираем их с позиции атакующего
  • Безопасность: overflow и underflow — Другой класс арифметических уязвимостей - integer overflow/underflow. До Solidity 0.8 они были столь же разрушительны, как reentrancy
  • Fuzzing смарт-контрактов — Автоматический поиск reentrancy через fuzzing: Echidna и Foundry генерируют последовательности вызовов, находя неожиданные reentrancy-векторы
  • Аудит безопасности — Reentrancy - первое, что проверяют аудиторы. Методология аудита, чеклисты и инструменты статического анализа (Slither, Mythril)

Вопросы для размышления

  • The DAO Hack привёл к hard fork, который вернул 60M владельцам. С одной стороны - справедливость восстановлена. С другой - принцип "код есть закон" нарушен. Где проходит граница, после которой вмешательство оправдано? Стоит ли менять правила блокчейна ради возврата украденных средств?
  • ReentrancyGuard стоит ~5,000-10,000 gas за каждый вызов защищённой функции. В протоколе с миллионами транзакций это существенные затраты. Как найти баланс между безопасностью и gas-эффективностью? Может ли transient storage (EIP-1153) полностью решить эту дилемму?
  • Read-only reentrancy - вектор, при котором view-функции возвращают устаревшие данные во время незавершённой транзакции. Как вы бы защитили price oracle от этой атаки, учитывая что view-функции не могут использовать стандартный ReentrancyGuard (они не меняют state)?

Связанные уроки

  • sec-01
Reentrancy и классические атаки

0

1

Войти