Блокчейн
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
Предварительные знания
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)?