Блокчейн

Фаззинг и тестирование контрактов

Разработчик пишет 200 unit-тестов, все зелёные. Аудиторы проверяют контракт, находят пару мелких замечаний. Деплой в mainnet. Через три недели хакер находит последовательность из четырёх вызовов, о которой никто не думал, и выводит 197 миллионов. Это не гипотетический сценарий, это Euler Finance, март 2023 года. Unit-тесты проверяют сценарии, которые придумал разработчик. Фаззинг проверяет сценарии, которые не придумал никто. Он генерирует тысячи случайных входов и последовательностей вызовов, пытаясь сломать инварианты контракта. Один прогон фаззера может найти баг, на поиск которого человеку потребовались бы годы.

  • **Trail of Bits (Echidna)** обнаружили критические баги в Compound, MakerDAO и десятках DeFi-протоколов через property-based фаззинг. Один из багов в Compound позволял бесконечный минт cTokens - Echidna нашёл нарушение инварианта totalSupply за 12 секунд, что ручной аудит пропустил
  • **Paradigm (Foundry)** сделали фаззинг доступным каждому Solidity-разработчику. Uniswap V4, OpenSea Seaport, Optimism - все используют Foundry fuzz и invariant-тесты как обязательную часть CI/CD. Fuzz-тесты Seaport нашли edge case в обработке partial fills, который прошёл мимо трёх аудиторских команд
  • **Euler Finance (март 2023, 197M)** имел 100% line coverage и прошёл шесть аудитов. Баг был в комбинации donateToReserves() + self-liquidation, которую не покрывали unit-тесты. После инцидента команда внедрила invariant-тесты, проверяющие платёжеспособность после произвольных последовательностей вызовов

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

  • Reentrancy and Classic Attacks

Echidna: property-based фаззинг смарт-контрактов

Представьте, что вы наняли тысячу тестировщиков, каждый из которых бесконечно подбирает входные данные к вашему контракту, пытаясь сломать его инварианты. Один вводит ноль, другой - максимальный uint256, третий - адрес контракта вместо EOA. Именно так работает **фаззинг** (fuzzing): генератор автоматически создаёт огромное количество случайных входных данных и проверяет, что определённые свойства (properties) контракта никогда не нарушаются. **Echidna** - это property-based фаззер от Trail of Bits, написанный на Haskell, который специализируется на смарт-контрактах Solidity.

Echidna не просто генерирует случайные байты. Он понимает ABI контракта и генерирует **корректные последовательности вызовов функций** с типизированными аргументами. Если контракт имеет функции `deposit(uint256)` и `withdraw(uint256)`, Echidna будет генерировать цепочки: `deposit(42)` → `withdraw(10)` → `deposit(0)` → `withdraw(type(uint256).max)` и так далее, пытаясь найти последовательность, которая нарушает указанное свойство. При обнаружении нарушения включается **shrinking** - сужение контрпримера до минимального воспроизводимого сценария.

**Corpus management** - одна из сильных сторон Echidna. Параметр `corpusDir` сохраняет транзакции, которые увеличили покрытие кода. При повторных запусках Echidna начинает с этих сохранённых последовательностей, что значительно ускоряет нахождение глубоко спрятанных багов. Команды используют corpus в CI: каждый прогон дополняет базу, и со временем фаззер "обучается" вашему контракту.

В Echidna свойство (property) для тестирования определяется как функция с префиксом echidna_, которая возвращает bool. При каком результате вызова Echidna считает, что обнаружен баг?

Foundry Fuzz: фаззинг в экосистеме Forge

Echidna - удобный специализированный инструмент, но он требует отдельной установки и конфигурации. **Foundry** (Forge) интегрирует фаззинг прямо в стандартный тестовый фреймворк: любой тест-функции достаточно принять параметры, и Forge автоматически начнёт подставлять случайные значения. Это делает фаззинг доступным без порога входа - вы просто пишете тест с аргументами вместо hardcoded значений.

Foundry использует **convention over configuration**: если тест-функция принимает параметры, Forge автоматически переключается в fuzz mode. Имя функции начинается с `testFuzz_` (или просто `test_` с параметрами). Forge генерирует случайные значения для каждого параметра и запускает тест указанное количество раз (по умолчанию 256). Для фильтрации невалидных входов используется `vm.assume(condition)` - если condition == false, этот прогон отбрасывается и генерируется новый набор входов.

Fuzz-тест в Foundry содержит строку `vm.assume(amountA > 0 && amountA < 100)`. При запуске с runs = 10000 Forge сообщает: "Too many rejected inputs (max_test_rejects reached)". Как лучше исправить проблему?

Invariant Testing: stateful фаззинг

Обычный fuzz-тест проверяет одну функцию с разными входами. Но баги в смарт-контрактах часто возникают из-за **последовательности** вызовов: deposit → borrow → price change → liquidate. **Invariant testing** (stateful fuzzing) - это следующий уровень: фаззер генерирует не отдельные входы, а **цепочки вызовов** разных функций и после каждой цепочки проверяет, что глобальные инварианты контракта не нарушены.

В Foundry invariant-тесты используют **handler-контракты** - обёртки, которые вызывают функции целевого контракта от имени разных пользователей и с подготовленными данными. Handler-контракт решает проблему «слепого» фаззинга: вместо подачи случайного адреса (который не имеет баланса), handler сначала делает `deal()` или `mint()`, а затем вызывает целевую функцию. `targetContract()` указывает Forge, какие контракты фаззить. `targetSelector()` ограничивает набор функций.

**Ghost variables** - критически важный паттерн для invariant-тестирования. Без них невозможно проверить accounting-инварианты: контракт хранит только текущее состояние (totalDeposits), но не историю (сколько всего было внесено и выведено). Ghost variables ведут параллельный учёт в handler-контракте и позволяют обнаружить расхождения между ожидаемым и фактическим состоянием.

В invariant-тесте lending-протокола handler-контракт содержит ghost_totalDeposited и ghost_totalWithdrawn. Зачем нужны эти ghost variables, если у контракта уже есть totalDeposits?

Coverage: метрики покрытия и стратегия тестирования

Вы написали 200 unit-тестов, 50 fuzz-тестов и 10 invariant-тестов. Все зелёные. Контракт безопасен? **Нет** - если вы не знаете, какую часть кода эти тесты реально проверяют. **Code coverage** - метрика, показывающая, какой процент кода был выполнен во время тестирования. Forge предоставляет отчёт по четырём метрикам: line coverage (строки), branch coverage (ветки if/else), function coverage (функции) и statement coverage (выражения).

**100% coverage не гарантирует безопасность.** Euler Finance имел 100% line coverage и прошёл несколько аудитов перед взломом на 197M в марте 2023 года. Баг был в бизнес-логике: donateToReserves() позволяла уничтожить collateral без ликвидации долга. Тесты покрывали каждую строку, но не проверяли **комбинацию** вызовов, которая нарушала инвариант платёжеспособности. Поэтому invariant-тесты критичны: unit-тесты проверяют отдельные функции, а invariant-тесты - свойства системы после произвольных последовательностей вызовов.

**Mutation testing** - техника, которая проверяет качество самих тестов. Инструмент (например, vertigo-rs для Solidity или gambit от Certora) автоматически вносит небольшие изменения в код контракта - **мутации**: заменяет `>` на `>=`, `+` на `-`, удаляет строки. Затем запускает тесты. Если тесты по-прежнему проходят на мутированном коде - значит они не ловят этот класс ошибок. **Mutation score** - процент мутантов, которых убили тесты. Высокий coverage с низким mutation score означает, что тесты выполняют код, но не проверяют результаты.

100% code coverage означает, что контракт полностью протестирован и безопасен для деплоя в production

Coverage показывает только, какой код был выполнен, но не что он был проверен на корректность. Euler Finance имел 100% coverage и прошёл несколько аудитов, но потерял 197M из-за бага в бизнес-логике, который не ловился отдельными unit-тестами - только invariant-тестирование или формальная верификация могли обнаружить опасную комбинацию вызовов

Unit-тесты проверяют отдельные функции в изоляции с конкретными входами. Но смарт-контракты живут в среде, где любой может вызвать любую public-функцию в любом порядке. Баг Euler был в последовательности: donateToReserves() уничтожала collateral, после чего self-liquidation создавала bad debt. Каждая функция в отдельности работала корректно - проблема была в их комбинации. Поэтому пирамида тестирования включает пять уровней: static analysis, unit tests, fuzz tests, invariant tests и formal verification. Ни один уровень не заменяет остальные.

Проект имеет 100% line coverage и 95% branch coverage. Все 300 unit-тестов проходят. Mutation testing показывает mutation score 40%. Что это означает?

Итоги

  • **Echidna** - property-based фаззер от Trail of Bits. Генерирует типизированные последовательности вызовов, проверяет свойства через `echidna_*` функции (return false = баг найден), автоматически сужает контрпримеры через shrinking и накапливает corpus для повышения покрытия
  • **Foundry fuzz** интегрирует фаззинг в стандартный тестовый фреймворк: тест с параметрами автоматически становится fuzz-тестом. `bound()` для числовых диапазонов, `vm.assume()` для логических фильтров, differential testing для сравнения реализаций
  • **Invariant testing** (stateful fuzzing) - проверка глобальных свойств системы после случайных последовательностей вызовов. Handler-контракты подготавливают валидные входы, ghost variables ведут независимый учёт для верификации accounting-инвариантов
  • **Coverage** - метрика покрытия кода (line, branch, function, statement). `forge coverage` генерирует отчёт. Mutation testing проверяет качество самих тестов: выживший мутант = тест не ловит данный класс ошибок
  • Euler Finance потерял 197M при 100% coverage и шести аудитах - unit-тесты не нашли опасную комбинацию вызовов. Пирамида безопасности (static analysis → unit → fuzz → invariant → formal verification) - единственный путь к реальной защите, потому что каждый уровень ловит класс багов, который пропускают остальные

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

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

  • Формальная верификация — Верхний уровень пирамиды тестирования. Там, где фаззинг проверяет свойства на случайных входах, формальная верификация (Certora, Halmos) математически доказывает их для всех возможных входов
  • Аудит безопасности — Аудиторы используют Echidna и Foundry fuzz как часть методологии. Результаты фаззинга (coverage отчёт, найденные инварианты) входят в аудиторский отчёт. Ручной аудит дополняет автоматизированное тестирование
  • Reentrancy и классические атаки — Invariant-тесты особенно эффективны для поиска reentrancy: фаззер генерирует произвольные последовательности вызовов, включая re-entrant паттерны, и проверяет, что балансы остаются консистентными
  • Основы Solidity — Фундамент для написания тестовых контрактов: наследование, модификаторы, типы данных. Handler-контракты и echidna_* свойства используют стандартные паттерны Solidity

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

  • Echidna генерирует случайные последовательности вызовов, но реальные атаки часто требуют точной последовательности с flash loans, oracle manipulation и reentrancy. Как бы вы дополнили стандартный фаззинг, чтобы увеличить вероятность нахождения таких комплексных атак? Какие свойства стоит формулировать в первую очередь?
  • Mutation testing показывает mutation score 40% при 100% line coverage. Это значит, что 60% изменений в коде не обнаруживаются тестами. Как вы бы приоритизировали исправление: по критичности функций (withdraw > view) или по типу мутаций (арифметика > логика)? Какой mutation score вы бы считали достаточным для mainnet деплоя?
  • Invariant-тесты с handler-контрактами сложнее в написании и поддержке, чем unit-тесты. Для небольшого проекта (1 контракт, 5 функций) стоит ли инвестировать время в invariant-тесты, или достаточно unit + fuzz? Где проходит граница, после которой invariant-тесты становятся обязательными?

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

  • sec-05
Фаззинг и тестирование контрактов

0

1

Войти