Фаззинг 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 подход в фаззинге.


ссылка на оригинал статьи https://habr.com/ru/company/dsec/blog/664230/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *