EVM: типичные ошибки безопасности и ловушки для разработчиков

EVM — низкоуровневая виртуальная машина Ethereum (EVM), а не «обычный серверный рантайм». Многие критические взломы dApp были не про «гениальные 0-day», а про банальные ловушки модели EVM, которые разработчики недооценили.

Эта страница — практический обзор ключевых pitfalls, с которыми сталкиваются команды, пишущие на Solidity/Vyper поверх архитектуры Ethereum.

EVM: типичные ошибки безопасности и ловушки для разработчиков

Почему 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-интеграциями
    • Понимать риски протоколов, с которыми вы интегрируетесь.
    • Не считать чужой контракт «беспровальным», даже если он популярен.

См. также

Task Runner