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

Формальная верификация смарт-контрактов Solidity: SMTChecker

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров136

В этой статье мы:

  • сделаем поверхностный обзор подходов, обеспечивающих безопасность смарт‑контрактов;

  • разберем устройство и принцип работы инструмента формальной верификации SMTChecker — он интегрирован в компилятор solc;

  • практика: верифицируем несколько учебных контрактов на Solidity с помощью SMTChecker.

Обзор подходов к безопасности и надежности смарт-контрактов

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

  • инструменты статического анализа, линтеры (Slither, Solhint)

  • верификация (сверка) исходного кода и байткода, развернутого на блокчейне (Sourcify, EtherScan)

  • фреймворки для Unit-тестирования, динамического анализа (Foundry, HardHat)

  • инструменты для Fuzzing-тестирования (Echidna)

  • инструменты для оптимизации расхода газа и DeFi — тестирования (Chainlink VRF)

  • аудит, краудсорсинговое обнаружение уязвимостей (OpenZeppelin, Immunefi)

  • мониторинг контракта после развертывания (OpenZeppelin Defender, EtherScan, The Graph)

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

Формальная верификация на практике — это:

  1. написание спецификаций (или описание модели поведения/модели безопасности программы);

  2. математическое доказательство того, что код соответствует спецификации.

Верификация смарт-контракта начинается с выбора инструмента — готовых существует несколько:

  • Certora Prover

  • Фреймворк ConCert

  • KEVM (K Framework)

  • Solidity SMTChecker

Устройство и принцип работы SMT-Checker

В этой статье мы рассмотрим инструмент, интегрированный в компилятор solc: Solidity SMTChecker.

Для начала необходимо установить компилятор, я использовала PPAs для Ubuntu:

sudo add-apt-repository ppa:ethereum/ethereum
sudo apt-get update
sudo apt-get install solc

Если говорить простыми словами, SMTChecker строит модель кода смарт-контракта в виде формул, которые проверяются на SMT-солвере и Horn-солвере. Формулы отправляются солверу, солвер возвращает ответ. Если солвер нашел ошибку — он может вернуть контр-пример, демонстрирующий, на каких именно значениях входных параметров функция ведет себя не так как ожидается.

SMTChecker включает в себя две модели автоматической проверки спецификаций — это Bounded Model Checker (BMC) и Constrained Horn Clauses (CHC).

CHC (в основе лежит логика Хорна) — более мощная, и, по умолчанию запускается первой. Модель строит CFG (Граф управления потоком) смарт-контркта в виде системы хорновских дизъюнктов. Анализируется поведение контракта в целом, количество транзакций, каждая функция. Эта модель умеет анализировать циклы (BMC пока не умеет).

Две модели SMTChecker, использующие солвер Z3
Две модели SMTChecker, использующие солвер Z3

Если CHC не удалось доказать какое-то утверждение — это утверждение передается в BMC. Каждое сообщение об ошибке сопровождается пометкой, от какой модели это сообщение пришло. По умолчанию SMTChecker выключен. Включить его можно в консоли, указав, какую модель использовать:

CLI option --model-checker-engine {all,bmc,chc,none} 

Или в настройках JSON:

JSON option settings.modelChecker.engine={all,bmc,chc,none}.

Я рекомендую включать все модели {all}, т. к. принципы работы моделей отличаются, и ошибки, которые не смогла найти CHC может выявить BMC.

SMTChecker в Solidity работает со следующими солверами: Z3 (по-умолчанию), CVC4 / CVC5, Eldarica.

Пользователь может выбрать какой из солверов следует использовать, передав опцию CLI

 --model-checker-solvers {all,cvc5,eld,smtlib2,z3}

или определить это в файле JSON

option settings.modelChecker.solvers=[smtlib2,z3]

Все опции ModelChecker можно посмотреть через solc —help, вот они:

Опции SMTChecker
Опции SMTChecker

Исходный код чекера лежит на GitHub, написан он на языке C++, как и сам компилятор solc. Проект являетя open source, и, если очень хочется, можно поучаствовать в его разработке, взяв на себя одну из открытых задач.

Практика: формальная верификация смарт-контрактов

Приступим к верификации кода на Solidity, чтобы продемонстрировать возможности SMTChecker.

Исходный код примеров, разработанных для этой статьи можно посмотреть также на GitHub.

По умолчанию, если включить обе модели, SMTChecker проверит следующее:

  • Арифметическое переполнение и исчерпание разрядности (underflow и overflow)

  • Деление на ноль

  • Тривиальные условия и недостижимый код

  • Извлечение элемента из пустого массива

  • Выход за границы индекса

  • Недостаточность средств для перевода

Можно также в строке запроса указать, какие именно цели проверить отдельно, например

--model-checker-targets divByZero

Чтобы получить больше — нужно написать собственную спецификацию. В целом процесс верификации можно разделить на два больших шага:

  1. разработка модели безопасности, или спецификаций, описывающих ожидаемое поведение смарт-контракта. Этот шаг выполняет человек.

  2. доказательство, что код соответствует спецификации. Этот шаг помогает выполнить солвер

Спецификации состоят (как и во многих других подобных инструментах) из require и assert утверждений. Где require — это ограничения, которые солвер будет учитывать во время проверки, то есть они будут частью формулы, отправляемой солверу. А assert — само утверждение.

Встроенная в Solidity функция require помогает исправить ошибки, которые нашел SMTChecker — она накладывает требуемые ограничения, и уточнения.

Алгоритмы проверки, лежащие в основе используют разные подходы — символьное исполнение, проверка пограничных значений и другие.

Можно включить опцию «показать все доказанные свойства безопасности контракта»

--model-checker-show-proved-safe

и мы увидим целый список ошибок, отсутствие которых было доказано.

Давайте рассмотрим контракт, приведенный ниже - Arithmetic.sol. Это код контракта до верификации, в нем есть ошибки, если точнее, три ошибки разных видов.

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0;

contract Overflow {
    uint immutable x;
    uint immutable y;

    modifier req() {
        require(x >= 0);
        require(y >= 0);
        _;
    }

    function add(uint x_, uint y_) internal view req returns (uint) {
        return x_ + y_;
    }

    function mul(uint x_, uint y_) internal view req returns (uint) {
        return x_ * y_;
        return x_+ y_;
    }

    constructor(uint x_, uint y_) {
        (x, y) = (x_, y_);
    }

    function stateAdd() public view {
        uint res_add = add(x, y);
        assert(x <= res_add && y <= res_add);
    }

    function stateMul() public view {
        uint res_mul = mul(x, y);
        assert(x <= res_mul && y <= res_mul);
    }
}

А теперь приведем этот же контракт, но после верификации. В коде ниже я устранила все ошибки, допущенные в контракте. Это было сделано с помощью SMTChecker:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0;

// Since Solidity 0.8.0, arithmetic operations automatically revert on overflow/underflow
contract Overflow {
    uint immutable x;
    uint immutable y;

    modifier req() {
        require(x >= 0);
        require(y >= 0);
        require(x < type(uint128).max); // comment to detect overflow error
        require(y < type(uint128).max); // comment to detect overflow error
        _;
    }

    function add(uint x_, uint y_) internal view req returns (uint) {
        return x_ + y_;
    }

    function mul(uint x_, uint y_) internal view req returns (uint) {
        return x_ * y_;
        // return x_+ y_; // uncomment to detect unrecheable code error
    }

    constructor(uint x_, uint y_) {
        (x, y) = (x_, y_);
    }

    function stateAdd() public view {
        uint res_add = add(x, y);
        assert(x <= res_add && y <= res_add);
    }

    function stateMul() public view {
        require (x != 0); // comment to detect assertion violation
        require (y != 0); // comment to detect assertion violation

        uint res_mul = mul(x, y);
        assert(x <= res_mul && y <= res_mul);
    }
}

Места, где ошибки были обнаружены снабжены комментариями.

Всего с помощью SMTChecker было выявлено 3 вида ошибок:

  • unrecheable code (но это легко находит и простой IDE, не говоря уж о линтерах и проч.)

  • arithmetic overflow — проверяется SMTCheker по умолчанию

  • assertion violation — невыполнение спецификации, заданной assert

Ошибки, выявленные солвером во время проверки кода контракта
Ошибки, выявленные солвером во время проверки кода контракта

Для overflow ошибок, солвер привел контр-пример. Для ошибки Assertion violation солвер мне не привел контр-примера (а хотелось бы). Так тоже бывает. Это зависит от многих факторов: от солвера, установленного пользователем, от сложности задачи. Попробуйте запустить код на вашей машине — вернул ли вам солвер контр-пример для assertion violation?

Рассмотрим найденную солвером ошибку Assert violation (нарушение спецификации assert). Заметим, что для функции stateAdd assert выполняется, а для функции stateMul — уже нет. Почему? Если x или y будут равные нулю, то assert для stateMul уже не выполнится. Добавление дополнительных ограничений для функции stateMul решает проблему:

require (x != 0); // comment to detect assertion violation

require (y != 0); // comment to detect assertion violation

После того, как мы исправили все найденные солвером ошибки, SMTCheker возвращает такой ответ:

Info: CHC: 4 verification condition(s) proved safe! Enable the model checker option "show proved safe" to see all of them.

CHC — говорит нам о том, что проверка выполнялась на модели Constrained Horn Clauses

А если мы включим опцию —model-checker-show-proved-safe, то солвер покажет нам полный список доказанных свойств:

Ответ SMTChecker на проверку контракта с исправленными ошибками: отсутствие этих ошибок доказано.
Ответ SMTChecker на проверку контракта с исправленными ошибками: отсутствие этих ошибок доказано.

Ошибки были исправлены — можно раскомментировать или закомментировать строчки кода, чтобы посмотреть как солвер отмечает найденные ошибки.

Итак, утверждение (assertion) представляет собой инвариант в вашем коде: это свойство, которое должно быть истинным для всех транзакций, включая все входные значения и значения storage, в противном случае это означает наличие ошибки.

Функции stateMul() и stateAdd() задают спецификации, а SMTChecker подтверждает, что свойство корректно.

Рассмотрим еще один пример: контракт Max, из листинга Loops.sol (все примеры конрактов для этой статьи можно посмотреть на GitHub)

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0;

contract Max {
    function max(uint[] memory a) public pure returns (uint) {
        uint m = 0;
        for (uint i = 0; i < a.length; ++i)
            if (a[i] > m)
                m = a[i];

        for (uint i = 0; i < a.length; ++i)
            assert(m >= a[i]);

        return m;
    }
}

В этом примере assert (инвариант) встроен в тело цикла. Для этого примера солвер также проверит overflow для ++i, и выполнение assert на каждом шаге цикла. Для солвера циклы могут быть проблемой (так же как и для расхода газа), тут нужно помнить, что в общем случае SMT-проблемы являются NP-полными (как и SAT):

  • В худшем случае время работы экспоненциально (O(2^n)) от размера задачи.

  • На практике решатели, с которыми работает SMTChecker (Z3, cvc5, Eldarica) используют эвристики и оптимизации, поэтому часто справляются за разумное время.

В заключение приведем тезисно преимущества и недостатки инструментов формальной верификации, встроенных в компилятор Solidity - SMTChecker

Преимущества:

  • выразительность спецификаций, возможность описать модель поведения кода

  • возможность автоматизации и большой объем покрытия диапазонов анализируемых значений

  • демонстрация контр-примеров

  • автоматическая проверка целого ряда ошибок

Недостатки:

  • инструментарий находится в стадии разработки и набор разновидностей ошибок, которые можно верифицировать — ограничен

  • SMTChecker не может анализировать некоторые возможности Solidity, такие как keccak256, ecrecover, address.call, delegatecall,а также динамическое хранилище: массивы без ограничения длины, маппинги с произвольными ключами.

  • плохо работает с нелинейной арифметикой

  • при работе с Horn логикой инварианты (например, в циклах) должны описываться вручную

  • ВMC (Bounded Model Checker) проверяет конечное число путей выполнения (до определённой глубины циклов) на нарушения условий assert и require, и может не отловить баги, находящиеся за пределами проверки

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

  • Высокая вычислительная сложность:

    SMT-решатели (например, Z3) могут не уложиться в допустимое время проверки для больших контрактов или сложной логики.

  • не решает вопросы учета расхода газа

Для максимальной безопасности смарт-контракта необходимо реализовать комплексный подход, использовать разные инструменты и формальная верификация — один из них.

Теги:
Хабы:
0
Комментарии0

Публикации

Работа

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