Практикум: исследование C++ приложения с помощью libFuzzer
Цель работы: освоить базовый цикл in-process фаззинга на примере простой C++ функции: подготовить harness для libFuzzer, собрать цель с санитайзерами, запустить фаззинг, воспроизвести найденное падение, понять первопричину и предложить исправление.
1. Что изучается в работе
| Тема | Практический результат |
|---|---|
| libFuzzer | Слушатель запускает фаззер через функцию LLVMFuzzerTestOneInput. |
| AddressSanitizer и UndefinedBehaviorSanitizer | Слушатель получает диагностический отчет о выходе за пределы стекового массива. |
| Корпус входных данных | Слушатель создает минимальные seed-файлы и наблюдает рост покрытия. |
| Root cause analysis | Слушатель связывает crash-файл с конкретной ошибкой в проверке длины. |
2. Требования к окружению
Рекомендуемая среда: Ubuntu Linux, WSL2 с Ubuntu или другая Linux-среда с Clang. В проекте уже есть файл src/main_libfuzzer.cpp.
sudo apt update sudo apt install -y clang build-essential
Проверьте компилятор:
clang++ --version
-fsanitize=fuzzer.
src/main_libfuzzer.cpp поставляется в архиве с примером, поэтому его нужно распаковать: sudo apt install -y unzip, затем unzip my-first-python-project-v02.zip. На Ubuntu 24.04 LTS штатный Clang — версии 18, этого достаточно.
3. Исходный учебный код
Файл задания: src/main_libfuzzer.cpp. Он содержит функцию разбора простого бинарного формата и harness для libFuzzer.
#include <cstdint>
#include <cstddef>
#include <iostream>
static int parse_demo_format(const uint8_t* data, size_t size) {
if (size < 5) {
return 0;
}
if (data[0] != 'F' || data[1] != 'U' || data[2] != 'Z' || data[3] != '1') {
return 0;
}
uint8_t declared_size = data[4];
char name[16] = {};
for (uint8_t i = 0; i < declared_size && 5 + i < size; ++i) {
name[i] = static_cast<char>(data[5 + i]);
}
if (name[0] == 'L' && name[1] == 'I' && name[2] == 'B') {
std::cout << "interesting libFuzzer input\n";
}
return 0;
}
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
parse_demo_format(data, size);
return 0;
}
3.1. Формат входных данных
| Смещение | Поле | Назначение |
|---|---|---|
0..3 |
FUZ1 |
Magic-значение, по которому программа понимает, что файл относится к ожидаемому формату. |
4 |
declared_size |
Заявленная длина имени. Значение полностью контролируется входным файлом. |
5..N |
name |
Данные, которые программа копирует в локальный буфер char name[16]. |
declared_size ограничивает цикл копирования, но не ограничивается размером массива name.
4. Подготовка корпуса
Создайте директории для сборки, корпуса и результатов:
mkdir -p build corpus findings
Создайте два начальных seed-файла. Первый проходит проверку magic, второй показывает фаззеру альтернативный короткий вход:
printf 'FUZ1\x01A' > corpus/seed1 printf 'hello' > corpus/seed2
Проверьте содержимое:
xxd -g 1 corpus/seed1 xxd -g 1 corpus/seed2
FUZ1 полезнее случайной строки для быстрого поиска глубокой ветки кода.
5. Сборка цели для libFuzzer
Соберите fuzz target с libFuzzer, AddressSanitizer и UndefinedBehaviorSanitizer:
clang++ -std=c++17 -g -O1 \ -fsanitize=fuzzer,address,undefined \ src/main_libfuzzer.cpp \ -o build/main_libfuzzer
Пояснение ключевых флагов:
| Флаг | Что делает |
|---|---|
-fsanitize=fuzzer |
Подключает libFuzzer runtime и добавляет собственный main фаззера. |
-fsanitize=address |
Находит ошибки памяти: выход за границы буфера, use-after-free и похожие дефекты. |
-fsanitize=undefined |
Находит часть случаев неопределенного поведения C/C++. |
-g -O1 |
Сохраняет отладочную информацию и оставляет оптимизацию, приемлемую для fuzzing-сборки. |
main(), если используется -fsanitize=fuzzer. Точкой входа является LLVMFuzzerTestOneInput.
6. Запуск фаззинга
Запустите короткую fuzz-сессию:
./build/main_libfuzzer \ -max_total_time=30 \ -artifact_prefix=findings/ \ corpus/
Что означают параметры:
| Параметр | Назначение |
|---|---|
-max_total_time=30 |
Остановить запуск через 30 секунд. Для занятия удобно ограничивать время. |
-artifact_prefix=findings/ |
Сохранять crash-файлы и другие артефакты в отдельную директорию. |
corpus/ |
Директория с начальными seed-файлами и расширяющимся корпусом. |
-artifact_prefix должна существовать до запуска. Если её нет, libFuzzer не начинает работу и печатает ERROR: The required directory "findings/" does not exist. Поэтому в разделе 4 создаётся findings, а перед повторным прогоном в разделе 11 — findings_after_fix.
Типичный фрагмент вывода при обнаружении ошибки:
runtime error: index 16 out of bounds for type 'char[16]'
ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 1
#0 ... parse_demo_format ... src/main_libfuzzer.cpp
#1 ... LLVMFuzzerTestOneInput ... src/main_libfuzzer.cpp
Test unit written to findings/crash-<hash>
findings/, а отчет ASan указывает на строку записи в name[i].
7. Воспроизведение crash
После нахождения ошибки libFuzzer сохраняет конкретный входной файл. Запустите fuzz target на этом файле напрямую:
ls -l findings/ ./build/main_libfuzzer findings/crash-*
Ожидаемый результат: программа снова падает с тем же типом ошибки. Это важный этап: найденный фаззером дефект должен быть воспроизводимым.
Посмотрите байты crash-файла:
xxd -g 1 findings/crash-*
46 55 5a 31. Это ASCII-строка FUZ1. Затем определите значение пятого байта и объясните, почему оно приводит к переполнению.
8. Минимизация входа
libFuzzer умеет уменьшать crash-вход, сохраняя воспроизведение ошибки. Это упрощает анализ.
mkdir -p minimized ./build/main_libfuzzer \ -minimize_crash=1 \ -runs=10000 \ findings/crash-* \ -artifact_prefix=minimized/
Если минимизация завершилась успешно, сравните размер исходного и минимизированного файла:
ls -lh findings/ minimized/ xxd -g 1 minimized/crash-*
9. Анализ первопричины
Проблемный участок:
uint8_t declared_size = data[4];
char name[16] = {};
for (uint8_t i = 0; i < declared_size && 5 + i < size; ++i) {
name[i] = static_cast<char>(data[5 + i]);
}
Условие 5 + i < size проверяет только безопасность чтения из входного буфера. Оно отвечает на вопрос: есть ли еще байт во входных данных?
Но в условии нет проверки безопасности записи в name. Для массива char name[16] допустимы индексы 0..15. Когда i == 16, запись name[i] выходит за пределы массива.
10. Исправление
Исправление должно учитывать три ограничения: заявленную длину, доступные байты входа и размер буфера назначения.
#include <algorithm>
uint8_t declared_size = data[4];
char name[16] = {};
size_t available_size = size - 5;
size_t copy_size = std::min<size_t>(
static_cast<size_t>(declared_size),
std::min(sizeof(name), available_size)
);
for (size_t i = 0; i < copy_size; ++i) {
name[i] = static_cast<char>(data[5 + i]);
}
После исправления пересоберите target и проверьте старый crash-файл:
clang++ -std=c++17 -g -O1 \ -fsanitize=fuzzer,address,undefined \ src/main_libfuzzer.cpp \ -o build/main_libfuzzer ./build/main_libfuzzer findings/crash-*
11. Повторная проверка после исправления
Создайте директорию для артефактов повторного прогона (libFuzzer требует, чтобы она уже существовала) и запустите фаззинг на более длительное время:
mkdir -p findings_after_fix ./build/main_libfuzzer \ -max_total_time=60 \ -artifact_prefix=findings_after_fix/ \ corpus/
Для учебной работы достаточно зафиксировать результат: падений нет в течение заданного времени или найден новый дефект, который требует отдельного анализа.
12. Что нужно сдать преподавателю
- Команду сборки fuzz target.
- Команду запуска libFuzzer.
- Фрагмент отчета ASan с типом ошибки и строкой исходного кода.
- Hexdump crash-файла.
- Краткое объяснение первопричины дефекта.
- Исправленный фрагмент кода.
- Результат повторной проверки после исправления.
13. Контрольные вопросы
- Почему libFuzzer требует функцию
LLVMFuzzerTestOneInput? - Чем in-process fuzzing отличается от запуска программы через файлы и аргументы командной строки?
- Почему seed
FUZ1\x01Aпомогает быстрее пройти проверку magic? - Какую ошибку показывает AddressSanitizer в этом задании?
- Почему проверка
5 + i < sizeне защищает массивname? - Какие три ограничения нужно учитывать при безопасном копировании?
- Зачем воспроизводить crash отдельно от основной fuzz-сессии?
- Для чего нужна минимизация crash-файла?
14. Краткая памятка команд
sudo apt install -y clang build-essential unzip mkdir -p build corpus findings printf 'FUZ1\x01A' > corpus/seed1 printf 'hello' > corpus/seed2 clang++ -std=c++17 -g -O1 \ -fsanitize=fuzzer,address,undefined \ src/main_libfuzzer.cpp \ -o build/main_libfuzzer ./build/main_libfuzzer -max_total_time=30 -artifact_prefix=findings/ corpus/ ./build/main_libfuzzer findings/crash-* xxd -g 1 findings/crash-* mkdir -p minimized ./build/main_libfuzzer -minimize_crash=1 -runs=10000 findings/crash-* -artifact_prefix=minimized/ mkdir -p findings_after_fix ./build/main_libfuzzer -max_total_time=60 -artifact_prefix=findings_after_fix/ corpus/
Версия методички: 2026-10-09. Учебный проект: main_libfuzzer.cpp. Команды проверены на WSL2 с Ubuntu 24.04.4 LTS, clang 18.1.3: цель собирается, падение находится в пределах 30 секунд, старый crash-файл перестаёт воспроизводиться после исправления.