Перейти к основному содержанию

Как использовать Echidna для тестирования смарт-контрактов

Solidity
Умные контракты
безопасность
тестирование
фаззинг
Advanced
Trailofbits
10 апреля 2020 г.
12 минута прочтения

Установка

Echidna можно установить через docker или с помощью предварительно скомпилированного двоичного файла.

Echidna через docker

docker pull trailofbits/eth-security-toolbox
docker run -it -v "$PWD":/home/training trailofbits/eth-security-toolbox

Последняя команда запускает eth-security-toolbox в контейнере Docker, который имеет доступ к вашему текущему каталогу. Вы можете изменять файлы со своего хоста и запускать инструменты для работы с этими файлами из контейнера Docker_

Внутри docker выполните:

solc-select 0.5.11
cd /home/training

Двоичный файл

https://github.com/crytic/echidna/releases/tag/v1.4.0.0 (opens in a new tab)

Введение в фаззинг на основе свойств

Echidna — это фаззер на основе свойств, как мы описывали в наших предыдущих статьях в блоге (1 (opens in a new tab), 2 (opens in a new tab), 3 (opens in a new tab)).

Фаззинг

Фаззинг (opens in a new tab) — это хорошо известная техника в сообществе безопасности. Она заключается в генерировании более или менее случайных входных данных для поиска ошибок в программе. Фаззеры для традиционного программного обеспечения (такие как AFL (opens in a new tab) или LibFuzzer (opens in a new tab)) известны как эффективные инструменты для поиска ошибок.

Помимо чисто случайной генерации входных данных, существует множество методов и стратегий для создания качественных входных данных, в том числе:

  • Получение обратной связи от каждого выполнения и использование ее для направления генерации. Например, если вновь сгенерированные входные данные приводят к обнаружению нового пути, имеет смысл сгенерировать новые входные данные, близкие к ним.
  • Генерация входных данных с соблюдением структурных ограничений. Например, если ваши входные данные содержат заголовок с контрольной суммой, имеет смысл позволить фаззеру генерировать входные данные, проверяющие контрольную сумму.
  • Использование известных входных данных для генерации новых: если у вас есть доступ к большому набору действительных входных данных, ваш фаззер может генерировать новые входные данные из них, а не начинать генерацию с нуля. Обычно их называют затравками.

Фаззинг на основе свойств

Echidna принадлежит к особому семейству фаззеров: фаззинг на основе свойств, в значительной степени вдохновленный QuickCheck (opens in a new tab). В отличие от классического фаззера, который пытается найти сбои, Echidna пытается нарушить определенные пользователем инварианты.

В смарт-контрактах инварианты — это функции Solidity, которые могут представлять любое неверное или недопустимое состояние, в которое может перейти контракт, в том числе:

  • Неправильный контроль доступа: злоумышленник стал владельцем контракта.
  • Неправильный конечный автомат: токены могут быть переданы, пока контракт приостановлен.
  • Неправильная арифметика: пользователь может вызвать целочисленное опустошение (underflow) своего баланса и получить неограниченное количество бесплатных токенов.

Тестирование свойства с помощью Echidna

Мы рассмотрим, как тестировать смарт-контракт с помощью Echidna. Целью является следующий смарт-контракт token.sol (opens in a new tab):

1contract Token{
2 mapping(address => uint) public balances;
3 function airdrop() public{
4 balances[msg.sender] = 1000;
5 }
6 function consume() public{
7 require(balances[msg.sender]>0);
8 balances[msg.sender] -= 1;
9 }
10 function backdoor() public{
11 balances[msg.sender] += 1;
12 }
13}
Показать все

Мы сделаем предположение, что этот токен должен обладать следующими свойствами:

  • Любой может иметь не более 1000 токенов
  • Токен не может быть передан (это не токен ERC20)

Написание свойства

Свойства Echidna — это функции Solidity. Свойство должно:

  • Не иметь аргументов
  • Возвращать true в случае успеха
  • Иметь имя, начинающееся с echidna

Echidna будет:

  • Автоматически генерировать произвольные транзакции для проверки свойства.
  • Сообщать о любых транзакциях, которые заставляют свойство возвращать false или вызывать ошибку.
  • Отбрасывать побочные эффекты при вызове свойства (то есть если свойство изменяет переменную состояния, это изменение отменяется после теста)

Следующее свойство проверяет, что у вызывающего не более 1000 токенов:

1function echidna_balance_under_1000() public view returns(bool){
2 return balances[msg.sender] <= 1000;
3}

Используйте наследование, чтобы отделить ваш контракт от ваших свойств:

1contract TestToken is Token{
2 function echidna_balance_under_1000() public view returns(bool){
3 return balances[msg.sender] <= 1000;
4 }
5 }

token.sol (opens in a new tab) реализует свойство и наследуется от токена.

Инициализация контракта

Echidna требуется конструктор без аргументов. Если вашему контракту требуется особая инициализация, ее необходимо выполнить в конструкторе.

В Echidna есть несколько особых адресов:

  • 0x00a329c0648769A73afAc7F9381E08FB43dBEA72, который вызывает конструктор.
  • 0x10000, 0x20000 и 0x00a329C0648769a73afAC7F9381e08fb43DBEA70, которые случайным образом вызывают другие функции.

В нашем текущем примере не требуется никакой особой инициализации, поэтому наш конструктор пуст.

Запуск Echidna

Echidna запускается с помощью:

echidna-test contract.sol

Если contract.sol содержит несколько контрактов, вы можете указать целевой контракт:

echidna-test contract.sol --contract MyContract

Итог: тестирование свойства

Ниже приведены итоги запуска Echidna на нашем примере:

1contract TestToken is Token{
2 constructor() public {}
3 function echidna_balance_under_1000() public view returns(bool){
4 return balances[msg.sender] <= 1000;
5 }
6 }
echidna-test testtoken.sol --contract TestToken
...
echidna_balance_under_1000: failed!💥
Call sequence, shrinking (1205/5000):
airdrop()
backdoor()
...
Показать все

Echidna обнаружила, что свойство нарушается, если вызывается backdoor.

Фильтрация функций для вызова во время фаззинга

Мы рассмотрим, как фильтровать функции, подлежащие фаззингу. Целью является следующий смарт-контракт:

1contract C {
2 bool state1 = false;
3 bool state2 = false;
4 bool state3 = false;
5 bool state4 = false;
6
7 function f(uint x) public {
8 require(x == 12);
9 state1 = true;
10 }
11
12 function g(uint x) public {
13 require(state1);
14 require(x == 8);
15 state2 = true;
16 }
17
18 function h(uint x) public {
19 require(state2);
20 require(x == 42);
21 state3 = true;
22 }
23
24 function i() public {
25 require(state3);
26 state4 = true;
27 }
28
29 function reset1() public {
30 state1 = false;
31 state2 = false;
32 state3 = false;
33 return;
34 }
35
36 function reset2() public {
37 state1 = false;
38 state2 = false;
39 state3 = false;
40 return;
41 }
42
43 function echidna_state4() public returns (bool) {
44 return (!state4);
45 }
46}
Показать все

Этот небольшой пример заставляет Echidna найти определенную последовательность транзакций, чтобы изменить переменную состояния. Это сложно для фаззера (рекомендуется использовать инструмент символьного выполнения, такой как Manticore (opens in a new tab)). Мы можем запустить Echidna, чтобы проверить это:

echidna-test multi.sol
...
echidna_state4: passed! 🎉
Seed: -3684648582249875403

Фильтрация функций

Echidna испытывает трудности с поиском правильной последовательности для тестирования этого контракта, потому что две функции сброса (reset1 и reset2) установят все переменные состояния в false. Однако мы можем использовать специальную функцию Echidna, чтобы либо добавить функции сброса в черный список, либо добавить в белый список только функции f, g, h и i.

Чтобы добавить функции в черный список, мы можем использовать этот файл конфигурации:

1filterBlacklist: true
2filterFunctions: ["reset1", "reset2"]

Другой подход к фильтрации функций — перечислить функции из белого списка. Для этого мы можем использовать этот файл конфигурации:

1filterBlacklist: false
2filterFunctions: ["f", "g", "h", "i"]
  • filterBlacklist по умолчанию имеет значение true.
  • Фильтрация будет выполняться только по имени (без параметров). Если у вас есть f() и f(uint256), фильтр "f" будет соответствовать обеим функциям.

Запуск Echidna

Чтобы запустить Echidna с файлом конфигурации blacklist.yaml:

echidna-test multi.sol --config blacklist.yaml
...
echidna_state4: failed!💥
Call sequence:
f(12)
g(8)
h(42)
i()

Echidna почти сразу найдет последовательность транзакций, чтобы опровергнуть это свойство.

Итог: фильтрация функций

Echidna может либо добавлять функции в черный, либо в белый список для вызова во время фаззинга, используя:

1filterBlacklist: true
2filterFunctions: ["f1", "f2", "f3"]
echidna-test contract.sol --config config.yaml
...

Echidna начинает кампанию фаззинга, либо добавляя в черный список f1, f2 и f3, либо вызывая только их, в зависимости от значения логической переменной filterBlacklist.

Как тестировать утверждения (assert) Solidity с помощью Echidna

В этом коротком руководстве мы покажем, как использовать Echidna для тестирования проверки утверждений в контрактах. Предположим, у нас есть такой контракт:

1contract Incrementor {
2 uint private counter = 2**200;
3
4 function inc(uint val) public returns (uint){
5 uint tmp = counter;
6 counter += val;
7 // tmp <= counter
8 return (counter - tmp);
9 }
10}
Показать все

Написание утверждения

Мы хотим убедиться, что tmp меньше или равно counter после возврата их разницы. Мы могли бы написать свойство Echidna, но нам нужно было бы где-то хранить значение tmp. Вместо этого мы могли бы использовать такое утверждение:

1contract Incrementor {
2 uint private counter = 2**200;
3
4 function inc(uint val) public returns (uint){
5 uint tmp = counter;
6 counter += val;
7 assert (tmp <= counter);
8 return (counter - tmp);
9 }
10}
Показать все

Запуск Echidna

Чтобы включить тестирование сбоев утверждений, создайте файл конфигурации Echidna (opens in a new tab) config.yaml:

1checkAsserts: true

Когда мы запускаем этот контракт в Echidna, мы получаем ожидаемые результаты:

echidna-test assert.sol --config config.yaml
Analyzing contract: assert.sol:Incrementor
assertion in inc: failed!💥
Call sequence, shrinking (2596/5000):
inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)
inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)
inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)
Seed: 1806480648350826486
Показать все

Как вы можете видеть, Echidna сообщает о сбое утверждения в функции inc. Можно добавить более одного утверждения на функцию, но Echidna не сможет сказать, какое именно утверждение не удалось.

Когда и как использовать утверждения

Утверждения могут использоваться как альтернатива явным свойствам, особенно если условия для проверки напрямую связаны с правильным использованием некоторой операции f. Добавление утверждений после некоторого кода обеспечит, что проверка произойдет сразу после его выполнения:

1function f(..) public {
2 // какой-то сложный код
3 ...
4 assert (condition);
5 ...
6}
7

Напротив, использование явного свойства Echidna приведет к случайному выполнению транзакций, и нет простого способа точно определить, когда будет выполнена проверка. Тем не менее, можно использовать такой обходной путь:

1function echidna_assert_after_f() public returns (bool) {
2 f(..);
3 return(condition);
4}

Однако есть некоторые проблемы:

  • Это не сработает, если f объявлена как internal или external.
  • Непонятно, какие аргументы следует использовать для вызова f.
  • Если f отменяет транзакцию, свойство не будет выполнено.

В целом мы рекомендуем следовать рекомендациям Джона Регера (opens in a new tab) о том, как использовать утверждения:

  • Не вызывайте никаких побочных эффектов во время проверки утверждения. Например: assert(ChangeStateAndReturn() == 1)
  • Не утверждайте очевидные вещи. Например assert(var >= 0), где var объявлена как uint.

Наконец, пожалуйста, не используйте require вместо assert, так как Echidna не сможет это обнаружить (но контракт все равно отменит транзакцию).

Итог: проверка утверждений

Ниже приведены итоги запуска Echidna на нашем примере:

1contract Incrementor {
2 uint private counter = 2**200;
3
4 function inc(uint val) public returns (uint){
5 uint tmp = counter;
6 counter += val;
7 assert (tmp <= counter);
8 return (counter - tmp);
9 }
10}
Показать все
echidna-test assert.sol --config config.yaml
Analyzing contract: assert.sol:Incrementor
assertion in inc: failed!💥
Call sequence, shrinking (2596/5000):
inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)
inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)
inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)
Seed: 1806480648350826486
Показать все

Echidna обнаружила, что утверждение в inc может не сработать, если эта функция вызывается несколько раз с большими аргументами.

Сбор и изменение корпуса Echidna

Мы рассмотрим, как собирать и использовать корпус транзакций с помощью Echidna. Целью является следующий смарт-контракт magic.sol (opens in a new tab):

1contract C {
2 bool value_found = false;
3 function magic(uint magic_1, uint magic_2, uint magic_3, uint magic_4) public {
4 require(magic_1 == 42);
5 require(magic_2 == 129);
6 require(magic_3 == magic_4+333);
7 value_found = true;
8 return;
9 }
10
11 function echidna_magic_values() public returns (bool) {
12 return !value_found;
13 }
14
15}
Показать все

Этот небольшой пример заставляет Echidna найти определенные значения для изменения переменной состояния. Это сложно для фаззера (рекомендуется использовать инструмент символьного выполнения, такой как Manticore (opens in a new tab)). Мы можем запустить Echidna, чтобы проверить это:

echidna-test magic.sol
...
echidna_magic_values: passed! 🎉
Seed: 2221503356319272685

Однако мы все еще можем использовать Echidna для сбора корпуса при запуске этой кампании фаззинга.

Сбор корпуса

Чтобы включить сбор корпуса, создайте каталог корпуса:

mkdir corpus-magic

И файл конфигурации Echidna (opens in a new tab) config.yaml:

1coverage: true
2corpusDir: "corpus-magic"

Теперь мы можем запустить наш инструмент и проверить собранный корпус:

echidna-test magic.sol --config config.yaml

Echidna все еще не может найти правильные магические значения, но мы можем взглянуть на собранный ею корпус. Например, одним из этих файлов был:

1[
2 {
3 "_gas'": "0xffffffff",
4 "_delay": ["0x13647", "0xccf6"],
5 "_src": "00a329c0648769a73afac7f9381e08fb43dbea70",
6 "_dst": "00a329c0648769a73afac7f9381e08fb43dbea72",
7 "_value": "0x0",
8 "_call": {
9 "tag": "SolCall",
10 "contents": [
11 "magic",
12 [
13 {
14 "contents": [
15 256,
16 "93723985220345906694500679277863898678726808528711107336895287282192244575836"
17 ],
18 "tag": "AbiUInt"
19 },
20 {
21 "contents": [256, "334"],
22 "tag": "AbiUInt"
23 },
24 {
25 "contents": [
26 256,
27 "68093943901352437066264791224433559271778087297543421781073458233697135179558"
28 ],
29 "tag": "AbiUInt"
30 },
31 {
32 "tag": "AbiUInt",
33 "contents": [256, "332"]
34 }
35 ]
36 ]
37 },
38 "_gasprice'": "0xa904461f1"
39 }
40]
Показать все

Очевидно, что эти входные данные не вызовут сбоя в нашем свойстве. Однако на следующем шаге мы увидим, как изменить его для этого.

Заполнение корпуса

Echidna нужна помощь, чтобы справиться с функцией magic. Мы скопируем и изменим входные данные, чтобы использовать для них подходящие параметры:

cp corpus/2712688662897926208.txt corpus/new.txt

Мы изменим new.txt, чтобы вызывать magic(42,129,333,0). Теперь мы можем снова запустить Echidna:

echidna-test magic.sol --config config.yaml
...
echidna_magic_values: failed!💥
Call sequence:
magic(42,129,333,0)
Unique instructions: 142
Unique codehashes: 1
Seed: -7293830866560616537
Показать все

На этот раз он сразу обнаружил, что свойство нарушено.

Поиск транзакций с высоким потреблением газа

Мы рассмотрим, как найти транзакции с высоким потреблением газа с помощью Echidna. Целью является следующий смарт-контракт:

1contract C {
2 uint state;
3
4 function expensive(uint8 times) internal {
5 for(uint8 i=0; i < times; i++)
6 state = state + i;
7 }
8
9 function f(uint x, uint y, uint8 times) public {
10 if (x == 42 && y == 123)
11 expensive(times);
12 else
13 state = 0;
14 }
15
16 function echidna_test() public returns (bool) {
17 return true;
18 }
19
20}
Показать все

Здесь expensive может иметь большое потребление газа.

В настоящее время Echidna всегда требует свойство для тестирования: здесь echidna_test всегда возвращает true. Мы можем запустить Echidna, чтобы проверить это:

1echidna-test gas.sol
2...
3echidna_test: passed! 🎉
4
5Seed: 2320549945714142710

Измерение потребления газа

Чтобы включить измерение потребления газа с помощью Echidna, создайте файл конфигурации config.yaml:

1estimateGas: true

В этом примере мы также уменьшим размер последовательности транзакций, чтобы результаты было легче понять:

1seqLen: 2
2estimateGas: true

Запуск Echidna

После создания файла конфигурации мы можем запустить Echidna следующим образом:

echidna-test gas.sol --config config.yaml
...
echidna_test: passed! 🎉
f использовала максимум 1333608 газа
Call sequence:
f(42,123,249) Gas price: 0x10d5733f0a Time delay: 0x495e5 Block delay: 0x88b2
Unique instructions: 157
Unique codehashes: 1
Seed: -325611019680165325
Показать все

Отфильтровывание вызовов, снижающих потребление газа

Приведенное выше руководство по фильтрации функций для вызова во время фаззинга показывает, как удалить некоторые функции из вашего тестирования.
Это может быть критически важно для получения точной оценки газа. Рассмотрим следующий пример:

1contract C {
2 address [] addrs;
3 function push(address a) public {
4 addrs.push(a);
5 }
6 function pop() public {
7 addrs.pop();
8 }
9 function clear() public{
10 addrs.length = 0;
11 }
12 function check() public{
13 for(uint256 i = 0; i < addrs.length; i++)
14 for(uint256 j = i+1; j < addrs.length; j++)
15 if (addrs[i] == addrs[j])
16 addrs[j] = address(0x0);
17 }
18 function echidna_test() public returns (bool) {
19 return true;
20 }
21}
Показать все

Если Echidna может вызывать все функции, она не сможет легко найти транзакции с высокой стоимостью газа:

1echidna-test pushpop.sol --config config.yaml
2...
3pop использовала максимум 10746 газа
4...
5check использовала максимум 23730 газа
6...
7clear использовала максимум 35916 газа
8...
9push использовала максимум 40839 газа
Показать все

Это потому, что стоимость зависит от размера addrs, а случайные вызовы, как правило, оставляют массив почти пустым. Однако добавление pop и clear в черный список дает нам гораздо лучшие результаты:

1filterBlacklist: true
2filterFunctions: ["pop", "clear"]
1echidna-test pushpop.sol --config config.yaml
2...
3push использовала максимум 40839 газа
4...
5check использовала максимум 1484472 газа

Итог: поиск транзакций с высоким потреблением газа

Echidna может находить транзакции с высоким потреблением газа, используя опцию конфигурации estimateGas:

1estimateGas: true
echidna-test contract.sol --config config.yaml
...

Echidna сообщит о последовательности с максимальным потреблением газа для каждой функции после завершения кампании фаззинга.

Последнее обновление страницы: 21 октября 2025 г.

Было ли это руководство полезным?