Фаззинг Linux через WTF
Недавно появился фаззер What The Fuzz, который (кроме названия) интересен тем, что это:
- blackbox фаззер;
- snapshot-based фаззер.
То есть он может исследовать бинарь без исходников на любом интересном участке кода.
Например, сам автор фаззера натравил WTF на Ida Pro и нашел там кучу багов. Благодаря подходу с snapshot’ами, WTF умеет работать с самыми тяжелыми приложениями.
Ключевые особенности WTF, на которые стоит обратить внимание:
- работает только с бинарями под x86;
- запустить фаззинг можно и на Linux, и на Windows;
- исследуемым бинарем может быть только бинарь под Windows.
Получается, нельзя фаззить ELF?
На самом деле, можно. Просто нет инструкции, как сделать snapshot для Linux. Все-таки главная мишень — это программы для Windows.
Эта статья появилась из желания обойти это ограничение.
Содержание
1. Чем WTF лучше AFL
2. Как работает WTF с таргетом для Windows
3. Как сделать снимок через gdb
3.1. Виртуальная память
3.2. Физическая память
3.3 Процессор
4. Пример
4.1. Границы фаззинга symbol-store.json
4.2. Дамп памяти mem.dmp
4.3. Дамп процессора regs.json
4.4. Покрытие stackoverflow.cov
4.5. Фаззер
5. Вывод
Прежде всего вспомним, что в blackbox умеет и AFL. Тогда зачем мучиться, когда есть готовое решение?
Чем WTF лучше AFL
У WTF есть выгодное отличие — это snapshot-based фаззер, то есть он использует виртуальную машину на основе снимка всей системы с запущенным внутри бинарем в нужном состоянии.
Из этого следуют два вывода:
- не нужно пересоздавать процесс на каждой итерации;
- можно начать фаззить откуда угодно, с любой инструкции;
- можно фаззить kernel-space: ядро, драйверы.
А кроме этого, WTF дает полный контроль над процессором и памятью.
Представим, что есть функция vuln(void* buf, int size)
, и в снимке сохранено состояние всей ОС на момент вызова функции в исследуемом процессе.
mov rsi, rdx
mov rdi, rax
call vuln <== rip
Контроль над процессором и памятью позволяет подменять данные в буффере buf
и наблюдать, что будет происходить.
Для этого нужно написать фаззер, который по адресу rdi
будет засовывать мусор, а в регистр rsi
— писать размер этих данных. После чего WTF снимет систему из снимка с паузы и узнает, что изменилось.
Раз уж начали, посмотрим тогда, как работать с WTF.
Как работает WTF с таргетом для Windows
Процесс работы выглядит так:
- юзер запускает ОС и исследуемую программу в ней;
- через дебаггер KD останавливает систему на желаемой инструкции внутри программы;
- снимает состояние процессора и дамп памяти через скрипты для KD, то есть делает снимок ОС;
- пишет фаззер, в котором определяет адреса, на которых фаззинг должен остановиться (подробнее об этом ниже);
- запускает фаззер.
Юзер делает очень много работы, а WTF просто создает виртуальную машину через Hyper-V/KVM/Bochscpu на основе дампа и состояния процессора и запускает ее в тех границах и с теми модификациями памяти и процессора, которые определил юзер.
Тут, кстати, и становится понятно, что WTF все-таки справится с ELF: WTF можно запустить на Linux, и на борту есть поддержка KVM.
Автор ограничился Windows, потому что есть понятный способ сделать снимок системы — через KD.
А на Linux есть gdb, можно ли сделать снимок через него?
Как сделать снимок через gdb
Что потребуется:
- ядро, собранное с отладочной информацией;
- виртуальная машина (без kaslr, aslr — так просто удобнее) с этим ядром и исследуемым бинарем;
- qemu, тоже с отладочной информацией.
gdb запустит qemu, а qemu — виртуальную машину. Такая схема и позволит сделать снимок. Чтобы не запутаться, надо помнить, что есть две виртуальные машины — одна (qemu) для того, чтобы сделать снимок, ее создает юзер, и вторая (KVM) — для фаззинга, ее создает WTF.
Отладочная информация нужна, чтобы можно было вытягивать информацию из gdb о ядре и процессоре.
От qemu нужно получить значения всех регистров CPU на момент исполнения интересующей инструкции.
От ядра — получить структуру task_struct
, которая описывает процесс.
Эта структура поможет решить проблему с виртуальной памятью.
Виртуальная память
Не все страницы виртуальной памяти процесса находятся в RAM, часть свопнута на диск.
А когда WTF создает свою виртуалку, то там есть только два девайса — CPU и RAM, больше ничего, диска нет. При обращении к отсутствующей странице произойдет исключение, ядро не найдет ее на диске, потому что никакого диска вообще нет, и завершит процесс. При фаззинге это наблюдалось бы в росте таймаутов.
Поэтому перед дампом необходимо «прокликать» все страницы всех мэппов, показав ядру, что их стоит держать в RAM.
Для этого нужно:
- узнать границы всех мэппов;
- где-то разместить какой-нибудь такой код:
.init:
push rdx
push rdi
push rsi
mov rdi, START
mov rsi, END
.loop:
mov rdx, byte [rdi]
add rdi, 0x1000
cmp rdi, rsi
jl .loop
.restore:
pop rsi
pop rdi
pop rdx
- передать туда управление столько раз, сколько есть мэппов в процессе, каждый раз меняя START, END — это границы мэппа.
Возможно, есть более простой путь. Например, сискол mlockall
вроде бы предназначен для этой же задачи, судя по описанию. Но через него не получается решить вопрос даже с нужной capability
CAP_IPC_LOCK
. Поэтому — цикл.
Реализовать подход с циклом позволит кастомный брейк в gdb на адресе call vuln
.
Обработчик брейка должен будет:
- через структуру
task_struct
получить все мэппинги, кроме guard page и прочих ненужных страниц; - проставить права rwx, чтобы можно было модифицировать код (на самом деле, rwx нужен только для одного мэппа, но проще не задумываться и сменить всем);
- записать указанный выше код прямо перед
call vuln
с очередными START, END адресами; - передать управление на этот код;
- после того, как цикл отработает, управление снова попадает на
call vuln
, снова срабатывает брейк; - обработчик меняет адреса START, END, передает управление опять в цикл, и так делает, пока есть необработанные мэппы.
Вот таким образом можно добиться, чтобы все страницы виртуальной памяти процесса были в RAM. После этого уже можно делать дамп памяти всей системы.
Физическая память
Сохранить физическую память в qemu очень просто — нужно воспользоваться монитором qemu:
ctrl+alt+2
— вызов монитора;pmemsave 0 0xffffffff raw
— сохранить память в файлraw
.
Однако есть нюанс — WTF будет ожидать дамп в формате dmp
— все-таки WTF заточен под Windows — и надо что-то с этим сделать.
Формат dmp
не очень сложный в контексте WTF, сделать конвертер оказалось простой задачей. Просто несколько захардкоженных значений, единичный битмап размером количество страниц памяти / 8
и дальше уже чистый дамп из qemu.
Результатом всех манипуляций будет дамп памяти в формате dmp
со всеми нужными для процесса страницами.
Остается разобраться с процессором.
Процессор
Состояние процессора легко найти в процессе qemu.
Оно описывается через стуктуру CPUState*
(поле env_ptr
), и переменную такого типа можно найти в функции cpu_exec
.
Если сделать одноразовый кастомный брейк на этой функции и запомнить CPUState* cpu
, то по этому адресу можно будет найти любые регистры в любой момент времени.
Неожиданно получилось так, что WTF считает невалидными значения некоторых регистров, а некоторые вообще не видит. Поэтому пришлось пропатчить WTF (коммит e278c942848f2e211904320ff804df4ccb6fd7f8
).
Функция bool SanitizeCpuState (CpuState_t &CpuState), удалить проверку:
for (Seg_t *Seg : Segments) {
if (Seg->Reserved != ((Seg->Limit >> 16) & 0xF)) {
fmt::print("Segment with selector {:x} has invalid attributes.\n",
Seg->Selector);
return false;
}
}
Метод bool KvmBackend_t: LoadSregs (const CpuState_t &CpuState), проблема с cs
, заменить SEG(cs, Cs)
на:
Run_->s.regs.sregs.cs = {
.base = 0,
.limit = 0xffffffff,
.selector = CpuState.Cs.Selector,
.type = uint8_t(CpuState.Cs.SegmentType),
.present = uint8_t(CpuState.Cs.Present),
.dpl = uint8_t(CpuState.Cs.DescriptorPrivilegeLevel),
.db = 0,
.s = uint8_t(CpuState.Cs.NonSystemSegment),
.l = 1,
.g = 1,
.avl = 0,
};
В чем именно проблема — неизвестно, но в qemu точно правильные значения, поэтому все под нож.
Правки можно не вносить самостоятельно, все есть в этом форке, там же и пример фаззинга ELF.
Пример
Стоит заметить, что это user-space, поэтому скрипты заточены под это.
У WTF такая организация рабочего процесса:
dir/
coverage/ - хранит файл с относительными адресами базовых блоков бинаря
crashes/ - здесь будут хранится крэши
harness/ - какой-то harness, неважно
inputs/ - корпус входных данных
outputs/ - кейсы, которые обнаруживают новое покрытие
state/ - файлы regs.json, symbol-store.json, mem.dmp
trace/ - какой-то trace, неважно
Все папки должны быть, это захардкоженные пути.
В качестве примера будет бинарь example/stackoverflow
. Все необходимые скрипты, бинари, образы — в том же репозитории.
Тяжелые файлы лежат отдельно тут:
archlinux-root-123.tar.xz
— образ диска для qemu, должен лежать вexample/archlinux-root-123.qcow2
;vmlinux-5.17.4-arch1.tar.xz
— ядро,example/vmlinux-5.17.4-arch1
;mem.dmp.tar.xz
— дамп,example/stackoverflow/fuzzer/state/mem.dmp
.
Чего в репозитории нет, так это собранного с отладкой qemu, оно несложно собирается.
git clone https://github.com/qemu/qemu && \
cd qemu && \
mkdir build && \
cd build && \
CXXFLAGS="-g" \
CFLAGS="-g" \
../configure \
--cpu=x86_64 \
--target-list="x86_64-softmmu x86_64-linux-user" && \
make
Границы фаззинга symbol-store.json
Как упоминалось выше, необходимо определить границы потока исполнения, чтобы WTF различал, какое поведение нормальное, а какое — аварийное, и где вообще остановить фаззинг. В случае с переполняхой на стеке границы будут такие:
rip
— это начало, эта граница уже хранится в снятом состоянии процессора, определять не нужно;- адрес инструкции сразу за
call vuln
— место, где происходит нормальное завершение; - адрес инструкции
call ___stack_chk_fail
внутри функцииvuln
— место, где происходит детект порчи канарейки стека.
Адреса записывают в state/symbol-store.json
.
Выглядит так: "stop":"0x555555555272", "stack_chk_failed":"0x5555555551ca"
Границы очень важны, нужно учесть все варианты. Если этого не сделать, поток убежит, фаззер не остановится до таймаута.
Это простой пример, для чего-то большего и границ будут больше:
- обработчик деления на ноль
asm_exc_divide_error
; - обработчики
asm_exc_page_fault
,page_fault_oops
; force_sigsegv
- …
Более того, учитывая, что ядро собирается с оптимизациями, придется экспериментальным путем выяснять актуальные адреса для всех этих функций. Интересный таргет будет того стоить.
Дамп памяти mem.dmp
Понадобятся два инстанса gdb, первый — в режиме удаленной отладки, он запускает qemu, и через второй подключаемся к первому. Удобно сделать это через tmux и эти скрипты:
tmux-pane1:
./gdb_server.sh
tmux-pane2:
./gdb_connect.sh stackoverflow
В qemu запускаем бинарь, вводим 123, срабатывает брейк, который готовит виртуальную память.
Далее — переход на системный монитор qemu, который вызывается через ctrl+alt+2
, и команда pmemsave 0 0xffffffff
сохранит дамп в файл raw
.
./convert.sh
Этот скрипт вызовет наколеночный конвертер raw2dmp
, который сделает mem.dmp
из raw
файла.
Дамп процессора regs.json
Сейчас виртуальная машина замерла на выбранной инструкции call vuln
, осталось сдампить процессор.
В первом инстансе gdb жмем ctrl+c
, набираем кастомную команду cpu
. Эта команда найдет CPUState*
структуру в памяти qemu и вытащит все регистры в regs.json
.
Теперь есть все файлы, которые описывают состояние ОС — regs.json
, symbol-store.json
, mem.dmp
. Можно останавливать qemu, gdb.
Покрытие stackoverflow.cov
В качестве инструментации WTF использует относительные адреса базовых блоков бинаря.
Их можно получить из Ida с помощью скрипта gen_cov.py:
- загрузить бинарь;
File -> Script file
Появится файл stackoverflow.cov
, его место — в example/stackoverflow/fuzzer/state
.
Фаззер
Фаззер можно посмотреть здесь. Ничего сложного.
Здесь границы:
bool Init(const Options_t &Opts, const CpuState_t &) {
if (!g_Backend->SetBreakpoint("stop", [](Backend_t *Backend) {
Backend->Stop(Ok_t());
})) {
DebugPrint("Failed to SetBreakpoint stop\n");
return false;
}
if (!g_Backend->SetBreakpoint("stack_chk_failed", [](Backend_t *Backend) {
Backend->Stop(Crash_t("crash"));
})) {
DebugPrint("Failed to SetBreakpoint stack_chk_failed\n");
return false;
}
return true;
}
Тут вставляется новая порция мусора:
bool InsertTestcase(const uint8_t *Buffer, const size_t BufferSize) {
const Gva_t Rdi = Gva_t(g_Backend->GetReg(Registers_t::Rdi));
if (!g_Backend->VirtWrite(Rdi, Buffer, BufferSize, true)) {
DebugPrint("Failed to write next testcase!");
return false;
}
g_Backend->SetReg(Registers_t::Rsi, BufferSize);
return true;
}
Неприятная особенность WTF: модуль с фаззером становится частью инструмента. WTF нужно пересобирать каждый раз, когда появляются правки.
Чтобы запустить фаззер:
tmux-pane1:
./run.master
tmux-pane2:
sudo ./run.worker
Мастер запускает сокет-сервер на выбранном порту, воркеры подлкючаются к нему за новыми тест-кейсами и отправляют статистику по ним обратно мастеру.
It works!
Вывод
What The Fuzz — мощный инструмент, и требует от юзера значительных усилий по настройке. Еще больше нужно для настройки фаззинга Linux, потому что WTF по дефолту его не поддерживает. Все равно WTF стоит попробовать в случае изучения тяжелых приложений, ядра или драйверов. Эта статья была о том, как преодолеть ограничение по Linux и что такое snapshot-based подход в фаззинге.