Источник: https://github.com/AnaktaCTF/CTF/blob/main — Misc/fuzzing.md

Введение

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

В этой статье мы подробно разберём ключевые механизмы современных fuzz-движков, продемонстрируем примеры для трёх языков (C, Python и Go), а также расскажем, как интегрировать fuzzing в рабочие процессы разработки и непрерывной поставки (CI/CD). Особое внимание уделим вопросам инструментирования, использованию флагов санитаризации (sanitize), автоматическому анализу крашей и оптимизации производительности.

Основные принципы fuzzing

Fuzzing можно представить как итеративный цикл, в котором движок последовательно генерирует или модифицирует входные данные, подаёт их в целевую программу (target), отслеживает поведение при исполнении и на основе полученной обратной связи формирует новое поколение тестов. Центральный элемент этого процесса — feedback loop: благодаря измерению покрытия кода или выявлению аномалий движок решает, какие из тестов «интересны» и каким образом стоит их дальше мутировать.

Генерация и мутация тестов

Генерация тестов может базироваться на простейшем случайном алгоритме, но гораздо эффективнее работать с исходным набором корректных или «полусемян» (seeds), внося в них избирательные изменения. В зависимости от характера target-приложения и формата обрабатываемых данных фреймворк выбирает стратегию мутации, будь то побитовая мутация, вставка структурированных фрагментов или применение грамматик. К каждому тесту пути исполнения программы прикрепляется метка покрытия — какая часть кода была затронута — и если в результате мутации удалось посетить ранее недоступный участок, тест сохраняется как новое семя.

Подача данных в target

Подача данных реализуется через стандартные входные потоки, файловую систему, сетевые сокеты, библиотеки API или иные интерфейсы target’а. При этом важно корректно фиксировать точки входа и изолировать саму логику тестируемого компонента от побочных эффектов: например, если программа при невалидных данных открывает или записывает файлы, это может «засорять» среду и мешать анализу.

Инструментирование и наблюдение

Наблюдение за поведением программы осуществляют посредством инструментирования: уже на этапе компиляции или с помощью динамических обёрток вставляются счётчики покрытия, санитайзеры (AddressSanitizer, UndefinedBehaviorSanitizer, LeakSanitizer) и механизмы детектирования таймаутов и дедлоков. Фреймворк регистрирует не только аварийные завершения (segfault, abort), но и случаи переполнения буфера, обращения к освобождённой памяти или неопределённого поведения на уровне языка.

Триаж обнаруженных крашей

Важнейшая задача — триаж (triage) обнаруженных крашей: движок группирует аварии по сходству трассировки стека, отфильтровывает дубликаты и приоритетизирует расследование тех, что связаны с потенциально критичными уязвимостями (например, Out-Of-Bounds, Use-After-Free, Integer Overflow). Отдельные тесты, расширившие покрытие, тоже выделяются, поскольку они могут скрывать новые слабые места.

Мутация и генерация структурированных данных

В самом простом случае fuzzer выполняет чисто случайную мутацию уже существующих файлов: выбирает произвольные байты, заменяет их случайными значениями, удаляет или дублирует фрагменты. Такой подход применим практически к любому target’у, однако у него есть два ограничения. Во-первых, слишком агрессивная мутация обычно разрушает структуру данных, и большая часть тестов будет сразу отвергнута парсером. Во-вторых, без какой-то целенаправленной логики фьюзер тратит ресурсы на повторение бессмысленных комбинаций.

Гораздо более действенной оказывается generation-based методика: для данных, описываемых определённой грамматикой или протоколом, движок программно собирает валидные, но разнообразные структуры. Например, для HTTP-запросов может быть задано, что вначале идёт метод (GET, POST), затем URI, за ним заголовки и тело. Грамматику точки входа можно описать в формате ASN.1, JSON Schema или даже BNF, и генератор будет комбинировать лексемы согласно правилам, лишь изредка вводя «шум» в виде лишних пробелов, неизвестных заголовков или некорректных значений полей.

Ниже приведён упрощённый псевдокод генерации структурированного пакета:

function generate_packet():
    header = choose_one(["HDR1", "HDR2", "HDR3"])
    length = random_int(1, 1024)
    payload = random_bytes(length)
    checksum = crc32(header + payload)
    return header + encode_uint32(length) + payload + encode_uint32(checksum)

Такой способ позволяет пройти глубоко в коде, когда чистый хаос никогда не пробился бы сквозь тяжёлую валидацию. К нему прибегают при fuzzing’е мультимедийных контейнеров (PNG, JPEG), сетевых протоколов (TLS, MQTT) и проприетарных форматов файлов (бинарные логи, конфиги). Некоторые продвинутые реализации грамматик автоматически извлекают правила из исполняемого кода или на основе сэмплов обучаются генерации похожих структур, сочетая машинное обучение и динамический анализ.

Инструментирование и санитайзеры

Ключ к эффективному fuzzing’у — подробный мониторинг исполнения. Для этого на этапе сборки target-компонента применяют специальные флаги компилятора, которые встраивают в бинарь или библиотеку подсчёт покрытия и проверку памяти. Среди наиболее популярных средств:

  • AddressSanitizer (ASan) фиксирует выходы за границы аллокаций, использование освобождённых участков и неправомерное выравнивание.
  • UndefinedBehaviorSanitizer (UBSan) детектирует случаи неопределённого поведения языка (деление на ноль, неверное приведение типов, signed integer overflow).
  • LeakSanitizer (LSan) отслеживает утечки памяти, что бывает критично при многократном запуске под fuzzingом.

Компиляция может выглядеть так (для LLVM/Clang):

clang -fsanitize=address,undefined,leak -fprofile-instr-generate \
      -fcoverage-mapping -g -O1 -o target_fuzz target.c

Флаг -fprofile-instr-generate включает сбор профилей покрытия, а -fcoverage-mapping — отображение этих профилей на исходные файлы. При таком инструментировании каждый запуск теста порождает файл профиля, который движок анализирует, подсчитывая посещённые базовые блоки и принудительно сохраняя тесты, открывшие новые ветви.

Нередко fuzzer оборачивают в прокси, который следит за выходными кодами target’а: если приложение завершилось с аварийным сигналом или условным assertion-failure, прокси фиксирует дамп памяти и возвращает информацию движку для повторного анализа. Важна и поддержка таймаутов: если target подвис в бесконечном цикле, механизм watchdog прерывает его через заданный интервал, помечая тест как «зависший», но не учитывая это как краш.

Coverage-guided fuzzing: «умная» мутация

Самое серьёзное эволюционное улучшение — включение обратной связи о покрытии кода. Такой движок сначала выполняет все имеющиеся семена под инструментированным бинарём, строит карту покрытия и затем приступает к мутации, но при этом каждое новое поколение тестов оценивается по двум критериям: вызывает ли оно сбой (краш) и расширяет ли оно покрытие. Только в этих случаях тест сохраняется в очередь и послужит основой для следующей итерации.

Принцип работы «coverage-guided» фреймворка можно схематично представить так. На вход приходит N исходных семян. Для каждого генерируется M мутантов. После запуска всех мутантов по профилям покрытия кода сравнивается с базовым. Те мутанты, которые «зашли» в новые блоки, отбираются и пополняют пул, а наиболее эффективные из них многократно мутируются дальше. При этом в пуле всегда сохраняются старые семена, если они давали ценное покрытие. Таким образом формируется волновой алгоритм поиска новых путей исполнения.

American Fuzzy Lop (AFL) — классический пример coverage-guided fuzzer’а. Он использует оптимизированное считывание покрытия через XOR-смещения адресов базовых блоков, что позволяет быстро сравнивать профили и практически мгновенно отбрасывать неинтересные тесты. LibFuzzer от LLVM аналогично встраивается в сам бинарь: пользователь реализует функцию LLVMFuzzerTestOneInput, а движок автоматически перезапускает программу с новыми вариантами байт, анализирует покрытие и сохраняет «interesting» входные данные в директорию corpus.

Грамматический fuzzing и сложные протоколы

Когда формат данных сложен, чистая мутация почти бессильна: нужно гарантировать, что тесты проходят хотя бы минимальную валидацию и достигают глубоких частей парсера. Здесь на помощь приходит grammar-based fuzzing. Программисты описывают синтаксис языка или протокола в виде контекстно-свободной грамматики или DSL, и движок генерирует структурно корректные конструкции, в которых, однако, встречаются «дикие» варианты: неожиданные длины полей, редкие комбинации атрибутов, некорректные начисления контрольных сумм.

Такой подход особенно эффективен для web-протоколов (HTTP/2, WebSocket), мультимедиа (PNG, MP4, WebM) и сериализованных форматов (Protocol Buffers, ASN.1). Существуют проекты, которые автоматически извлекают грамматики, анализируя исходники библиотеки или протокол через статический анализ, а затем комбинируют эвристики мутации и обученные модели, чтобы создавать тесты, которые одновременно проходят валидацию и при этом оставляют за собой шанс вызвать нетривиальные ошибки.

Пример на C с AFL

// parser_fuzz.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>

void process_input(const uint8_t *data, size_t size) {
    char buffer[128];
    // Копирование без проверки длины
    memcpy(buffer, data, size);
    // Гарантируем корректную строку
    buffer[size < 127 ? size : 127] = '\0';
    // Если встречено ключевое слово, краш
    if (strstr(buffer, "FUZZ_CRASH") != NULL) {
        *(volatile int*)0 = 0;
    }
}

int main(int argc, char **argv) {
    if (argc != 2) return 1;
    FILE *f = fopen(argv[1], "rb");
    if (!f) return 1;
    fseek(f, 0, SEEK_END);
    size_t size = ftell(f);
    rewind(f);
    uint8_t *data = malloc(size);
    if (!data) return 1;
    fread(data, 1, size, f);
    fclose(f);
    process_input(data, size);
    free(data);
    return 0;
}

Сборка и запуск

afl-gcc -o parser_fuzz_afl -O1 -fno-omit-frame-pointer \
        -fsanitize=address -g parser_fuzz.c

mkdir seeds
echo "HELLO" > seeds/seed1.txt
echo "WORLD" > seeds/seed2.txt

afl-fuzz -i seeds -o afl_output -- ./parser_fuzz_afl @@

Пример на Python с python-afl

# target.py
import sys
import afl

def process(data: bytes):
    text = data.decode('utf-8', errors='ignore')
    if "PYFUZZ" in text:
        # Принудительная авария через некорректный индекс
        arr = []
        _ = arr[1]
    if text.startswith("PING"):
        print("PONG")

def main():
    afl.init()  # инициализация точек взаимодействия с AFL
    for line in sys.stdin.buffer:
        process(line.strip())

if __name__ == "__main__":
    main()
mkdir pyseeds
echo "PING" > pyseeds/s1.txt
afl-fuzz -i pyseeds -o pyfuzz_output -- python3 target.py

Пример на Go с go-fuzz

// +build gofuzz

package myfuzz

import "strings"

func Fuzz(data []byte) int {
    s := string(data)
    if strings.Contains(s, "GOLANG_CRASH") {
        // Авария через нулевой указатель
        var p *int
        _ = *p
    }
    if strings.HasPrefix(s, "START") {
        return 1
    }
    return 0
}
go-fuzz-build -func Fuzz -o myfuzz-fuzz.zip ./path/to/package
go-fuzz -bin=myfuzz-fuzz.zip -workdir=./go_fuzz_work

Анализ крашей и triage

Найдя сбой, нужно понять, действительно ли он указывает на потенциальную уязвимость, или это ложный сигнал среды. Первым шагом обычно является группировка крашей по сходству трассировки: большинство фреймворков автоматически хешируют стек-трейс и удаляют дубликаты, оставляя лишь уникальные случаи. Затем критериям приоритета подлежат сбои, связанные с нарушением целостности памяти (переполнение, use-after-free), арифметическими переполнениями и недетерминированным поведением.

Далее тесты воспроизводятся вручную под отладчиком (gdb, lldb) или с помощью AddressSanitizer в режиме «no-fork», чтобы получить точную линию кода, вызвавшую ошибку. Полезно включить режим подробных логов и записи трассировки памяти (heap sanitizer). Если сбой вызван внешней библиотекой, её исходники тоже компилируют с теми же флагами sanitize и coverage, чтобы локализовать проблему.

После идентификации корня неисправности обычно генерируют минимальный тест-кейс (minimized input), устраняя лишние символы и поля, чтобы получить кратчайшую последовательность, воспроизводящую сбой. Этот шаг помогает быстро понять, какой именно фрагмент данных нарушает логику.

Интеграция в CI/CD

Чтобы fuzzing стал не разовой акцией, а частью процесса разработки, его подключают к CI/CD-пайплайнам. Движок запускают на выделенных машинах с ограничением по времени (тестовые воркеры работают например сутки, затем сохраняют результаты и сбрасываются), а затем результаты автоматически анализируются: новые краши отправляются в баг-трекер, расширившие покрытие семена — в артефакты.

Часто используют контейнеризацию: каждый job поднимает Docker-контейнер с необходимыми зависимостями, запускает AFL или libFuzzer на заданном наборе модулей и по истечении таймаута выгружает директорию corpus и crashes в общий storage. Логи работы и профили покрытия интегрируют в отчёты типа Codecov, чтобы следить за динамикой качества тестирования.

Оптимизация производительности

Fuzzing — ресурсозатратный процесс. Чтобы добиться максимальной скорости тестирования, применяют несколько приёмов. Во-первых, отказ от полного санитайзер-инструментирования для «горячих» участков, вместо этого применяют легковесное считывание покрытия (llvm-cov на основе PC-журналов). Во-вторых, масштабирование на кластеры: распределённые fuzzer-агенты обмениваются новыми семенами через централизованный пул, что ускоряет поиск редких крашей. В-третьих, многопоточность внутри одного процесса: honggfuzz и libFuzzer поддерживают режим fork-server, когда один инструментированный процесс раздаёт сигналы на форк новых «рабочих» процессов с уже размеченным адресным пространством.

Другая оптимизация — использование «persistent mode» для libFuzzer: целевая функция вызывается десятки тысяч раз в одном процессе, что снижает накладные расходы на инициализацию. При этом важно, чтобы harness был реализован таким образом, чтобы не накапливать глобальное состояние между итерациями или корректно сбрасывать его.

Лучшие практики

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

Заключение

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