Инструменты аудита: что находит машина
- Зачем это нужно
- Четыре сита
- Первое сито — компилятор
- Второе сито — линтер
- Третье сито — статический анализатор
- Чего анализатор не видит
- Четвёртое сито — тест, воспроизводящий нападение
- Утверждения, верные всегда
- Покрытие как карта непроверенного
- В каком порядке применять
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
В прошлой главе четыре уязвимости были разобраны руками. В настоящем проекте это делают не только руками: есть инструменты, которые за десятки секунд проходят весь код и показывают известные схемы ошибок.
Но с ними надо уметь обращаться. Один выдаёт восемьдесят три предупреждения, из которых важно одно. Другой находит повторный вход, но проходит мимо забытой проверки прав — и это не недоработка, а принципиальное ограничение.
Эта глава — про четыре сита, про то, что каждое ловит, и про то, чего не поймает ни одно.
Четыре сита
Компилятор. Уже стоит, уже запускается, ничего настраивать не надо. Из четырёх уязвимостей прошлой главы он сам нашёл одну.
Линтер. Разбирает стиль и простые огрехи. Секунды работы.
Статический анализатор. Читает код, строит граф вызовов и ищет известные схемы уязвимостей. Десятки секунд.
Тесты. Единственное, что знает ваш замысел. Остальные три знают только, как выглядят типовые ошибки.
Первое сито — компилятор
Warning: Return value of low-level calls not used.
--> ./contracts/audit/Misc.sol:39:9:
|
39 | to.call{value: amount}("");
Предупреждение, а не ошибка: сборка проходит. Именно поэтому их и не читают — и зря.
Практическое правило: относитесь к предупреждениям компилятора как к ошибкам. Их мало, они точные, и почти каждое означает либо настоящую проблему, либо что вы написали не то, что хотели.
Второе сито — линтер
npm i -D solhint
npx solhint 'contracts/**/*.sol'
39:9 warning Return value of low-level call is not checked no-unchecked-calls
44:9 warning GC: Use Custom Errors instead of require gas-custom-errors
51:1 warning Code contains empty blocks no-empty-blocks
…
✖ 83 problems (0 errors, 83 warnings)
Восемьдесят два из восьмидесяти трёх — про отсутствующие комментарии, имена переменных и пустые блоки. Одно — настоящее: непроверенный результат вызова.
Это типичная беда линтеров и главная причина, по которой их перестают читать. Инструмент, выдающий восемьдесят три предупреждения на четыре файла, превращается в фон, и настоящая находка тонет.
Лечение — настройка:
{
"extends": "solhint:recommended",
"rules": {
"compiler-version": ["error", "^0.8.28"],
"use-natspec": "off"
}
}
Выключить то, что вам не нужно, и повысить до ошибки то, что важно. Настроить инструмент — часть работы, а не признак лени.
Третье сито — статический анализатор
slither contracts/audit/Reentrancy.sol
Detector: reentrancy-eth
Reentrancy in BadBank.withdraw() (contracts/audit/Reentrancy.sol#10-16):
External calls:
- (ok,None) = msg.sender.call{value: amount}() (#13)
State variables written after the call(s):
- balances[msg.sender] = 0 (#15)
INFO:Slither: analyzed (4 contracts with 102 detectors), 6 result(s) found
Он не просто нашёл повторный вход — он объяснил его тем же языком, что и мы: внешний вызов, а после него запись в состояние.
И главное — на исправленной версии он молчит:
GoodBank.withdraw() — в списке находок отсутствует
Чего анализатор не видит
На файле с остальными уязвимостями он нашёл проверку по инициатору цепочки и непроверенный вызов:
Detector: tx-origin
TxOriginVault.withdrawAll(address) uses tx.origin for authorization
Detector: unchecked-lowlevel
Unchecked.pay(address,uint256) ignores return value by to.call{value: amount}()
А вот это в списке отсутствует:
function setRate(uint256 v) external { rate = v; } // проверки прав нет
И это не пробел в инструменте, а его природа. Анализатор видит: есть функция, она меняет поле, ограничений нет. Откуда ему знать, что ставку должен менять только владелец? Для него это обычная открытая функция — таких в любом контракте полно.
Инструмент ищет схемы. Замысел знаете только вы.
Отсюда простое следствие: пропущенная проверка прав — самая частая настоящая уязвимость именно потому, что её не ловит ни один автоматический инструмент.
Четвёртое сито — тест, воспроизводящий нападение
Самое убедительное доказательство того, что защита работает, — нападение в тестах.
function test_BadBankLosesOthersMoney() public {
BadBank bank = new BadBank();
vm.prank(alice); bank.deposit{value: 5 ether}();
vm.prank(bob); bank.deposit{value: 5 ether}();
vm.prank(mallory);
Attacker atk = new Attacker(address(bank));
vm.prank(mallory);
atk.attack{value: 1 ether}();
require(address(atk).balance > 1 ether, unicode"нападение должно было пройти");
require(address(bank).balance < 10 ether, unicode"банк должен был потерять деньги");
}
function test_GoodBankHolds() public {
…
vm.expectRevert();
atk.attack{value: 1 ether}();
require(address(bank).balance == 10 ether, unicode"банк не потерял ничего");
}
contracts/audit/Reentrancy.t.sol:ReentrancyTest
✔ test_GoodBankHolds()
✔ test_BadBankLosesOthersMoney()
✔ testFuzz_GoodBankNeverPaysMoreThanDeposited(uint96,uint96) (runs: 256)
8 passing
Две пары строк, которые стоят целого отчёта: уязвимая версия действительно теряет деньги, исправленная действительно не теряет. Это проверяется на каждом запуске тестов и не устаревает.
Утверждения, верные всегда
function testFuzz_GoodBankNeverPaysMoreThanDeposited(uint96 a, uint96 b) public {
vm.assume(a > 0 && b > 0);
…
require(alice.balance - before == a, unicode"вернули не ровно свой вклад");
require(address(bank).balance == b, unicode"чужой вклад пострадал");
}
Двести пятьдесят шесть прогонов со случайными суммами, и каждый проверяет два утверждения: человек получает ровно свой вклад и чужой вклад не трогается.
Такие утверждения ценнее отдельных проверок, потому что описывают правило системы, а не конкретный случай. Для хранилища список обычно такой:
- сумма всех записей равна балансу контракта;
- никто не может забрать больше, чем внёс;
- баланс не уходит в минус;
- после любого набора действий владелец остаётся владельцем.
Полезное упражнение перед сдачей: напишите три утверждения о своём контракте, которые должны быть верны всегда, и прогоните их случайными данными. Если хоть одно не формулируется — значит, вы не до конца понимаете, что контракт делает.
Покрытие как карта непроверенного
Из главы про тесты уже знаком флаг покрытия. В аудите он читается иначе: список непокрытых строк — это список того, что никто никогда не выполнял.
Строка, которая ни разу не исполнилась, не проверена ничем: ни тестами, ни тем более руками. Если это ветка if с проверкой прав — значит, никто не проверял, что она срабатывает.
Порядок работы: снять покрытие, взять список непокрытых строк, для каждой ответить «почему её не проверяют». Половина ответов будет «забыли».
В каком порядке применять
- Компилятор без предупреждений. Пять минут, начинать с этого.
- Линтер, настроенный под проект. Один раз настроить, дальше бесплатно.
- Анализатор, все находки разобраны. Не «исправлены» — разобраны: у части будет ответ «так задумано», и его надо уметь произнести.
- Тесты на нападение для каждой опасной функции.
- Утверждения со случайными данными — три-четыре на контракт.
- Покрытие — и объяснение для каждой непокрытой строки.
- Чтение глазами. По списку из прошлой главы. Это единственное, что ловит замысел.
Слова главы
- Четыре сита — компилятор, линтер, анализатор, тесты; каждое ловит своё.
- Предупреждение компилятора — не ошибка, но относиться к нему стоит как к ошибке.
- Линтер — стиль и простые огрехи; без настройки тонет в шуме.
- Статический анализатор — читает код и ищет известные схемы; объясняет цепочку.
- Схема против замысла — инструмент знает первое, вы второе.
- Тест-нападение — воспроизводит атаку и доказывает, что защита работает.
- Утверждение, верное всегда — правило системы, проверяемое случайными данными.
- Непокрытая строка — то, что не выполнялось ни разу; для каждой нужен ответ «почему».
Тренажёр: набрать вслепую
Цель: скорость ≥ 95 зн/мин, точность ≥ 88%
npx solhint "contracts/**/*.sol"
На чемпионате
Чистый вывод компилятора — минимум. Предупреждения в сборке выглядят как небрежность.
Запустите анализатор и разберите находки. Не обязательно все исправлять: часть будет «так задумано». Но ответ должен быть готов — это ровно тот вопрос, который зададут.
Тест-нападение — сильнейший аргумент на защите. «Вот контракт, который пытается опустошить наше хранилище; вот тест, где он не смог». Это убедительнее любых слов про безопасность.
Три утверждения о контракте. Сумма записей равна балансу, никто не забирает больше внесённого, владелец не теряется. Со случайными данными это занимает десять строк.
И честно про инструменты: они не сделают ваш контракт безопасным. Они находят то, что уже находили у других. Всё остальное — чтение кода с вопросом «что будет, если сюда придёт злой человек».
Куда углубиться
- Символьное выполнение — инструмент, который перебирает пути выполнения и ищет условия отказа.
- Утверждения с последовательностями действий — случайные не аргументы, а целые сценарии.
- Формальная проверка — доказательство свойств вместо испытаний.
- Разница между анализом исходников и байт-кода — что делать с чужим контрактом без исходников.
- Отчёты соревнований по поиску уязвимостей — где разобраны находки на настоящих проектах.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Slither — статический анализатор и описание всех его проверок.
- Solhint — линтер и список правил.
- Foundry Book: инварианты — утверждения на последовательностях действий.
- Building Secure Contracts — руководство от авторов анализатора.
- Code4rena: отчёты — находки на настоящих проектах, с объяснениями.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.