Фаззинг: поиск багов случайными входами
Разбираемся, как подача программе случайных и искажённых входов вскрывает краши и уязвимости, почему coverage-guided фаззеры на порядки эффективнее наивных, что и как стоит фаззить — на простой Python-иллюстрации.
Фаззинг (fuzzing) — автоматизированное тестирование, при котором программе скармливают массу сгенерированных, часто намеренно искажённых входов, наблюдая, не упадёт ли она, не зависнет ли или не поведёт себя аномально. Каждый такой сбой — потенциальная уязвимость.
Программисты тестируют на «нормальных» данных, которые ожидают увидеть. Но атакующий присылает то, чего разработчик не предусмотрел: строку в миллион символов вместо имени, отрицательную длину, обрезанный посередине файл, неожиданные байты. Именно на таких входах ломаются парсеры, декодеры и любой код, разбирающий внешние данные. Фаззинг автоматически генерирует тысячи подобных входов в секунду и ищет те, что приводят к сбою. Применять фаззинг можно только к своему коду или к коду с разрешения владельца — это лабораторная техника, а не средство атаки на чужие сервисы.
Зачем это знать разработчику
Фаззинг находит дефекты, которые почти невозможно отыскать ревью и обычными тестами: переполнения буфера, разыменование null, бесконечные циклы, необработанные исключения, утечки памяти. Многие громкие уязвимости в библиотеках разбора (изображений, шрифтов, архивов, сетевых протоколов) были найдены именно фаззерами. Для защитника фаззинг — это способ найти баг раньше, чем его найдёт атакующий, на собственном стенде.
Наивный фаззинг: перебор случайных входов
Простейший фаззер генерирует случайные данные и подаёт их в функцию, ловя исключения. Покажем принцип на учебной функции-парсере, которая ошибочно не проверяет формат и падает на «плохом» входе:
import random, string
def parse_record(s):
# Учебный парсер формата "имя:возраст". Намеренно хрупкий.
name, age = s.split(":") # упадёт, если нет ровно одного ":"
return name, int(age) # упадёт, если age не число
def random_input(n):
alphabet = string.ascii_letters + string.digits + ":"
return "".join(random.choice(alphabet) for _ in range(n))
random.seed(42)
crashes = 0
for i in range(2000):
data = random_input(random.randint(0, 8))
try:
parse_record(data)
except Exception:
crashes += 1 # сбой = кандидат в баг-репорт
print("Входов подано:", 2000)
print("Сбоев поймано:", crashes)
Вывод:
Входов подано: 2000 Сбоев поймано: 1998
Фаззер за тысячи попыток нашёл массу входов, валящих парсер: строки без двоеточия, с несколькими двоеточиями, с нечисловым «возрастом». Каждый такой сбой указывает разработчику, что функция не валидирует вход. Здесь сбои очевидны, но в реальном коде так же всплывают переполнения и зависания, о которых никто не подозревал.
Чего не хватает наивному подходу
Чистый рандом плохо проходит «узкие горлышки». Если код срабатывает только на входе, начинающемся с волшебной сигнатуры (скажем, PNG в начале файла), случайные байты почти никогда её не угадают — и весь интересный код за проверкой останется непокрытым. Нужен фаззер, который учится.
Coverage-guided fuzzing
Современные фаззеры (AFL++, libFuzzer, в Python — Atheris) работают по принципу обратной связи по покрытию (coverage-guided). Идея: инструментировать программу так, чтобы фаззер знал, какие ветки кода исполнил каждый вход. Если новый случайный вход добрался до ранее не достигнутой ветки, фаззер сохраняет его в «корпус» интересных образцов и дальше мутирует именно его — переставляет байты, меняет числа, дублирует куски. Так фаззер эволюционно прорывается всё глубже в код.
очередь входов
│ выбрать вход ──> мутировать ──> запустить под инструментацией
│ │
│ новое покрытие? ── да ─> добавить в очередь (ценный вход)
└────────────── нет ────────────────> отбросить
сбой? ── да ──> сохранить как краш-кейс
Именно благодаря такой петле coverage-guided фаззер за часы находит то, чего наивный рандом не нашёл бы и за годы: он сам «нащупывает» магические значения и контрольные суммы, потому что каждый шажок к новой ветке вознаграждается. Многие проекты подключены к непрерывному фаззингу (например, OSS-Fuzz), где это крутится постоянно.
Что и как фаззить
Главные кандидаты на фаззинг — код, разбирающий недоверенный вход: парсеры форматов файлов, декодеры, обработчики сетевых пакетов, десериализация, любой разбор пользовательских данных. Для эффективного фаззинга нужны:
- Чёткая точка входа — функция, принимающая массив байтов (в libFuzzer это «fuzz target»: одна функция от буфера).
- Стартовый корпус — несколько валидных примеров входа, от которых фаззер отталкивается мутациями.
- Санитайзеры — сборка с ASan/UBSan (для C/C++), чтобы фаззер ловил не только явные краши, но и порчу памяти и переполнения.
- Детектор зависаний — таймаут, помечающий вход, на котором программа «думает» бесконечно (потенциальный DoS).
Отдельно полезен дифференциальный фаззинг: один и тот же вход подают в две реализации (например, в свою и эталонную), и расхождение результатов — это баг. И structure-aware фаззинг, когда мутации делают не на сырых байтах, а на структуре (через грамматику), чтобы быстрее проходить форматы со строгим синтаксисом.
Как это работает под капотом
Coverage-guided фаззер компилирует цель с инструментацией: в каждую ветвь (переход) вставляется счётчик, и после прогона фаззер читает «карту покрытия» — какие переходы были задеты. Сравнивая карту с уже виденными, он решает, ценен ли вход. Поверх работает мутатор (битфлипы, арифметика над числами, вставка «интересных» констант, скрещивание входов из корпуса) и минимизатор, который ужимает найденный краш-вход до минимального воспроизводящего. Падение фиксируется по сигналу (SIGSEGV, SIGABRT) или срабатыванию санитайзера.
Как защититься
1. Фаззьте свой парсер-код регулярно — в идеале непрерывно в CI, а не разово.
2. Собирайте под санитайзерами. Без ASan/UBSan фаззер пропустит порчу памяти, которая не приводит к немедленному крашу.
3. Чините найденное и добавляйте краш-кейс в регрессионные тесты, чтобы баг не вернулся.
4. Валидируйте вход в самом коде. Фаззинг показывает дыры, но защита — это строгая проверка длины, формата и границ, а не надежда, что «такой вход не придёт».
Итоги
- Фаззинг подаёт программе массу искажённых входов и ищет краши/зависания/аномалии — кандидаты в уязвимости.
- Наивный случайный перебор быстро находит грубые баги, но не проходит «узкие горлышки» вроде сигнатур и контрольных сумм.
- Coverage-guided фаззинг через обратную связь по покрытию мутирует ценные входы и прорывается вглубь кода — это и есть рабочий стандарт.
- Фаззить нужно прежде всего код, разбирающий недоверенный вход; нужны точка входа, стартовый корпус, санитайзеры и таймаут.
- Фаззинг — лабораторная техника для своего кода; найденные краши чинят и закрепляют регрессионными тестами.