Как стать автором
Обновить

Гайд по автоматическому аудиту смарт-контрактов. Часть 1: Подготовка к аудиту

Время на прочтение10 мин
Количество просмотров7.2K

Введение


Наша компания занимается аудитом безопасности смарт-контрактов, и вопрос использования автоматических средств стоит очень остро. Насколько сильно они могут помочь в определении подозрительных мест, какие стоит их использовать, что они умеют делать и какова специфика работы в этой области? Эти и сопутствующие вопросы — тема данной статьи. А материалом будут попытки поработать с реальными контрактами с помощью наиболее интересных представителей и рецепты по запуску этого крайне разношерстного и дико интересного софта. Сначала хотелось сделать одну статью, но после некоторого времени объем информации стал слишком большим, поэтому было решено сделать цикл статей, по одной на каждый автоанализатор. Список, из которого мы будем брать инструменты представлен, например, здесь, но, если за время написания попадутся другие интересные инструменты, я с удовольствием их опишу и протестирую.


Надо сказать, что задачи по аудиту оказались крайне интересными, т.к. до сих пор разработчики не так много внимания уделяли экономическим аспектам алгоритмов, и внутренней оптимизации. А аудит смарт-контрактов добавил несколько интересных векторов атак, которые надо учитывать при поиске ошибок. Также, как оказалось, появилось достаточно много средств для автоматического тестирования: статические анализаторы, анализаторы байт-кода, фаззеры, парсеры и много другого хорошего ПО.


Цель статьи: способствовать распространению безопасного кода контрактов и позволить разработчикам быстро и просто избавляться от глупых багов, которые часто являются наиболее обидными. Когда сам протокол является вполне надежным, и решает серьезную проблему, наличие глупой ошибки, забытой на этапе тестирования может серьезно испортить проекту жизнь. Поэтому давайте учиться пользоваться, как минимум, инструментами, которые позволяют “малой кровью” избавиться от well-known проблем.


Забегая вперед, должен сказать, что наиболее часто встречающиеся critical баги, которые мы встречали в аудитах — это всё таки логические проблемы имплементации, а не типовые уязвимости, такие как access rights, integer overflow, reentrancy. Большой, полный аудит решений невозможен без опытных разработчиков, которые способны проаудировать высокоуровневую логику контрактов, их lifecycle, аспекты реальной эксплуатации и соответствие заданию, а не только типовые паттерны атак. Именно высокоуровневая логика часто становится источником critical багов.


Зато предупреждения, типовые дыры и ошибки оставленные по невнимательности, которые нельзя пропускать — удел автоматических анализаторов, с этими задачами они должны справляться лучше людей. Именно этот тезис и будет подвергнут проверке.


Особенности аудита кода смарт-контрактов


Аудит кода смарт-контрактов — довольно специфичная область. Несмотря на небольшой размер, смарт-контракт в Ethereum это полноценная программа, способная организовывать сложные ветвления, циклы, деревья решений, и, даже для автоматизации казалось бы несложных сделок требуют продумывания всех возможных ветвлений на каждом шагу. С этой точки зрения блокчейн-разработка является крайне низкоуровневой, очень требовательной к ресурсам и крайне напоминает разработку системного и встроенного софта на C/С++ и ассемблерных языках. Именно поэтому мы так любим видеть на собеседованиях разработчиков низкоуровневых алгоритмов, сетевого стека, высоконагруженных сервисов, всех кто имел дело с низкоуровневой оптимизацией и аудитом кода.


С точки зрения разработчика Solidity также довольно специфичен, хотя легко читается практически любым программистом и на первых шагах и кажется крайне простым. Код на Solidity довольно просто читать, он привычен для любого разработчика, владеющего C/C++ синтаксисом и ООП, например JavaScript.


Здесь простота кода — залог выживания, ничто тяжелое не работает, так что в работе используется весь арсенал низкоуровневой разработки — алгоритмы, позволяющие эффективно использовать ресурсы, экономить память: деревья Меркла, Bloom filters, “lazy” подгрузка ресурсов, разворачивание циклов, ручная сборка мусора и еще много всего.
Небольшое количество исходного кода и результирующего байт-кода.


Отдельный смарт-контракт ограничен по объему байт-кода, каждый байт стоит некоторое количество газа, а максимум ограничен сверху, поэтому затолкать в блокчейн можно порядка 10Kb(на данный момент), больше не получится. Вот хорошая статья на тему, сколько стоит деплой контракта и что сколько газа стоит. Поэтому многое затолкать не удастся. Если утрировать, то несколько тысяч строк “среднего” кода — это максимум. Несколько десятков методов, отсутствие агрегации и вообще сложной логики крайне характерно для контрактов. Все, что не помещается, требует выделять код в отдельные библиотеки, менять и усложнять порядок выкладки в сеть. Solidity разработчики может и рады затолкать кучу кода в один контракт, но попросту вынуждены оформлять свои системы контрактов правильно, создавая отдельные библиотеки-классы с собственным storage. А такие отдельные “классы” удобно раскладывать в отдельные файлы, и, поэтому, читать код контрактов довольно приятно, все неплохо структурировано изначально — по другому попросту не получится. В качестве примера рекомендую посмотреть, как сделан ERC721 в openzeppelin-solidity.


Газ, газ, газ


Газ вносит в исполнение кода контрактов дополнительный слой логики, которая требует аудита. Более того, в отличие от традиционного кода, один и тот же участок кода может тратить различное количество газа. Полезной для понимания ограничений по газу является таблица опкодов EVM и их стоимости, вот она.


Для демонстрации того, почему приходится много времени посвящать оценке газа, рассмотрим такой кусок псевдокода (разумеется нереального, пулять в цикле эфиром — плохая идея):


// функция просто записывает код события для аккаунта в блокчейн
function fixSomeAccountAction(uint _actionId) public onlyValidator {
    // … 
events[msg.sender].push(_actionId);
}

// юзер дергает функцию, которая суммирует награды за каждый тип действия и выплачивает награду
function receivePaymentForSavedActions() {
    // ...
    for (uint256 i = 0; i < events[msg.sender].length; i++) {
        // берем actionId из массива 
        uint actionId = events[msg.sender][i];
        // вычсляем награду за данный вид action
        uint payment = getPriceByEventId(actionId);
        if (payment > 0) {
            paymentAccumulators[msg.sender] += payment;
        }
        emit LogEventPaymentForAction(msg.sender, actionId, payment);
        // …
        // delete “events[msg.sender][i]” from array 
    }
}

дело в том, что цикл в контракте выполняется events[msg.sender].length раз, и каждая итерация — это запись в блокчейн (transfer() и emit()). Если длина массива мала, то цикл отрабатывает свой десяток раз, раздавая оплату за каждое действие. Но, если массив events[msg.sender] будет большой, то итераций будет много и потраченный газ упрется в захардкоженный максимальный лимит газа (~ 8 000 000 ). Транзакция упадет, и теперь никогда не отработает, так как никакого способа уменьшить длину массива events[msg.sender] в контракте не предусмотрено. Если в цикле производится не просто вычисление некоторого единичного значения, а производится запись в блокчейн (например выплачиваются какие-нибудь комиссии, оплаты за действия) — то допустимое количество итераций довольно существенно ограничено. Судите сами — лимит: 8 000 000, запись нового 256-битного значения: 20 000. Т.е. можно сохранить или обновить метаданные лишь для пары сотен 256-битных адресов с некоторыми метаданными Еще веселья добавляет то, что запись нового значения: 20 000, а апдейт существующего: 5 000, так что даже при полностью одинаковом окружении вашего контракта, когда вы делаете трансфер токенов на адрес, на котором уже есть токены, вы тратите на запись в 4 раза меньше газа (5 000 vs 20 000).


Поэтому не удивляйтесь, что вопрос газа в смарт-контрактах так тесно связан с безопасностью контрактов, ведь ситуация, когда в контракте навсегда застряли средства с практической точки зрения мало отличается от ситуации, когда их украли. То, что инструкция ADD стоит 3 газа, а SSTORE(сохранение в storage): 20 000 говорит о том, что самый дорогой ресурс в блокчейне — storage, а задачи оптимизации кода контрактов во многом перекликаются с задачами низкоуровневой разработки на C и ASM для embedded systems, где storage — также сильно ограниченный ресурс.


Прекрасный блокчейн


Это очень позитивный абзац про то, почему именно блокчейн так хорош с точки зрения безопасности именно для аудитора. Детерминизм исполнения кода контракта — залог успешной отладки и воспроизведения багов и уязвимостей. Технически, любой вызов кода контракта может быть воспроизведен на любой платформе с точностью до бита, это позволяет тестам работать везде и быть крайне простыми в поддержке, а расследование инцидентов — надежным и неоспоримым. Теперь мы всегда знаем кто когда какую функцию вызвал, с какими параметрами, какой код ее обработал и каков был результат. Все это полностью детерминировано, т.е. воспроизводится где угодно, хоть в JS на web-странице. Если говорить об Ethereum, то любой тестовый кейз крайне просто пишется на удобном JavaScript, включая fuzzing параметров, и прекрасно работает где угодно, где есть Node.js.


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


Окружение для сборки контракта


Для написания статьи я взял старый экспериментальный контракт для бронирования жилья из конструктора Smartz: https://github.com/smartzplatform/constructor-eth-booking. Контракт позволяет создать запись об объекте (квартире или номере в отеле), установить цену и даты сдачи, после чего контракт ждет оплату и в случае ее получения, фиксирует акт бронирования, держа средства на балансе до тех пор, пока гость не въедет в номер и не подтвердит въезд. В этот момент, владелец номера получает оплату. Контракт по сути является конечным автоматом, состояния и переходы которого можно посмотреть в Booking.sol. Мы сделали его довольно быстро, меняли в процессе разработки и не успели сделать большого количества тестов, у него далеко не новая версия компилятора и более-менее богатая внутренняя логика. Так что посмотрим как с ним справятся анализаторы, какие ошибки найдут, и, если понадобится, то добавим своих.


Работа с разными версиями solc


Разные анализаторы придется использовать по разному — одни запускаются из докера, другие используют готовый скомпилированный байт-код, да и самому аудитору тоже приходится иметь дело не с парой, а десятками ранзых контрактов с разными версиями компилятора. Поэтому, разные версии solc надо уметь “подсовывать” по разному, и в host-системе, и внутри docker-образа, и внутри truffle, поэтому приведу эти несколько вариантов грязных хаков:


1 способ: внутри truffle


Для этого никаких хитростей не нужно, т.к. начиная с truffle версии 5.0.0, можно указать версию компилятора прямо в truffle.js, как в этом diff-е.


Теперь truffle сам скачает нужный компилятор и запустит его. За это огромное спасибо команде, Solidity язык молодой, изменения в языке бывают серьезные, а переезд с версии на версию для аудитора неприемлем — так можно внести новые ошибки и замаскировать старые.


2 способ: замена /usr/bin/solc в docker-контейнере анализатора
Если анализатор распространяется в виде Dockerfile, то можно заменить его при сборке docker образа, добавив в Dockerfile строку, которая достает solc нужной версии прямо из образа, который подтягивает из сети и заменяет /usr/bin/solc:


COPY --from=ethereum/solc:0.4.19 /usr/bin/solc /usr/bin

3 способ: замена /usr/bin/solc


Самый грязный способ в лоб, если совсем не осталось выхода, можно подло заменить бинарник /usr/bin/solc на скрипт типа такого(не забудьте сохранить оригинальный файл):


#!/bin/bash                                                                                                                                                              

# run Solidity compiler of given version, pass all parameters
# you can run “SOLC_DOCKER_VERSION=0.4.20 solc --version”
SOLC_DOCKER_VERSION="${SOLC_DOCKER_VERSION:-0.4.24}"
docker run \
    --entrypoint "" \
    --tmpfs /tmp \
    -v $(pwd):/project \
    -v $(pwd)/node_modules:/project/node_modules \
    -w /project \
    ethereum/solc:$SOLC_DOCKER_VERSION \
    /usr/bin/solc \
    "$@"

Он скачивает и кеширует docker-образ с нужной версией solc, переходит в текущий каталог и запускает /usr/bin/solc с переданными параметрами. Не очень хороший способ, но возможно для каких то задач он вам подойдет.


Flattening code


Теперь разберемся с исходниками. Конечно, в теории автоанализаторы (особенно для статического анализа исходников) должны собирать контракт, подтягивать все зависимости, соединять все в один монолит и анализировать его. Но, как я уже говорил, изменения от версии к версии могут быть серьезными, и я постоянно натыкался на необходимость прокинуть в докер дополнительный каталог, настроить у него внутри пути, и все это чтобы он корректно подтянул нужные импорты. Одни анализаторы все понимают, вторые нет, поэтому универсальным вариантом, чтобы не мучаться с прокидыванием дополнительных каталогов, для анализаторов, которые едят одиночный файл удобней слить всё в один файл и анализировать только его.


Для этого используйте штатный truffle-flattener.


Это стандартный npm модуль, используется очень просто:


truffle-flattener contracts/Booking.sol > contracts/flattened.sol

: https://github.com/trailofbits/slither
Если нужно как то кастомизировать flattening, можно написать свой flattener, например раньше мы использовали python-based вариант: https://github.com/mixbytes/solidity-flattener


Начнем анализ.


На примере все того-же старичка https://github.com/smartzplatform/constructor-eth-booking продолжим анализ. У контракта указана старая версия компилятора “0.4.20”, и я намеренно взял старый контракт, чтобы порешать проблем с компилятором. Хуже ситуацию делает то, что от этой версии solc может зависеть и автоанализатор, например изучающий байт-код, и здесь расхождения в версиях могут сильно повлиять на результаты или вообще все сломать. так что, если даже если вы все делаете кошерно, с использованием последних версий, то все равно можете налететь на анализатор, заточенный под прошлую версию компилятора.
Компиляция и запуск тестов


Для начала просто вытащим проект с гитхаба и попробуем скомпилировать.:


git clone https://github.com/smartzplatform/constructor-eth-booking.git
cd constructor-eth-booking
npm install
truffle compile

Наверняка, у вас проблемы с версией компилятора. А еще эти проблемы есть и у автоанализаторов, поэтому используйте любые средства, чтобы заполучить компилятор 0.4.20 и собрать проект. Я просто прописал нужную версию компилятора в truffle.js и все собралось, как указано выше.


Также запустите


truffle-flattener contracts/Booking.sol > contracts/flattened.sol

как было указано в пункте про flattening, именно contracts/flattened.sol мы будем отдавать на анализ разным анализаторам
Заключение к вводной части


Теперь, имея flattened.sol и возможность использовать solc произвольной версии можно приступать к анализу. Я опущу проблемы с запуском truffle и тестами, документации по данному вопросу много, разбирайтесь сами. Конечно, тесты должны запускаться и успешно отрабатывать. Также, чтобы проверить логику аудитору часто приходится добавлять собственные тесты, проверяющие потенциально дырявые места, например, проверять функционал контракта на границах массивов, покрывать тестами все переменные, даже предназначенные строго для хранения данных, и т.п. Рекомендаций здесь много, кроме того это как раз тот продукт, который наша компания поставляет на рынок, поэтому исследование логики — это чисто человеческая задача.


Мы же пойдем по интересным с нашей точки зрения анализаторам, попробуем подсунуть им наш контракт, и будем искусственно вносить в него уязвимости, чтобы оценить, как автоанализаторы среагируют на них. Следующая статья будет посвящена анализатору Slither, а вообще, план действий приблизительно такой:


Часть 1. Введение. Компиляция, flattening, версии Solidity (эта статья)
Часть 2. Slither
Часть 3. Mythril
Часть 4. Manticore
Часть 5. Echidna
Часть 6. Unknown tool 1
Часть 7. Unknown tool 2


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


Возможно, в процессе подробного исследования, появятся новые кандидаты на рассмотрение, или изменится порядок статей, так что следите за обновлениями. Для перехода на следующую часть, “click here”

Теги:
Хабы:
Всего голосов 12: ↑11 и ↓1+10
Комментарии0

Публикации

Истории

Работа

Ближайшие события