Недавно появился фаззер 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 подход в фаззинге.

Комментарии (0)