Статья Фаззинг веб-приложений и API: от AFL++ и LibFuzzer до обнаружения 0-day в реальных проектах

Тёмная лаборатория ночью: монитор с картой покрытия AFL++ и надписью об обнаруженной уязвимости освещает стол сине-зелёным светом. Рядом кружка с кофе, кабели и отладочные зонды.


За последний год я прогнал AFL++ через парсеры JSON и XML в трёх open-source веб-фреймворках. Результат: два heap-buffer-overflow и один use-after-free - все три в коде, который обрабатывает входные данные от пользователя. Ни один из этих багов не нашли юнит-тесты, статический анализ или ручной code review. Ноль из трёх. Фаззинг веб-приложений и API - это не «запустил ffuf по словарю и посмотрел коды ответов». Это coverage-guided процесс, где фаззер видит внутреннюю структуру кода, мутирует входные данные на основе покрытия и целенаправленно лезет в необработанные ветки. Разница между black-box фаззингом через HTTP и coverage-guided подходом на уровне исходников - как между стуком по стене в надежде услышать пустоту и рентгеном конструкции.

Место фаззинга в цепочке атаки​

Фаззинг - инструмент фаз reconnaissance и resource development по MITRE ATT&CK. Vulnerability Scanning (T1595.002, Reconnaissance) - автоматизированный поиск слабых мест в публично доступных приложениях. Найденный через фаззинг баг превращается в эксплойт - Exploits (T1587.004, Resource Development), - который идёт в ход для Exploit Public-Facing Application (T1190, Initial Access).

На конкретном пентесте цепочка выглядит так:
  1. Сбор информации - определяем стек целевого приложения: язык, фреймворк, нативные парсеры, используемые библиотеки
  2. Написание harness - создаём обёртку, которая подаёт фаззеру данные в формате, ожидаемом целевым парсером
  3. Фаззинг с санитайзерами - запуск AFL++ или LibFuzzer с AddressSanitizer, сбор крашей
  4. Triage - анализ крашей, определение root cause, оценка exploitability
  5. Exploit development - если баг эксплуатируемый, собираем PoC
  6. Применение - на пентесте, в bug bounty или CVE-репорт
Контекст применения: coverage-guided фаззинг серверных компонентов веб-приложений актуален при внутреннем аудите (white box, grey box) - когда есть доступ к исходному коду или бинарям. Для внешнего пентеста без доступа к коду работает black-box API fuzzing через HTTP. Оба подхода находят принципиально разные классы уязвимостей, и ниже разберём, когда какой применять.

Black-box vs coverage-guided fuzzing: выбор подхода​

Прежде чем хвататься за конкретные инструменты - определитесь с подходом. Выбор зависит от двух вещей: есть ли исходный код и что за цель.

УсловиеПодходИнструментЧто найдёте
Есть исходники на C/C++/RustCoverage-guided (grey box)AFL++, LibFuzzermemory corruption, use-after-free, heap overflow
Есть исходники на PythonCoverage-guided (grey box)Atheris + LibFuzzer backendunhandled exceptions, ReDoS, type confusion
Есть исходники на GoCoverage-guided (grey box)go test -fuzz (встроенный)panic, index out of range, nil dereference
Нет исходников, есть OpenAPI/Swagger specStateful API fuzzing (black box)RESTler, Schemathesis500-ошибки, injection, auth bypass
Нет исходников, нет спецификацииBlind API fuzzing (black box)ffuf, wfuzz, Burp Intruderinjection, path traversal, parameter pollution
Нет исходников, кастомный протоколStateful protocol fuzzingBoofuzzcrash, memory corruption через протокол

Ключевое ограничение black-box подхода: фаззинг через HTTP-запросы практически никогда не находит memory corruption в серверном коде. WAF или сам веб-сервер отбросит malformed request до того, как данные дойдут до уязвимого парсера. Coverage-guided подход работает на уровне функции - данные попадают напрямую в целевой код, минуя все промежуточные слои.

По данным обзора Dharmaadi et al. (2024, arxiv) - пожалуй, самого полного survey по фаззингу серверных веб-приложений - основная проблема web API фаззинга в том, что HTTP-запросы должны быть валидными, иначе веб-сервер их отвергает на ранней стадии. Это фундаментальное отличие от бинарного фаззинга, где можно подать произвольный мусор на stdin. Тот же обзор указывает на «ineffectiveness of instrumentation» как одну из ключевых нерешённых проблем - инструментировать серверное веб-приложение технически сложнее, чем скомпилированный бинарь.

Когда coverage-guided подход неприменим: внешний пентест SaaS без доступа к исходникам или бинарям; тестирование cloud-native API через публичные эндпоинты; аудит legacy-систем, где пересборка невозможна (нет исходников, нет build-системы). Тут - только black-box.

AFL++ для веб-компонентов: harness как фундамент​

Требования к окружению​

  • ОС: Linux (Ubuntu 22.04+ или Debian 12+), macOS частично (без persistent mode некоторых фич)
  • RAM: минимум 4 ГБ, рекомендуется 8+ ГБ при запуске параллельных инстансов
  • AFL++ 4.x (репозиторий активно поддерживается, коммиты еженедельно)
  • Компиляторы: clang/LLVM 14+ для инструментации LTO
  • AddressSanitizer входит в LLVM, отдельная установка не требуется
  • Сетевые требования: полностью offline-совместим, всё работает локально

Что фаззить в веб-приложении​

Веб-приложение - не монолит. Это набор парсеров, сериализаторов и обработчиков, каждый из которых принимает недоверенные данные. Самые продуктивные цели для coverage-guided фаззинга уязвимостей:
  • Парсеры форматов: JSON, XML, YAML, protobuf, MessagePack. Категория A08:2021 (Software and Data Integrity Failures) по OWASP прямо указывает на insecure deserialization как критический риск
  • HTTP-парсеры: обработка заголовков, multipart form-data, chunked encoding. Часто написаны на C/C++ даже в Python/Node.js стеках - через нативные расширения
  • Обработчики загрузки файлов: парсинг изображений (libpng, libjpeg), PDF, XLSX - классика для mutation-based fuzzing
  • Валидаторы: regex-движки (ReDoS, CWE-1333), email-парсеры, URL-парсеры, кастомные DSL
  • Криптографические операции: верификация подписей, парсинг сертификатов (ASN.1), JWT-обработка

Harness: анатомия обёртки для веб-парсеров​

Harness - функция, которая принимает сырые байты от фаззера и передаёт их в целевой код. От качества harness зависит, найдёт фаззер баги за 20 минут или будет гонять впустую сутки. Я видел ситуацию, когда два harness для одной библиотеки - один написан за 10 минут, второй за 2 часа с анализом реальных вызовов - давали разницу в coverage 4x за первый час.

Harness для AFL++ должен: читать данные через shared memory (или stdin), вызывать целевую функцию и не делать лишних I/O-операций (сеть, диск замедляют фаззинг на порядки).
C:
// harness_json.c - пример для демонстрации концепции
#include <stdio.h>
#include <stdlib.h>
#include "target_json_parser.h"

__AFL_FUZZ_INIT();
int main(void) {
    __AFL_INIT();
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
    while (__AFL_LOOP(10000)) {
        int len = __AFL_FUZZ_TESTCASE_LEN;
        json_parse(buf, len);
    }
}
[B]AFL_LOOP(10000) - persistent mode: процесс переиспользуется вместо fork-exec на каждый тест-кейс. Прирост скорости в 10-20 раз. [/B]AFL_FUZZ_TESTCASE_BUF - shared memory, ещё быстрее чтения из stdin.

Компиляция с инструментацией и AddressSanitizer: AFL_USE_ASAN=1 AFL_USE_UBSAN=1 afl-clang-lto -o harness harness_json.c -ltarget_json. AFL_USE_ASAN=1 активирует ASan, который ловит heap-buffer-overflow, use-after-free, stack-buffer-overflow, double-free. Без ASan фаззер увидит только hard crash (SIGSEGV/SIGABRT), а тонкие memory corruption проскочат незамеченными.

Запуск: afl-fuzz -i corpus/ -o findings/ -m none -- ./harness. Каталог corpus/ должен содержать seed-файлы - минимальные валидные примеры. Для JSON: {"key":"value"}, [], "", 0, null. Разнообразный seed corpus ускоряет выход фаззера к глубоким веткам кода.

CmpLog: решение проблемы магических байтов​

Стандартный coverage-guided фаззинг плохо справляется с условиями вида if (header == 0xDEADBEEF) - вероятность угадать 4 байта мутацией ничтожна. AFL++ решает это через CmpLog (аналог RedQueen): инструментирует сравнения и подставляет значения из правой части условия в corpus. Для веб-парсеров это критично - XML начинается с <?xml, JSON-схемы содержат обязательные ключи, HTTP-заголовки имеют фиксированный синтаксис.

Подключение: собрать два бинаря. Основной с ASan: AFL_USE_ASAN=1 afl-clang-lto -o harness harness_json.c -ltarget_json. Вспомогательный CmpLog без ASan (совмещение ASan+CmpLog не рекомендуется из-за overhead): AFL_LLVM_CMPLOG=1 afl-clang-lto -o harness.cmplog harness_json.c -ltarget_json. Запуск: afl-fuzz -i corpus/ -o findings/ -c ./harness.cmplog -- ./harness. CmpLog заметно ускоряет прохождение парсеров с жёсткой структурой входных данных.

Ограничения AFL++ фаззинга в веб-контексте​

  • Требует перекомпиляции целевого кода - не подходит для SaaS без доступа к исходникам
  • Для интерпретируемых языков (Python, Ruby, PHP) нужны специализированные обёртки
  • Persistent mode не всегда корректен: если целевая функция использует глобальное состояние, которое не сбрасывается между итерациями, появляются false positives
  • Не видит логических багов (IDOR, auth bypass) - только memory corruption и crash

LibFuzzer: быстрый in-process поиск уязвимостей​

LibFuzzer - in-process coverage-guided фаззер, встроенный в LLVM. В отличие от AFL++, работает внутри одного процесса без fork - быстрее для фаззинга отдельных функций, но менее устойчив: один crash завершает сессию.

Применимость для веб-приложений: LibFuzzer идеален для фаззинга нативных расширений. Если Python/Node.js приложение использует C-библиотеку для парсинга (libxml2, rapidjson, zlib, openssl - а используют практически все), LibFuzzer фаззит именно этот нативный слой.
C:
// fuzz_xml.c - harness для libxml2
#include <libxml/parser.h>
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    xmlDocPtr doc = xmlReadMemory(
        (const char *)data, size, "noname.xml", NULL, 0);
    if (doc != NULL) xmlFreeDoc(doc);
    xmlCleanupParser();
    return 0;
}
Компиляция: clang -g -fsanitize=fuzzer,address -o fuzz_xml fuzz_xml.c $(xml2-config --cflags --libs). Запуск: ./fuzz_xml corpus_xml/ -max_len=65536 -jobs=4. Параметр -jobs=4 запускает четыре параллельных процесса - утилизация многоядерной машины.

AFL++ vs LibFuzzer для фаззинга веб-приложений: AFL++ устойчивее для длительных кампаний (дни, недели) - crash не убивает весь процесс. LibFuzzer быстрее для коротких сессий и проще интегрируется в CI/CD (один бинарь, без внешних зависимостей). На практике для серьёзного поиска 0-day уязвимостей я запускаю оба: LibFuzzer - быстрый первый проход, AFL++ - длительная кампания с расширенными мутационными стратегиями (MOpt, CmpLog).

Atheris: фаззинг Python-компонентов REST API​

Значительная часть современных веб-приложений написана на Python, JavaScript или Go. Фаззить их AFL++ напрямую нельзя - нет нативного бинаря для инструментации. Для Python есть Atheris - coverage-guided фаззер от Google, работающий на базе LibFuzzer.

Atheris перехватывает coverage на уровне CPython bytecode. Где это полезно:
  • Django/Flask/FastAPI view-функции, обрабатывающие пользовательский ввод
  • Пользовательские валидаторы и сериализаторы в REST API
  • Парсеры специфических форматов (CSV с кастомной логикой, проприетарные протоколы поверх HTTP)
Python:
# fuzz_api_validator.py - пример для демонстрации концепции
import atheris, sys
from myapp.validators import parse_user_input

def TestOneInput(data):
    try:
        parse_user_input(data.decode("utf-8", errors="ignore"))
    except (ValueError, KeyError):
        pass  # ожидаемые исключения пропускаем

atheris.Setup(sys.argv, TestOneInput)
atheris.Fuzz()
Запуск: python fuzz_api_validator.py -max_len=4096 corpus/. Atheris найдёт unhandled exceptions, бесконечные циклы (timeout), excessive memory allocation - потенциальные DoS-вектора (Endpoint Denial of Service, T1499 по MITRE ATT&CK; для resource exhaustion через ReDoS/memory - T1499.004, Application or System Exploitation).

Ограничение по скорости: Atheris выдаёт 100-1000 exec/sec вместо 10000-100000 у AFL++/LibFuzzer на нативном коде. Для глубокого поиска memory corruption в нативных расширениях Python-пакетов (Pillow, lxml, cryptography) Atheris требует пересборки C-кода с LibFuzzer-инструментацией (atheris_no_libfuzzer_main), что по сути эквивалентно прямому использованию LibFuzzer на C-слое. Atheris - про логику Python-кода, а не про memory bugs.

Фаззинг REST API: от спецификации к серверным крашам​

Coverage-guided подход требует доступа к коду. На внешнем пентесте, когда доступны только HTTP-эндпоинты, работает stateful API fuzzing - генерация последовательностей HTTP-запросов на основе OpenAPI/Swagger спецификации.

Инструменты API fuzzing: trade-off​

КритерийRESTler (Microsoft)Schemathesisffuf/wfuzzBoofuzz
ПодходStateful, grammar-basedProperty-based, HypothesisWordlist-based, black boxStateful protocol
Когда использоватьЕсть OpenAPI spec, нужны цепочки запросовЕсть OpenAPI/GraphQL spec, проверка контрактовНет спецификации, фаззинг параметровКастомные протоколы, не HTTP
Когда НЕ использоватьНет спецификацииТаргетированный поиск конкретных CWEStateful сценарии, memory corruptionСтандартные REST API
СтатусАктивный (Microsoft Research)Активный, частые релизыАктивный, широко используетсяАктивный, нишевый

Тот же survey Dharmaadi et al. (2024) показывает: большинство web API фаззеров используют OpenAPI спецификацию для генерации шаблонов запросов. Это решает проблему валидности HTTP - серверы отвергают невалидные запросы. Но у подхода серьёзный gap: спецификация описывает только документированные эндпоинты. Скрытые API, debug-маршруты, internal-эндпоинты остаются вне зоны покрытия.

Как я делаю на внешнем пентесте: сначала ffuf -u https://target/FUZZ -w api-wordlist.txt -mc 200,301,403 для обнаружения скрытых эндпоинтов, затем RESTler или Schemathesis для глубокого stateful фаззинга по найденным и документированным маршрутам.

В контексте OWASP A03:2021 (Injection) API фаззинг находит SQL injection, NoSQL injection, OS command injection через мутацию параметров. Но ещё ценнее - логические баги: race conditions при параллельных запросах, IDOR через перебор идентификаторов, нарушения бизнес-логики при нестандартных последовательностях вызовов.

Санитайзеры и триаж крашей​

Найти crash - половина дела. Вторая - понять, эксплуатируемый ли это баг и какой CWE ему соответствует.
📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме

Интеграция фаззинга в CI/CD: автоматизированный поиск уязвимостей​

Фаззинг приносит максимум пользы при непрерывной работе, а не при однократном запуске. Один прогон - лотерея. Регулярные прогоны - статистика.

Corpus-based fuzzing в пайплайне​

Минимальная CI-схема для фаззинга бинарных приложений и веб-компонентов:
  1. На каждый PR - короткий прогон (10-30 минут) с regression corpus. Цель: проверить, что новый код не создал регрессию по ранее найденным крашам
  2. Nightly - длительная кампания (4-8 часов) с расширенными мутациями (MOpt, CmpLog). Цель: поиск новых багов
  3. Weekly - полный фаззинг с обновлённым corpus (добавляются seed из реальных API-запросов)
Google OSS-Fuzz - бесплатная инфраструктура непрерывного фаззинга для open-source проектов. Если вы мейнтейнер библиотеки-парсера - интеграция автоматизирует фаззинг, triage и уведомления. Для proprietary кода - собственный пайплайн через GitHub Actions или GitLab CI с AFL++ в Docker-контейнере.

Управление corpus​

Corpus - то, что отличает результативный фаззинг от бесполезного шума:
  • Seed из production логов: реальные HTTP-запросы к API (с обфусцированными credentials) - лучший starting corpus для фаззинга REST API
  • Минимизация: afl-cmin -i corpus/ -o corpus_min/ -- ./harness для AFL++, ./fuzz_target -merge=1 corpus_merged corpus_raw для LibFuzzer - убирает дубликаты по coverage
  • Версионирование: corpus хранится в Git LFS или артефактах CI. Потеря corpus между прогонами = потеря недель работы фаззера
  • Ручное обогащение: пустые строки, максимально длинный input, unicode, null bytes, boundary values. Для веб-контекста: невалидный Content-Type, nested JSON глубиной 100+, multipart с миллионом boundaries

Сводная таблица подходов к фаззингу веб-приложений​

КритерийAFL++LibFuzzerAtherisRESTler
Скорость (exec/sec)1K-50K5K-100K100-1K10-100
Типы баговmemory corruptionmemory corruptionexceptions, DoSinjection, auth bypass, 500
Требует исходниковДа (C/C++/Rust)Да (C/C++)Да (Python)Нет (нужен OpenAPI)
Ловит memory corruptionДаДаТолько в C-расширенияхНет
CI/CD интеграцияСредняя (Docker)Высокая (один бинарь)Высокая (pip)Средняя
Длительные кампанииДа (устойчив к крашам)Ограниченно (-jobs смягчает, но crash завершает процесс)ЧастичноДа
Поддержка CmpLog/dictionaryДа (CmpLog, RedQueen)Да (dictionaries)НетГрамматика из spec
Когда НЕ использоватьНет исходниковДлительные кампанииНативный C/C++Кастомные протоколы

От crash к CVE: workflow поиска 0-day уязвимостей​

Crash в open-source библиотеке - потенциальный 0-day, если библиотека используется в production веб-приложениях. Процесс:
  1. Верификация - подтвердить crash на последней стабильной версии
  2. Root cause analysis - определить CWE. Heap-buffer-overflow: CWE-122. Use-after-free: CWE-416. Null dereference: CWE-476. Integer overflow: CWE-190
  3. Exploitability - WRITE примитив с контролируемым размером = вероятный RCE. READ-only = information disclosure. Null dereference = DoS
  4. Responsible disclosure - отчёт мейнтейнеру через security@ или GitHub Security Advisory. Стандартный дедлайн: 90 дней
  5. CVE request - через MITRE CVE form или GitHub CNA
  6. PoC - после выхода патча, минимальный воспроизводимый пример
Юридический контекст: фаззинг open-source проектов - легальная деятельность. Фаззинг чужих production-систем без разрешения - нет. Для пентеста нужен scope, для bug bounty - программа, для research - собственный стенд.

Если формула на бумаге понятна, но хочется прогнать crash triage от ASan-отчёта до рабочего PoC руками - на HackerLab.pro в категориях pwn и reverse есть задачи, где нужно проанализировать memory corruption и собрать exploit на готовом стенде.



Девять из десяти русскоязычных материалов по фаззингу - пересказ теории: что такое mutation-based, что такое generation-based, как работает coverage feedback. Для первого знакомства сойдёт, для реальной работы - нет. На практике 90% времени уходит на написание правильного harness и triage крашей, а не на выбор между AFL++ и LibFuzzer. Плохой harness - парсер вызывается с неинициализированным контекстом, глобальное состояние протекает между итерациями, I/O замедляет до 50 exec/sec - и месяц фаззинга даёт ноль. Я видел, как два harness для одной библиотеки давали разницу в coverage 4x за первый час. Второй нашёл use-after-free за 20 минут. Первый не нашёл ничего за сутки. Разница - два часа работы на анализ реальных вызовов в production-коде.

Индустрия сертификации (тот же ГОСТ Р 56939, на который ориентируются российские вендоры) загоняет фаззинг в формальные рамки: 80% покрытия, 1.5 миллиона итераций, два часа стабильности. Формальный подход превращает мощный исследовательский инструмент в checkbox. Реальные 0-day находятся не процентами покрытия, а качеством harness и глубиной triage - и именно этому стоит учиться в первую очередь. На HackerLab есть задачи, где нужно от найденного crash собрать полную цепочку эксплуатации - без подсказок и без EDR в дефолте.
 
Мы в соцсетях:

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

Похожие темы

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

HackerLab