EVM — низкоуровневая виртуальная машина Ethereum (EVM), а не «обычный серверный рантайм». Многие критические взломы dApp были не про «гениальные 0-day», а про банальные ловушки модели EVM, которые разработчики недооценили.
Эта страница — практический обзор ключевых pitfalls, с которыми сталкиваются команды, пишущие на Solidity/Vyper поверх архитектуры Ethereum.
Почему EVM — не «просто бэкенд»
- Контракты не могут обновляться молча: всё, что вы деплойнули, живёт в блокчейне и навсегда видно.
- Любой вызов — это публичная транзакция (транзакция) в мемпуле, доступная для анализа и MEV-эксплойта (MEV).
- В EVM нет «сессий» и «скрытого состояния» на пользователя: есть только хранение в storage и сообщения между адресами.
Типичные проблемы начинаются, когда к EVM подходят с мышлением «обычного» веб-бэкенда: считают, что порядок операций фиксирован, данные «никто не увидит заранее», а внешние вызовы «ведут себя честно».
Классические уязвимости в EVM-контрактах
Reentrancy (перезаход) и внешние вызовы
Ловушка: контракт отправляет эфир или вызывает внешний контракт до того, как обновит своё состояние. Внешний адрес может:
- вызвать fallback/receive;
- инициировать повторный вызов уязвимой функции;
- снова пройти проверки и вывести средства второй, третий раз.
Что делать:
- придерживаться паттерна checks → effects → interactions:
- сначала проверить условия,
- обновить внутреннее состояние,
- только затем делать внешние вызовы;
- использовать блокировки reentrancy-guard там, где это оправдано;
- минимизировать количество внешних вызовов внутри «критичных» функций.
delegatecall и «библиотечные» контракты
delegatecall исполняет код другого контракта в вашем контексте storage. Ловушки:
- библиотека может изменить ваши переменные состояния;
- неправильный порядок переменных в слотах приводит к storage-коллизиям;
- при апгрейдах через proxy легко сломать state-layout.
Рекомендации:
- жёстко фиксировать layout storage для upgradeable-контрактов;
- использовать проверенные библиотеки и шаблоны (proxy-паттерны, gap-слоты);
- избегать delegatecall к недоверенным адресам.
tx.origin, msg.sender и авторизация
Классическая ошибка: авторизация через tx.origin («если отправитель — владелец, разрешить»).
Проблема: tx.origin — это оригинальный EOA-отправитель транзакции, а не вызвавший ваш контракт адрес. Злоумышленник может:
- заставить пользователя вызвать свой контракт,
- его контракт вызовет ваш,
- tx.origin будет указывать на пользователя, а msg.sender — на злоумышленника.
Безопасный подход:
- проверять права через msg.sender;
- отдельно продумывать роли (owner, admin, безопасные мультисиги, DAO).
Арифметика, переполнения и unchecked
В старых версиях Solidity переполнения/недополнения uint были источником критичных багов. Сейчас компилятор по умолчанию делает проверки, но:
- в unchecked {} блоках переполнения снова возможны;
- в inline-assembly их можно легко пропустить;
- логические «переключатели», основанные на арифметике, могут вести себя неожиданно.
Практика:
- использовать unchecked только там, где вы на 100 % уверены в диапазоне;
- для сложной математики — отдельные библиотеки и инварианты с тестами.
DoS по газу и «неограниченные» циклы
Ловушка: цикл по динамическим массивам/мэппингам внутри функции, зависящий от пользовательских данных (например, раздать награды всем стейкерам в одном вызове).
Со временем массив растёт:
- газ одного вызова становится слишком большим;
- функцию невозможно выполнить — контракты оказываются в состоянии DoS.
Как решать:
- разбивать операции на шаги (batch-обновления, claim-модели);
- использовать pull-подход: каждый пользователь забирает свою награду сам;
- избегать хранения «всех адресов» в onchain-списках, где требуется полное перечисление.
Семантические ловушки EVM
Временные метки и «случайность»
- block.timestamp и block.number — не источники случайности, а параметры, которыми майнер/валидатор может немного манипулировать;
- если от них зависит результат лотереи, выдача награды или выбор победителя, стимул для манипуляции растёт.
Решения:
- использовать криптографические схемы (commit-reveal, VRF);
- принимать timestamp только как приблизительное время, не завязывать на него критические ветки логики.
selfdestruct и «мёртвые» адреса
Раньше selfdestruct позволял:
- удалить код контракта;
- перевести эфир на целевой адрес.
Ловушки:
- контракты полагаются на то, что по адресу «никогда не будет кода», а он внезапно появляется;
- или наоборот: предполагается, что контракт по адресу существует, а его уже selfdestruct’нули.
Даже с изменениями в протоколе агрессивное использование selfdestruct и предположения о «пустых адресах» остаются источником сюрпризов.
Fallback-функции и приём эфира
- fallback/receive с тяжёлой логикой могут ломать инварианты — особенно если контракт не ожидал, что ему кто-то отправит эфир напрямую;
- контракты, которые предполагают «баланс = сумме депозитов», легко ломаются через прямой transfer/selfdestruct на их адрес.
Лучше:
- явно проектировать модель учёта баланса;
- по возможности ограничивать приём эфира (revert в receive), если он не нужен.
Апгрейды, прокси и storage-коллизии
Популярные схемы upgradeable-контрактов используют:
- один адрес-proxy;
- один (или несколько) implementation-контрактов с логикой;
- delegatecall из proxy в implementation.
Типовые pitfalls:
- изменение порядка переменных в storage без учёта уже деплоенного layout;
- добавление новых переменных в середину старой структуры;
- переиспользование слотов, которые уже заняты переменными прокси.
Это приводит к:
- «перетиранию» данных;
- неожиданному поведению прав доступа;
- скрытым багам, которые не видно на unit-тестах с чистым деплоем.
Практика:
- фиксировать layout в документации и в коде;
- добавлять переменные только в конец;
- использовать «gap-слоты» (зарезервированные массивы/поля для будущих расширений);
- тестировать апгрейды на форкнутых сетях, а не только на локальном Ganache/Hardhat.
Как уменьшить риск: чек-лист для EVM-разработчика
- Мышление «по-EVM-ному»
- Понимать модель сообщений и storage.
- Всегда учитывать, что внешние вызовы могут вести себя злонамеренно.
- Архитектура контракта
- Разделять бизнес-логику, доступ и хранение.
- Минимизировать использование delegatecall и сложных прокси, если они не критичны.
- Входные данные и авторизация
- Всегда валидировать входные параметры.
- Не использовать tx.origin для доступа.
- Явно описывать роли и права.
- Газ и циклы
- Избегать неограниченных циклов по пользовательским структурам.
- Делать операции claim-базированными, где возможно.
- Тесты и аудит
- Писать property-based и инвариантные тесты (не только happy-path).
- Проверять контракты внешними аудиторами, особенно если в них хранится значимый TVL.
- Периодически пересматривать код с учётом новых атак и best-practice.
- Работа с DeFi-интеграциями
- Понимать риски протоколов, с которыми вы интегрируетесь.
- Не считать чужой контракт «беспровальным», даже если он популярен.
