Перейти к основному содержимому
Блокчейн55ГЛАВА 55Инструменты аудита: что находит машинаcompilersolhintslithertestsзамысел — только здесь

Инструменты аудита: что находит машина

Прочитано0%
Квизы0/4
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

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

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

Эта глава — про четыре сита, про то, что каждое ловит, и про то, чего не поймает ни одно.

Четыре сита​

ЧЕТЫРЕ СИТА, И КАЖДОЕ ЛОВИТ СВОЁкомпиляторбесплатно, всегданепроверенный результат вызова2 предупреждениялинтерсекундыстиль и простые огрехи83 предупрежденияанализатордесятки секундизвестные схемы уязвимостей19 находоктестыпишете самиваш замысел и ваши правила8 проверокпервые три знают, как выглядят типовые ошибки; последнее знает, чего вы хотелипорядок применения: слева направо, по возрастанию затрат и убыванию универсальностини одно из сит не заменяет чтение кода глазами
Компилятор, линтер, анализатор, тесты — по возрастанию затрат и убыванию универсальности

Компилятор. Уже стоит, уже запускается, ничего настраивать не надо. Из четырёх уязвимостей прошлой главы он сам нашёл одну.

Линтер. Разбирает стиль и простые огрехи. Секунды работы.

Статический анализатор. Читает код, строит граф вызовов и ищет известные схемы уязвимостей. Десятки секунд.

Тесты. Единственное, что знает ваш замысел. Остальные три знают только, как выглядят типовые ошибки.

Первое сито — компилятор​

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)
СИГНАЛ И ШУМ: 83 ПРЕДУПРЕЖДЕНИЯ ЛИНТЕРА1 важное82 про отсутствующие комментарии, имена и пустые блокиno-unchecked-calls — результат низкоуровневого вызова не проверенчто с этим делаютнастраивают правила под проект — иначе список перестают читать вовсеинструмент, который выдаёт 83 предупреждения на четыре файла, быстро становится фономнастроить его — часть работы, а не признак лени
Восемьдесят три предупреждения на четыре файла; важное — одно

Восемьдесят два из восьмидесяти трёх — про отсутствующие комментарии, имена переменных и пустые блоки. Одно — настоящее: непроверенный результат вызова.

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

Лечение — настройка:

{
"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() — в списке находок отсутствует
ЧТО АНАЛИЗАТОР НАШЁЛ И ЧЕГО НЕ УВИДЕЛBadBank.withdraw()reentrancy-ethнашёл: запись после внешнего вызоваGoodBank.withdraw()— ничего —не нашёл: порядок правильныйTxOriginVaulttx-originнашёл: проверка по инициатору цепочкиNoGuard.setRate()— ничего —НЕ нашёл: он не знает, что менять ставку должен владелецанализатор ищет схемы, а не замысел: пропущенная проверка прав для него — обычный код
Нашёл в уязвимом, промолчал на исправленном — и не заметил забытой проверки прав
Отвечено вопросов: 0 из 2
Линтер выдал 83 предупреждения, из них важно одно. Что с этим делать?
Чем отличается предупреждение компилятора от ошибки?
Отвечено верно: 0 из 2

Чего анализатор не видит​

На файле с остальными уязвимостями он нашёл проверку по инициатору цепочки и непроверенный вызов:

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 с проверкой прав — значит, никто не проверял, что она срабатывает.

Порядок работы: снять покрытие, взять список непокрытых строк, для каждой ответить «почему её не проверяют». Половина ответов будет «забыли».

В каком порядке применять​

  1. Компилятор без предупреждений. Пять минут, начинать с этого.
  2. Линтер, настроенный под проект. Один раз настроить, дальше бесплатно.
  3. Анализатор, все находки разобраны. Не «исправлены» — разобраны: у части будет ответ «так задумано», и его надо уметь произнести.
  4. Тесты на нападение для каждой опасной функции.
  5. Утверждения со случайными данными — три-четыре на контракт.
  6. Покрытие — и объяснение для каждой непокрытой строки.
  7. Чтение глазами. По списку из прошлой главы. Это единственное, что ловит замысел.
Отвечено вопросов: 0 из 2
Анализатор не нашёл забытую проверку прав. Это недоработка инструмента?
Чем тест-нападение лучше записи в отчёте «уязвимость исправлена»?
Отвечено верно: 0 из 2

Слова главы​

  • Четыре сита — компилятор, линтер, анализатор, тесты; каждое ловит своё.
  • Предупреждение компилятора — не ошибка, но относиться к нему стоит как к ошибке.
  • Линтер — стиль и простые огрехи; без настройки тонет в шуме.
  • Статический анализатор — читает код и ищет известные схемы; объясняет цепочку.
  • Схема против замысла — инструмент знает первое, вы второе.
  • Тест-нападение — воспроизводит атаку и доказывает, что защита работает.
  • Утверждение, верное всегда — правило системы, проверяемое случайными данными.
  • Непокрытая строка — то, что не выполнялось ни разу; для каждой нужен ответ «почему».
Отвечено вопросов: 0 из 2
Что означает непокрытая строка в отчёте покрытия при аудите?
С чего начинают проверку кода перед сдачей?
Отвечено верно: 0 из 2

Тренажёр: набрать вслепую​

Цель: скорость ≥ 95 зн/мин, точность ≥ 88%

npx solhint "contracts/**/*.sol"

На чемпионате​

Чистый вывод компилятора — минимум. Предупреждения в сборке выглядят как небрежность.

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

Тест-нападение — сильнейший аргумент на защите. «Вот контракт, который пытается опустошить наше хранилище; вот тест, где он не смог». Это убедительнее любых слов про безопасность.

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

И честно про инструменты: они не сделают ваш контракт безопасным. Они находят то, что уже находили у других. Всё остальное — чтение кода с вопросом «что будет, если сюда придёт злой человек».

Куда углубиться​

  • Символьное выполнение — инструмент, который перебирает пути выполнения и ищет условия отказа.
  • Утверждения с последовательностями действий — случайные не аргументы, а целые сценарии.
  • Формальная проверка — доказательство свойств вместо испытаний.
  • Разница между анализом исходников и байт-кода — что делать с чужим контрактом без исходников.
  • Отчёты соревнований по поиску уязвимостей — где разобраны находки на настоящих проектах.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

  • Slither — статический анализатор и описание всех его проверок.
  • Solhint — линтер и список правил.
  • Foundry Book: инварианты — утверждения на последовательностях действий.
  • Building Secure Contracts — руководство от авторов анализатора.
  • Code4rena: отчёты — находки на настоящих проектах, с объяснениями.

Видео по теме

ВидеоFind Smart Contract Vulnerabilities with SlitherSmart Contract Programmer
ВидеоSlither: How it works and how to use ittrailofbits
ВидеоSmart contract auditor: from zero to hero 👾 Сергей ПрилуцкийCyber Academy
Видео🦊 Проводит аудит Web3 контракта на soliditykirjs
ВидеоStatic Analysis Tools: Slither & Mythril Overview — Forge CollegeForge College

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.