Статья Фаззинг бинарных приложений: AFL++, libFuzzer и coverage-guided fuzzing на практике

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


Три месяца непрерывного 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 APILLVMFuzzerCustomMutator
Скорость (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 после установки
QEMU-mode работает через динамическую бинарную трансляцию: AFL++ запускает целевой бинарь под QEMU user-mode и инжектит инструментирование на лету. Coverage-guided fuzzing без перекомпиляции - но с ощутимым overhead-ом по скорости.

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;
}
Этот harness не привязан к libFuzzer - та же функция работает с AFL++ (через 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% дополнительного покрытия.
Для libFuzzer-целей используйте source-based coverage LLVM: компиляция с -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 уникальных багов, остальное - дубликаты с разными входными данными. Не радуйтесь раньше времени.
🔓 Часть контента скрыта: Эксклюзивный контент для зарегистрированных пользователей.

Ограничения фаззинга и операционный контекст​

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]
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab