Тёмной грозовой осенней ночью 1988 года в Мэдисоне (штат Висконсин) профессор информатики Бартон Миллер (Barton Miller) сидел дома за терминалом, пытаясь работать на университетской UNIX-станции через модем со скоростью 1200 бод по аналоговой телефонной линии. За окном бушевала сильная гроза.Каждый удар молнии наводил электромагнитные помехи в телефонном проводе, искажая поток символов ASCII, отправляемых в командную оболочку UNIX.
То, что произошло дальше, поразило его: когда линейный шум посылал бессмысленные последовательности байтов в проверенные временем стандартные утилиты UNIX — такие как vi, cc, awk, sed, csh и indent, — эти программы не просто выводили сообщение об ошибке синтаксиса, а одна за другой аварийно завершались с ошибкой сегментации (SIGSEGV, Segmentation Fault) или зависали в бесконечных циклах.
Потрясённый этой хрупкостью, Миллер поручил своим студентам написать генератор случайных символьных потоков, который он назвал «Fuzz» (шум, помехи), и протестировать около 90 утилит на семи версиях UNIX. Результаты ошеломили индустрию: от 25 % до 33 % всех стандартных утилит UNIX падали при подаче чисто случайных байтовых потоков.
Спустя более чем 35 лет фаззинг (Fuzzing / Fuzz Testing) превратился из слепого генератора шума в самую результативную технологию автоматического поиска уязвимостей в компьютерных науках, объединившую компиляторную инструментацию (LLVM IR), эволюционные генетические алгоритмы, SMT-решатели и теневую память (Shadow Memory). Только платформа непрерывного фаззинга OSS-Fuzz от Google автоматически обнаружила более 10 000 уязвимостей безопасности и свыше 36 000 багов в критически важных проектах, включая OpenSSL, SQLite, FFmpeg и ядро Linux.
Почему тысячи тщательно написанных модульных тестов (Unit Tests) пропускают фатальные повреждения памяти, которые автоматическая мутация вскрывает за считанные минуты? В этой статье мы разберём комбинаторный взрыв пространства входов, четыре парадигмы фаззинга, внутреннюю архитектуру AFL / AFL++ / LibFuzzer (битовая карта 64 КБ в кэше L1, математическое доказательство сдвига >> 1, Forkserver и марковские расписания), устройство AddressSanitizer (ASan) и алгоритм дельта-отладки (ddmin) Андреаса Целлера.
Глава 1: Комбинаторная бездна пространства входов и «эффект фонарного столба»
1.1 Буфер из 64 байт скрывает $10^{154}$ вселенных
Почему ручные модульные тесты не способны искоренить ошибки в ПО? Фундаментальная причина кроется в комбинаторном взрыве пространства входных данных. Рассмотрим простую функцию на C, разбирающую бинарный заголовок пакета или изображения длиной всего $N = 64$ байта. Поскольку каждый байт принимает $|\Sigma| = 2^8 = 256$ значений, общее число возможных 64-байтовых входов равно:
$$|\Sigma|^N = 256^{64} = \left(2^8\right)^{64} = 2^{512} \approx 1.34 \times 10^{154}$$Для сравнения: число Эддингтона (оценка общего количества протонов во всей наблюдаемой Вселенной) составляет всего около $10^{80}$. Даже если бы все процессоры Земли проверяли по триллиону ($10^{12}$) входов в секунду непрерывно с момента Большого взрыва (13,8 млрд лет), они не исследовали бы и $10^{-124}$ доли этого крошечного 64-байтового буфера.
1.2 «Эффект фонарного столба» (Streetlight Effect) и «Странная машина» (Weird Machine)
При написании модульных тестов разработчики неизбежно попадают в ловушку эффекта фонарного столба (смещение наблюдения): подобно человеку, ищущему потерянные ночью ключи только под фонарём, потому что там светло, программисты тестируют лишь штатные сценарии из спецификации и очевидные граничные значения (0, -1, NULL), о которых они и так подумали при написании кода. Но критические уязвимости прячутся в темноте — в так называемой «Weird Machine» (Bratus et al.), скрытом конечном автомате, возникающем на стыке целочисленных переполнений и неявных допущений парсера:
| |
При width = 256, height = 256 произведение $256 \times 256 = 65536 \equiv 0 \pmod{2^{16}}$ переполняет 16-битную переменную alloc_size в 0, malloc(16) выделяет крошечный буфер, а memcpy записывает туда 64 КБ данных, разрушая кучу (Heap).
Глава 2: Четыре парадигмы фаззинга
Чтобы преодолеть проверки вроде if (magic != 0x494D4721) (вероятность случайного угадывания $2^{-32} \approx 1 / 4{,}29\times 10^9$), фаззинг развился в четыре основные парадигмы:
| Парадигма фаззинга | Примеры инструментов | Внутренняя видимость | Механизм поиска и генерации | Сильные стороны | Фундаментальные ограничения |
|---|---|---|---|---|---|
| Чёрный ящик (Black-box Fuzzing) | fuzz (1988), zzuf, radamsa | Отсутствует (полная непрозрачность) | Слепые случайные инверсии битов и перезапись байтов | Не нужен исходный код и инструментация; высокий темп запуска | Блокируется магическими числами ($2^{-32}$), контрольными суммами и сложным синтаксисом |
| Грамматический / генерационный (Grammar-based) | Peach, Syzkaller, LangFuzz, Domato | Контекстно-свободная грамматика (CFG) или схема системных вызовов | Синтезирует и мутирует синтаксически корректные деревья (AST) | Легко проходит лексеры и парсеры, проникая вглубь JS-движков и ядра ОС | Высокая трудоёмкость написания грамматик; пропускает баги парсера на битом синтаксисе |
| Белый ящик: символьное / конколическое исполнение | SAGE, KLEE, angr, Driller | Полная символьная трасса инструкций и предикатов ветвления | Собирает условие пути $\Pi$ и инвертирует ветви через SMT-решатель (Z3) | Мгновенно алгебраически решает уравнения вида x == 0xdeadbeef | Взрыв числа путей ($2^d$) в циклах; зависание SMT на хэшах и нелинейной арифметике |
| Серый ящик с обратной связью по покрытию (CGF) | AFL, AFL++, LibFuzzer, Honggfuzz | Лёгкая компиляторная инструментация рёбер (битовая карта 64 КБ) | Дарвиновский генетический отбор: сохраняет только входы, открывшие новые рёбра CFG | Не требует SMT-решателя; $10^4\text{–}10^5\text{ запусков/с}$ с эволюционным продвижением вглубь кода | Требует CmpLog (RedQueen) или словарей для монолитных многобайтовых сравнений |
При конколическом (Concrete + Symbolic) исполнении входные байты представляются символьными переменными $\vec{\alpha}$, а последнее условие ветвления инвертируется для SMT-решателя:
$$\Pi_{\text{path}} = c_1(\vec{\alpha}) \land c_2(\vec{\alpha}) \land \dots \land c_{k-1}(\vec{\alpha}) \land \neg c_k(\vec{\alpha})$$Напротив, созданный Михалом Залевским (Michał Zalewski) в 2013 году AFL, а также AFL++ и LibFuzzer заменили тяжёлый SMT-анализ сверхбыстрой обратной связью по покрытию рёбер.
flowchart LR
SeedQueue["Очередь сидов (Seed Corpus)<br/>Отобранные 'интересные' входы"] --> NextInput["1. Выбор сида<br/>(Расписание AFLFast / Entropic)"]
NextInput --> Mutator["2. Генетический мутатор<br/>(Bitflips, Arith, CmpLog, Havoc, Splice)"]
Mutator --> ForkServer["3. Сверхбыстрое исполнение<br/>(Forkserver / Persistent Mode)"]
ForkServer --> Bitmap["4. Разделяемая битовая карта 64 КБ<br/>(Учёт переходов по рёбрам CFG)"]
ForkServer --> Sanitizer{"Краш или ловушка<br/>Sanitizer?"}
Sanitizer -- "Да (SIGSEGV / ASan)" --> Triage["Триаж и минимизация<br/>(Stack Hash & ddmin)"]
Sanitizer -- "Нет" --> NewCov{"Новое ребро CFG или<br/>новая корзина счётчика?"}
Bitmap --> NewCov
NewCov -- "Да (Рост приспособленности!)" --> AddQueue["Добавление в очередь сидов<br/>(Родитель для следующего поколения)"]
AddQueue --> SeedQueue
NewCov -- "Нет (Известный путь)" --> Discard["Немедленное отбрасывание входа"]
Глава 3: Внутри движка AFL — инструментация рёбер, битовая карта 64 КБ и математика >> 1
3.1 Почему покрытие рёбер (Edge Coverage) лучше покрытия базовых блоков
Если бы фаззер отмечал только посещённые базовые блоки (узлы), то после пути $A \to B \to C$ все блоки $\{A, B, C\}$ считались бы пройденными. Когда второй вход пропускает блок инициализации $B$ и идёт напрямую по ребру $A \to C$ (вызывая разыменование NULL в $C$), множество узлов $\{A, C\} \subset \{A, B, C\}$ показалось бы избыточным, и фаззер выбросил бы этот вход! Отслеживая направленные пары переходов (рёбра) $(B_{\text{prev}} \to B_{\text{cur}})$, AFL мгновенно видит, что переход $(A \to C)$ является новым.
3.2 Трамплин из 3 строк, 64 КБ в кэше L1 и необходимость сдвига >> 1
При компиляции AFL вставляет в начало каждого базового блока три операции:
| |
Размер shared_mem равен ровно $2^{16} = 65\,536$ байт (64 КБ), так как 64 КБ целиком помещаются в кэш данных L1/L2 ядра процессора, исключая промахи кэша в DRAM и сокращая время записи до ~1 наносекунды.
Почему в третьей строке prev_location = cur_location >> 1; математически необходим побитовый сдвиг вправо на 1 бит (>> 1)? Без него операция XOR ($\oplus$) страдала бы от двух фатальных алгебраических вырождений:
- Потеря направления из-за коммутативности ($A \oplus B = B \oplus A$): Переход $A \to B$ и обратный переход $B \to A$ давали бы один и тот же индекс.
- Уничтожение петель из-за нильпотентности ($A \oplus A = 0$): Любой цикл из блока в самого себя ($A \to A$) давал бы $0$, сталкивая все самопетли программы в единственный байт
shared_mem[0]!
Сдвиг >> 1 разрушает симметрию и нильпотентность за один такт CPU. Кроме того, счётчики попаданий квантуются в 8 логарифмических корзин ($1, 2, 3, 4\text{–}7, 8\text{–}15, 16\text{–}31, 32\text{–}127, 128\text{–}255$), позволяя отслеживать прогресс раскрутки циклов.
3.3 Forkserver, Persistent Mode и марковские расписания энергии (AFLFast / Entropic)
Чтобы убрать накладные расходы execve() и динамического компоновщика (ld.so), Forkserver замораживает процесс перед main() и клонирует дочерние процессы только через Copy-on-Write (fork()) по каналам fd 198/199. В режиме Persistent Mode (LLVMFuzzerTestOneInput) устраняется даже fork() — целевая функция вызывается в цикле внутри одного процесса со скоростью свыше $100\,000\text{ запусков/с}$. В сочетании с CmpLog (RedQueen) и планировщиками AFLFast / Entropic на базе цепей Маркова и информационной энтропии Шеннона $H(s)$ фаззер направляет основную энергию на редкие ветви:
Глава 4: Превращение скрытой порчи памяти в мгновенный краш — архитектура санитайзеров
Поскольку аппаратный блок управления памятью (MMU) проверяет права доступа только с точностью до страниц по $4\text{ КБ}$, переполнение буфера в куче на 1 байт или Use-After-Free почти никогда не вызывают SIGSEGV. AddressSanitizer (ASan) решает эту проблему с помощью отображения теневой памяти (Shadow Memory) в масштабе 1:8:
Каждые выровненные 8 байт памяти приложения отображаются в 1 теневой байт: 0x00 означает доступность всех 8 байт, $1 \le k \le 7$ — доступность только первых $k$ байт, а отрицательные коды помечают отравленные красные зоны (Redzones: 0xfa в куче, 0xf1 на стеке) или освобождённую память в карантинной очереди (0xfd для поимки Use-After-Free). Перед каждым обращением к памяти компилятор вставляет проверку:
| |
Вместе с UBSan (целочисленные переполнения и неопределённое поведение), MSan (побитовое отслеживание неинициализированной памяти), TSan (обнаружение гонок данных через векторные часы) и дифференциальным фаззингом санитайзеры превращают малейшее нарушение инвариантов в мгновенный отчёт об ошибке.
Глава 5: Триаж крашей и дельта-отладка (ddmin) Андреаса Целлера
Тысячи дублирующихся крашей сначала группируются по корневой причине с помощью хэширования стека вызовов (Stack Hash) верхних фреймов в момент аварии. Затем, чтобы сжать многомегабайтный аварийный файл $c$ (длины $N$), переполненный мутационным шумом, до крошечного примера, применяется алгоритм дельта-отладки (ddmin) Андреаса Целлера, находящий 1-минимальный тест $c^*$, из которого нельзя удалить ни одного байта без исчезновения ошибки:
Разбивая вход на $n$ кусков $\Delta_1, \dots, \Delta_n$ (начиная с $n=2$), ddmin сначала проверяет каждое подмножество $\Delta_i$, затем каждое дополнение $\nabla_i = c \setminus \Delta_i$, и удваивает дробность разбиения $n \leftarrow \min(2n, |c|)$, если прямое сокращение не удалось. Благодаря этому обобщённому двоичному поиску ddmin (afl-tmin и C-Reduce) сжимает 10-мегабайтный файл до нескольких байт минимального PoC всего за $\mathcal{O}(\log_2 N)$ шагов в лучшем случае.
