Три месяца непрерывного AFL++ в QEMU-режиме против закрытого сетевого парсера - 23 уникальных crash-а, четыре из которых оказались heap buffer overflow с контролируемым размером аллокации. Coverage застрял на ~2000 путях к концу первой недели, и только пересборка seed corpus-а с корректными образцами протокола вытащила фаззер на 3400+ путей к финалу кампании. На бумаге фаззинг бинарных приложений выглядит тривиально - запустил AFL++, подождал - но реально 80% усилий уходит не на ожидание crash-ей, а на работу с покрытием: seed-ы, словари, кастомные мутаторы, анализ uncovered branches. Дальше - конкретный workflow от выбора между AFL++ и libFuzzer до crash triage с классификацией по CWE, с акцентом на binary-only сценарии, которые русскоязычные материалы практически не покрывают.
AFL++ против libFuzzer: когда какой инструмент для фаззинга брать
Русскоязычные источники обычно описывают оба инструмента списком фич, но не дают decision tree для конкретных сценариев. А выбор определяется тремя параметрами: есть ли исходники, нужна ли изоляция отдельной функции, какая целевая архитектура.| Условие | AFL++ | libFuzzer |
|---|---|---|
| Исходники доступны, C/C++ | Да, afl-clang-fast | Да, -fsanitize=fuzzer |
| Исходников нет, только бинарь | Да, QEMU-mode / Frida-mode | Не применим без перетрансляции |
| In-process фаззинг одной функции | Persistent mode + deferred init | Нативный сценарий |
| Параллельный запуск | -M/-S master-slave или -fork=N | -jobs=N -workers=N или -fork=N |
| Кастомные мутаторы | afl_custom_mutator API | LLVMFuzzerCustomMutator |
| Скорость (binary-only) | 1000-5000 exec/s (QEMU), 5000-30000 (persistent QEMU) | Не применим |
| Статус проекта (2025) | Активно поддерживается, регулярные коммиты (AFLplusplus/AFLplusplus) | Часть LLVM; авторы перешли на Centipede, только bugfix-ы |
По документации LLVM, libFuzzer - in-process coverage-guided evolutionary fuzzing engine, который линкуется с целевой библиотекой и подаёт мутированные данные через точку входа
LLVMFuzzerTestOneInput. Информацию о покрытии предоставляет SanitizerCoverage. Ключевое ограничение: target вызывается многократно в одном процессе, поэтому не должен вызывать exit(), обязан быть детерминированным и быстрым.AFL++ работает иначе: каждый тестовый вход по умолчанию обрабатывается в fork-е, feedback использует shared bitmap для отслеживания edge transitions между базовыми блоками. В persistent mode AFL++ зацикливает target-функцию внутри одного процесса, приближаясь к модели libFuzzer.
Практическое правило. Бинарь без исходников - AFL++ в QEMU-режиме, альтернатив по сути нет. Исходники есть и нужно фаззить конкретный парсер изолированно - libFuzzer с harness-обёрткой будет быстрее. При наличии исходников оба работают: libFuzzer выигрывает по скорости (нет fork-ов), AFL++ - по гибкости мутаций и встроенному CMPLOG для преодоления сложных сравнений.
Из альтернатив стоит упомянуть Honggfuzz (используется в ClusterFuzz, поддерживает binary-only фаззинг через QBDI) и Sydr-fuzz от ИСП РАН - гибридный фаззер, который комбинирует символьное выполнение (Sydr) с AFL++ и libFuzzer. По данным проекта, Sydr-fuzz нашёл 172 ошибки в 31 open-source проекте. Гибридный подход особенно хорош для преодоления глубоких условных переходов, которые мутационный фаззинг не пробивает за разумное время.
AFL++ в QEMU-режиме: фаззинг бинарных файлов без исходников
Главный gap русскоязычных материалов по фаззингу - binary-only сценарий. Статья cloud.ru даёт общий обзор, кейс Kaspersky ICS CERT рассматривает конкретный протокол (OPC UA) с доступом к исходникам, Sydr-fuzz предполагает source-based инструментирование. Но на пентесте или в vulnerability research исходники - роскошь. Прошивки IoT, проприетарные парсеры, закрытые сетевые демоны - всё это binary-only цели, и именно здесь AFL++ в QEMU-режиме незаменим.Требования к окружению
- ОС: Linux (Ubuntu 22.04/24.04 рекомендуется); macOS и Windows не поддерживают QEMU-mode
- RAM: минимум 4 ГБ, рекомендуется 8+ ГБ для параллельного фаззинга (master + 3 slave)
- CPU: 4 ядра - минимум, больше - лучше; каждый slave занимает ядро
- Зависимости:
build-essential,python3,cmake,ninja-build,libtool-bin,automake,bison,libglib2.0-dev - AFL++: сборка из исходников с поддержкой QEMU:
git clone https://github.com/AFLplusplus/AFLplusplus && cd AFLplusplus && make distrib && sudo make install - Режим работы: полностью offline после установки
Persistent mode: от 2000 к 15000 exec/s
Без persistent mode каждая итерация - fork + трансляция всего бинаря. Persistent mode зацикливает выполнение target-функции внутри одного процесса, убирая overhead на fork:
Bash:
AFL_QEMU_PERSISTENT_ADDR=0x4005a0 \
AFL_QEMU_PERSISTENT_CNT=10000 \
AFL_QEMU_PERSISTENT_GPR=1 \
afl-fuzz -Q -i corpus_dir -o output_dir -- ./target_binary @@
AFL_QEMU_PERSISTENT_ADDR - адрес начала целевой функции (определяется реверсом через Ghidra или IDA). AFL_QEMU_PERSISTENT_CNT - количество итераций до рестарта процесса; 10000 - баланс между скоростью и стабильностью. AFL_QEMU_PERSISTENT_GPR=1 - восстановление регистров между итерациями.Прирост скорости - от 3x до 10x. Если без persistent mode вы получаете 2000 exec/s, с ним реально выйти на 8000-15000. Для сравнения: rev.ng, который использует статическую бинарную трансляцию через LLVM IR (не динамическую трансляцию QEMU), заявляет ускорение 20-40x относительно AFL QEMU-mode при фаззинге программы
less.Типичная ловушка. Если функция по адресу
AFL_QEMU_PERSISTENT_ADDR модифицирует глобальное состояние (открывает файлы, аллоцирует без освобождения), после N итераций процесс деградирует. Симптом: стабильность фаззера (stability в UI AFL++) падает ниже 90%. Решение - снизить AFL_QEMU_PERSISTENT_CNT до 1000 или реализовать AFL_QEMU_PERSISTENT_HOOK для cleanup-а между итерациями. Держится выше 95% - всё штатно, не трогайте.Применимость. QEMU-mode работает для бинарей x86, x86-64, ARM, AARCH64, MIPS. Frida-mode (
-O вместо -Q) - альтернатива для случаев, когда QEMU не справляется: нестандартные syscall-ы, сложные зависимости от окружения. Frida-mode медленнее QEMU, но стабильнее на edge-case бинарях. На практике: пробуйте QEMU первым, при crash-ах самого фаззера (не target-а) - переходите на Frida.libFuzzer: написание harness для фаззинга C/C++ приложений
Когда исходники доступны, libFuzzer - кратчайший путь к coverage-guided fuzzing отдельной функции. Harness пишется один раз и работает с несколькими фаззерами.Fuzz target и типичные ошибки при написании harness
Точка входа для libFuzzer - функцияLLVMFuzzerTestOneInput, принимающая массив байт и размер:
C:
// harness.c - пример для демонстрации концепции
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
if (Size < 4) return 0; // минимальный размер входа
parse_input(Data, Size); // целевая функция-парсер
return 0;
}
afl-clang-fast) и с Honggfuzz. Один harness - несколько фаззеров. Удобно.Четыре ошибки, которые убивают эффективность фаззинга:
Широкий target. Парсер поддерживает PNG, JPEG и GIF - не фаззьте через один harness. Документация LLVM прямо говорит: «the narrower the target the better». Три отдельных harness-а покроют больше кода за то же время. Проверено не раз.
Недетерминизм. Вызовы
rand(), time(), чтение /dev/urandom внутри target-функции делают coverage-guided подход бессмысленным: фаззер не может воспроизвести путь и корректно оценить мутацию. Результат - хаотичное блуждание вместо направленного поиска.Утечки памяти. В AFL++ каждый вход обрабатывается в отдельном fork-е, утечки не накапливаются - как отмечает Kaspersky ICS CERT, это «большое достоинство фаззинга в отдельном процессе». В libFuzzer всё в одном процессе - утечки копятся. Решение:
LeakSanitizer для детекции, -detect_leaks=0 если утечки некритичны и вы осознанно их игнорируете.Вызов exit(). libFuzzer запускает target миллионы раз -
exit() внутри target-а убивает весь процесс фаззера. Один exit() в обработке ошибки - и вся кампания встала.AddressSanitizer и UBSan: превращение «тихих» багов в crash-и
Фаззер без санитайзеров находит только явные crash-и. Heap buffer overflow на 1 байт может перезаписать padding и не вызвать SIGSEGV - баг есть, а crash-а нет. Санитайзеры превращают невидимые memory corruption в детерминированные SIGABRT с подробным отчётом.Компиляция с libFuzzer и санитайзерами:
clang -g -O1 -fsanitize=fuzzer,address target.c для связки с ASan, clang -g -O1 -fsanitize=fuzzer,undefined target.c для UBSan, или оба сразу. Флаг -fsanitize=fuzzer линкует main() из libFuzzer; для больших проектов со своим main() используйте -fsanitize=fuzzer-no-link при компиляции и -fsanitize=fuzzer только при линковке.ASan ловит: heap/stack/global buffer overflow, use-after-free (CWE-416), double-free (CWE-415). Overhead: ~2x замедление. UBSan ловит: signed integer overflow (CWE-190), null dereference (CWE-476), shift past bitwidth. Overhead: 5-10%. MSan ловит неинициализированные чтения - поддержка экспериментальная, overhead ~3x.
Для AFL++ с исходниками - аналогичная схема через
afl-clang-fast и AFL_USE_ASAN=1. Для binary-only целей (QEMU-mode) санитайзеры недоступны - перекомпиляция невозможна. Это фундаментальное ограничение binary-only фаззинга: без ASan/UBSan вы увидите только те баги, которые вызывают crash сами по себе. Тихие corruption-ы останутся невидимыми. Держите это в голове, когда оцениваете результаты QEMU-кампании.Анализ покрытия: как выйти из coverage-плато
Coverage-guided fuzzing без анализа покрытия - работа вслепую. Внутренний bitmap AFL++ управляет мутациями, но не отвечает на вопрос, какие функции покрыты и где именно фаззер застрял. SRLabs формулируют точно: «Coverage is measurement, not mutation feedback. AFL++'s internal bitmap steers the fuzzer; it doesn't tell you which functions or branches the campaign actually reached.»Рабочий цикл: fuzz - measure - analyze - unlock - re-fuzz. Каждая итерация углубляет то, до чего harness может дотянуться. Без этапа анализа итерации 4-6 не происходят, и фаззер стагнирует на потолке, которого достиг сам. Я видел десятки кампаний, которые «не работали» - а проблема была в отсутствии этого цикла.
Когда анализировать и как интерпретировать результат
Не сразу - дайте фаззеру отработать. Два практических эвристика:- Типичные цели: плато наступило, когда за последние 50% общего времени фаззинга не обнаружено нового покрытия. Фаззили 48 часов - если последние 24 часа ноль новых путей, пора измерять.
- Большие цели с глубоким state space: плато - когда последние 25% времени дали менее 1% дополнительного покрытия.
-fprofile-instr-generate -fcoverage-mapping, прогон corpus-а, слияние .profraw через llvm-profdata merge, визуализация через llvm-cov show. Точнее GCOV - работает на уровне source regions, корректно обрабатывает макросы и шаблоны. Для AFL++ - пересоберите target с --coverage, прогоните corpus через afl-cov или вручную через lcov + genhtml.Разблокировка покрытия: словари, CMPLOG и ручной seed
Coverage застрял - decision tree для выбора действия:| Причина застоя | Признак | Решение |
|---|---|---|
| Плохой seed corpus | Покрытие <30% уже на старте | Собрать валидные образцы формата (pcap, примеры протокола) |
| Magic bytes / checksum | Фаззер не проходит валидацию заголовка | Словарь: -x dict.txt (AFL++), -dict=dict.txt (libFuzzer) |
| Multi-byte сравнения | CMPLOG показывает застрявшие сравнения | -l 2 (AFL++ CMPLOG) или -use_value_profile=1 (libFuzzer) |
| Конкретная ветка не покрыта | Coverage report показывает uncovered branch | Ручной seed-файл, проходящий по нужной ветке |
| Целый модуль не покрыт | Функции не вызываются из текущего entry point | Отдельный harness для непокрытой функции |
Словари - недооценённый инструмент при фаззинге структурированных форматов. Если target парсит HTTP, XML, бинарный протокол - файл с ключевыми токенами формата (строки заголовков, магические числа, служебные байты) кратно ускоряет проход через валидацию. Формат одинаков для AFL++ и libFuzzer: по одному токену на строку в кавычках,
\xAB для hex-значений. На одном проекте добавление словаря из 40 токенов протокола подняло покрытие с 18% до 52% за первые два часа - без словаря фаззер бился головой о magic bytes в заголовке.CMPLOG в AFL++ - механизм перехвата операций сравнения (
strcmp, memcmp, магические числа) с подсказкой мутатору, какие байты подставить. Для source-based: второй бинарь с AFL_LLVM_CMPLOG=1, запуск с -c cmplog_binary. Для binary-only: AFL_COMPCOV_LEVEL=2 в QEMU-mode. В libFuzzer аналог - Tracing CMP instructions, включённый по умолчанию.Crash triage: от падения до классификации по CWE
Фаззер нашёл 50 crash-ей за неделю - это не 50 уязвимостей. Скорее 3-5 уникальных багов, остальное - дубликаты с разными входными данными. Не радуйтесь раньше времени.
🔓 Часть контента скрыта: Эксклюзивный контент для зарегистрированных пользователей.
Минимизация и дедупликация
Первый шаг -afl-tmin: убирает из входных данных лишнее, оставляя минимальный input, вызывающий crash. Для corpus-а целиком - afl-cmin убирает файлы, не добавляющие покрытия. Для libFuzzer-корпуса - -merge=1: ./my_fuzzer -merge=1 NEW_CORPUS FULL_CORPUS (добавляются только входы, trigger-ящие новое покрытие).Дедупликация crash-ей - по stack trace. Два crash-а с идентичным стеком вызовов - один баг. Для автоматизации: Casr из проекта Sydr-fuzz (ИСП РАН) выполняет кластеризацию, дедупликацию и оценку severity - команда
sydr-fuzz casr. Ручной подход: запуск каждого минимизированного crash-а под ASan, группировка по top-3 фреймам стека. Муторно, но надёжно.Классификация и оценка эксплуатируемости
ASan-отчёт указывает тип бага напрямую. Маппинг на CWE для vulnerability research:| ASan-тип | CWE | Эксплуатируемость |
|---|---|---|
| heap-buffer-overflow | CWE-122 | Высокая: контроль размера/данных = RCE через перезапись vtable |
| stack-buffer-overflow | CWE-121 | Высокая: перезапись return address |
| heap-use-after-free | CWE-416 | Средняя-высокая: зависит от timing-а и размера объекта |
| double-free | CWE-415 | Средняя: heap exploitation через tcache/fastbin |
| null-dereference | CWE-476 | Низкая: обычно DoS (страница 0 не маппирована на x86-64) |
| signed-integer-overflow (UBSan) | CWE-190 | Контекстная: зависит от использования результата |
Для автоматической оценки эксплуатируемости: плагин GDB Exploitable классифицирует crash по четырём уровням - EXPLOITABLE, PROBABLY\_EXPLOITABLE, PROBABLY\_NOT\_EXPLOITABLE, NOT\_EXPLOITABLE. Грубая эвристика (ручной анализ не заменяет), но экономит время при сортировке десятков crash-ей. Если Exploitable говорит NOT\_EXPLOITABLE - всё равно гляньте глазами, он ошибается чаще, чем хотелось бы.
Ограничения фаззинга и операционный контекст
Coverage-guided fuzzing закрывает определённый класс багов - memory corruption в C/C++ коде - с высокой автоматизацией. Но у подхода есть архитектурные слепые зоны, и о них нужно знать до запуска кампании, а не после.Логические уязвимости. Обход авторизации, IDOR, некорректная бизнес-логика - программа не падает, sanitizer не сработает. Тут нужен property-based fuzzing с явными assertions в harness-е: вы сами описываете инвариант, фаззер ищет нарушение.
State-dependent баги. Race conditions, TOCTOU - фаззер работает детерминированно в одном потоке. Для concurrency-багов нужен ThreadSanitizer в связке со stress-тестами. Coverage-guided fuzzing тут бессилен.
Глубоко вложенные пути. 15 последовательных проверок со специфическими значениями - мутационный фаззинг может не пробить за недели. Гибридный подход (символьное выполнение + фаззинг) решает эту проблему: Sydr-fuzz от ИСП РАН строит математическую модель программы и генерирует входы, открывающие пути, недоступные чистым мутациям.
Место в цепочке атаки. Фаззинг - фаза research/pre-engagement, не initial access и не post-exploitation. На внешнем пентесте веб-приложения coverage-guided fuzzing неприменим (там другие инструменты - Burp, sqlmap, ваши руки). Область применения: анализ thick clients и VPN-клиентов (внутренний пентест), прошивок IoT (аудит устройств), проприетарного ПО (vendor security review). На CTF - pwn-таски, где бинарь без исходников нужно быстро покрыть crash-ами перед ручным реверсом.
Детекция в корпоративной среде. Массовые crash-и процесса оставляют следы: crash dump-ы в Windows Error Reporting, core dump-ы в Linux, всплеск CPU. При удалённом blackbox-фаззинге сетевого сервиса IDS/IPS обнаружат аномальный трафик: множество malformed-пакетов за секунды. Фаззинг на тестовом стенде - штатная ситуация. Фаззинг production-сервиса удалённо - заметная атака, и вас заметят быстро.
Последние пару лет наблюдаю устойчивый паттерн: исследователи запускают AFL++ из коробки с пустым corpus-ом, получают ноль crash-ей за сутки и делают вывод - «фаззинг не работает для этого бинаря». В 9 из 10 таких случаев проблема не в фаззере, а в отсутствии анализа покрытия. Запуск фаззинга бинарных приложений без правильных seed-ов - как сканировать сеть nmap-ом по одному порту и удивляться пустому результату.
Настоящий coverage-guided fuzzing - итеративный цикл: запустил - замерил покрытие - нашёл uncovered branches - написал seed или словарь - перезапустил. Кто не замеряет покрытие - работает вслепую, и никакой persistent mode это не компенсирует.
Ещё одно наблюдение: libFuzzer по инерции описывается в туториалах как основной инструмент, хотя оригинальные авторы покинули проект и перешли на Centipede. Для новых кампаний закладывайте AFL++ как primary фаззер, libFuzzer - как secondary для in-process фаззинга конкретных функций. Порог входа в грамотный фаззинг выше, чем кажется из README на GitHub, но и отдача пропорциональная - при условии, что весь цикл fuzz-measure-analyze-unlock выполняется, а не только первый шаг. Попробуйте прогнать свой бинарь через AFL++ QEMU-mode с пустым corpus-ом, замерьте покрытие через сутки, а потом добавьте словарь и валидные seed-ы - разница в покрытии покажет, зачем нужен весь этот цикл.[/HIDE]