В предыдущей части я приблизительно описал, как можно загрузить eBPF функции из ELF-файла. Теперь пришла пора перейти от фэнтези к советским мультикам, и следуя мудрому совету, потратив один раз некоторое количество усилий, сделать универсальный инструмент инструментации (или, сокращённо, УИИ!!!). При этом я воспользуюсь антипаттерном проектирования «Золотой молоток» и сооружу инструмент из относительно знакомого мне QEMU. Бонусом за это мы получим кросс-архитектурную инструментацию, а также инструментацию на уровне целого виртуального компьютера. Инструментация будет вида «небольшой нативный so-шничек + небольшой .o-файл с eBPF». При этом eBPF-функции будут подставляться перед соответствующими инструкциями внутреннего представления QEMU перед оптимизацией и кодогенерацией.
В итоге сама инструментация, добавляемая при кодогенерации (то есть, не считая пары килобайтов обычного сишного рантайма), выглядит вот так, и это не псевдокод:
#include <stdint.h>
extern uint8_t *__afl_area_ptr;
extern uint64_t prev;
void inst_qemu_brcond_i64(uint64_t tag, uint64_t x, uint64_t y, uint64_t z, uint64_t u)
{
__afl_area_ptr[((prev >> 1) ^ tag) & 0xFFFF] += 1;
prev = tag;
}
void inst_qemu_brcond_i32(uint64_t tag, uint64_t x, uint64_t y, uint64_t z, uint64_t u)
{
__afl_area_ptr[((prev >> 1) ^ tag) & 0xFFFF] += 1;
prev = tag;
}
Что же, пора загрузить нашего эльфа в Матрицу. Ну, как загрузить, скорее вмазать распылить.
Как уже упоминалось в статье про QEMU.js, один из режимов работы QEMU — это JIT-генерация хостового машинного кода из гостевого (потенциально, для совершенно другой архитектуры). Если в прошлый раз я реализовывал свой бекенд кодогенерации, то в этот раз я собираюсь обрабатывать внутреннее представление, вклинившись непосредственно перед оптимизатором. Произвольное ли это решение? Нет. Есть надежда, что оптимизатор срежет лишние углы, выкинет ненужные переменные и т.д. Насколько я понимаю, он, собственно, и занимается простыми и быстро выполнимыми вещами: проталкиванием констант, выкидыванием выражений вроде «x := x + 0» и удалением недостижимого кода. А уж его у нас может получиться порядочное количество.
Конфигурация сборочных скриптов
Первым делом давайте добавим наши файлы исходных текстов: tcg/bpf-loader.c
и tcg/instrument.c
в Makefile-ы. Вообще говоря, есть желание когда-нибудь запихнуть это в upstream, поэтому сделать в итоге нужно будет по уму, но пока я просто безусловным образом добавлю эти файлы в сборку. А параметры буду принимать в лучших традициях AFL — через переменные среды. Кстати, тестировать я это буду опять именно на инструментации для AFL.
Просто поищем упоминание «соседа» — файла optimize.c
с помощью grep -R
и ничего не найдём. Потому что искать надо было optimize.o
:
--- a/Makefile.target
+++ b/Makefile.target
@@ -110,7 +110,7 @@ obj-y += trace/
obj-y += exec.o
obj-y += accel/
obj-$(CONFIG_TCG) += tcg/tcg.o tcg/tcg-op.o tcg/tcg-op-vec.o tcg/tcg-op-gvec.o
-obj-$(CONFIG_TCG) += tcg/tcg-common.o tcg/optimize.o
+obj-$(CONFIG_TCG) += tcg/tcg-common.o tcg/optimize.o tcg/instrument.o tcg/bpf-loader.o
obj-$(CONFIG_TCG_INTERPRETER) += tcg/tci.o
obj-$(CONFIG_TCG_INTERPRETER) += disas/tci.o
obj-$(CONFIG_TCG) += fpu/softfloat.o
Так вот ты какое, метапрограммирование на C...
Для начала допишем bpf-loader.c
из прошлой серии кодом, выдёргивающим точки входа, соответствующие операциям QEMU. И поможет нам в этом загадочный файл tcg-opc.h
. Выглядит он так:
/*
* DEF(name, oargs, iargs, cargs, flags)
*/
/* predefined ops */
DEF(discard, 1, 0, 0, TCG_OPF_NOT_PRESENT)
DEF(set_label, 0, 0, 1, TCG_OPF_BB_END | TCG_OPF_NOT_PRESENT)
/* variable number of parameters */
DEF(call, 0, 0, 3, TCG_OPF_CALL_CLOBBER | TCG_OPF_NOT_PRESENT)
DEF(br, 0, 0, 1, TCG_OPF_BB_END)
// ...
Что за чепуха? А дело просто в том, что его не подключают в шапке исходника — нужно определить макрос DEF
, включить этот файл, и сразу удалить макрос. Видите, у него даже guard-ов нет.
static const char *inst_function_names[] = {
#define DEF(name, a, b, c, d) stringify(inst_qemu_##name),
#include "tcg-opc.h"
#undef DEF
NULL
};
В итоге мы получаем аккуратный массив из имён целевых функций, индексированный опкодами и заканчивающийся NULL, который мы можем пробежать для каждого символа в файле. Понимаю, что это не эффективно. Зато просто, что важно, учитывая единовременный характер этой операции. Далее мы просто пропускаем все символы, для которых
ELF64_ST_BIND(sym->st_info) == STB_LOCAL || ELF64_ST_TYPE(sym->st_info) != STT_FUNC
Остальные сверяем со списком.
Привязываемся к потоку выполнения
Теперь нужно встать где-то на потоке выполнения механизма кодогенерации, и ждать, пока мимо не проплывёт интересующая инструкция. Но для начала нужно определить свои функции instrumentation_init
, tcg_instrument
и instrumentation_shutdown
в файле tcg/tcg.h
и прописать их вызовы: инициализацию — после инициализации бекенда, инструментацию — прямо перед вызовом tcg_optimize
. Казалось бы, instrumentation_shutdown
можно повесить в instrumentation_init
на atexit
и не париться. Я тоже так думал, и оно, скорее всего, так и заработает в режиме эмуляции полной системы, а вот в режиме usermode-эмуляции QEMU транслирует системные вызовы exit_group
и иногда exit
в вызов функции _exit
, которая все эти atexit-handler-ы игнорирует, поэтому разыщем её в linux-user/syscall.c
и впишем перед ней вызов нашего кода.
Интерпретируем байткод
Вот и настало время почитать, что же нам сгенерировал компилятор. Это удобно сделать с помощью llvm-objdump
с параметром -x
, а лучше сразу -d -t -r
.
$ ./compile-bpf.sh
test-bpf.o: file format ELF64-BPF
Disassembly of section .text:
0000000000000000 inst_brcond_i64:
0: 18 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r2 = 0 ll
0000000000000000: R_BPF_64_64 prev
2: 79 23 00 00 00 00 00 00 r3 = *(u64 *)(r2 + 0)
3: 77 03 00 00 01 00 00 00 r3 >>= 1
4: 7b 32 00 00 00 00 00 00 *(u64 *)(r2 + 0) = r3
5: af 13 00 00 00 00 00 00 r3 ^= r1
6: 57 03 00 00 ff ff 00 00 r3 &= 65535
7: 18 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r4 = 0 ll
0000000000000038: R_BPF_64_64 __afl_area_ptr
9: 79 44 00 00 00 00 00 00 r4 = *(u64 *)(r4 + 0)
10: 0f 34 00 00 00 00 00 00 r4 += r3
11: 71 43 00 00 00 00 00 00 r3 = *(u8 *)(r4 + 0)
12: 07 03 00 00 01 00 00 00 r3 += 1
13: 73 34 00 00 00 00 00 00 *(u8 *)(r4 + 0) = r3
14: 7b 12 00 00 00 00 00 00 *(u64 *)(r2 + 0) = r1
15: 95 00 00 00 00 00 00 00 exit
0000000000000080 inst_brcond_i32:
16: 18 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r2 = 0 ll
0000000000000080: R_BPF_64_64 prev
18: 79 23 00 00 00 00 00 00 r3 = *(u64 *)(r2 + 0)
19: 77 03 00 00 01 00 00 00 r3 >>= 1
20: 7b 32 00 00 00 00 00 00 *(u64 *)(r2 + 0) = r3
21: af 13 00 00 00 00 00 00 r3 ^= r1
22: 57 03 00 00 ff ff 00 00 r3 &= 65535
23: 18 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r4 = 0 ll
00000000000000b8: R_BPF_64_64 __afl_area_ptr
25: 79 44 00 00 00 00 00 00 r4 = *(u64 *)(r4 + 0)
26: 0f 34 00 00 00 00 00 00 r4 += r3
27: 71 43 00 00 00 00 00 00 r3 = *(u8 *)(r4 + 0)
28: 07 03 00 00 01 00 00 00 r3 += 1
29: 73 34 00 00 00 00 00 00 *(u8 *)(r4 + 0) = r3
30: 7b 12 00 00 00 00 00 00 *(u64 *)(r2 + 0) = r1
31: 95 00 00 00 00 00 00 00 exit
SYMBOL TABLE:
0000000000000000 l df *ABS* 00000000 test-bpf.c
0000000000000000 l d .text 00000000 .text
0000000000000000 *UND* 00000000 __afl_area_ptr
0000000000000080 g F .text 00000080 inst_brcond_i32
0000000000000000 g F .text 00000080 inst_brcond_i64
0000000000000008 g O *COM* 00000008 prev
Если попробовать поискать описание опкодов eBPF, то обнаруживается, что в очевидных местах (исходниках и man-страницах ядра Linux) есть описания, как это использовать, как компилировать и т.д. После чего натыкаешься на страничку команды инструмента iovisor с удобным неофициальным справочником по eBPF.
Инструкция занимает одно 64-битное слово (некоторые — два) и имеет вид
struct {
uint8_t opcode;
uint8_t dst:4;
uint8_t src:4;
uint16_t offset;
uint32_t imm;
};
Те, что занимают два слова, просто состоят из первой инструкции со всей логикой и «прицепа» с ещё 32-мя битами immediate-значения и очень хорошо заметны на objdump-овском дизассемблере.
Сами опкоды тоже имеют регулярную структуру: младшие три бита — это класс операции: 32-битное ALU, 64-битное ALU, load/store, условные переходы. Поэтому их очень удобно в лучших традициях QEMU реализовывать на макросах. Не буду проводить подробную инструкцию по кодовой базе мы не на code review, лучше расскажу про подводные камни.
Первая моя проблема заключалась в том, что я сделал ленивый аллокатор регистров eBPF в виде QEMU-вских local_temp
, и стал бездумно передавать вызов этой функции в макрос. Получилось как в известном меме: «Мы вставили тебе абстракцию в абстракцию, чтобы ты мог генерировать инструкцию, пока генерируешь инструкцию». Post factum я уже сейчас плохо понимаю, что тогда сломалось, но с порядком генерируемых инструкций, по-видимому, творилось нечто странное. После этого я сделал аналоги функций tcg_gen_...
для запихивания новых инструкций в середину списка, принимающих операнды как аргументы функции, и порядок стал автоматически таким, как надо (поскольку аргументы полностью вычисляются ровно один раз перед вызовом).
Вторая проблема заключалась в попытках запихнуть TCG const как операнд произвольной инструкции при виде immediate операнда в eBPF. Суля по уже упоминавшемуся tcg-opc.h
, состав списка аргументов операции строго фиксирован: n
входных аргументов, m
выходных и k
константных. Кстати, при отладке подобного кода очень помогает передать QEMU аргумент командной строки -d op,op_opt
или даже -d op,op_opt,out_asm
.
$ ./x86_64-linux-user/qemu-x86_64 -d help
Log items (comma separated):
out_asm show generated host assembly code for each compiled TB
in_asm show target assembly code for each compiled TB
op show micro ops for each compiled TB
op_opt show micro ops after optimization
op_ind show micro ops before indirect lowering
int show interrupts/exceptions in short format
exec show trace before each executed TB (lots of logs)
cpu show CPU registers before entering a TB (lots of logs)
fpu include FPU registers in the 'cpu' logging
mmu log MMU-related activities
pcall x86 only: show protected mode far calls/returns/exceptions
cpu_reset show CPU state before CPU resets
unimp log unimplemented functionality
guest_errors log when the guest OS does something invalid (eg accessing a
non-existent register)
page dump pages at beginning of user mode emulation
nochain do not chain compiled TBs so that "exec" and "cpu" show
complete traces
trace:PATTERN enable trace events
Use "-d trace:help" to get a list of trace events.
Ну так вот, не повторяйте моих ошибок: дизассемблер внутренних инструкций довольно продвинутый, и если вы видите в нём что-то вроде add_i64 loc15,loc15,$554412123213
, то вот эта штуковина после знака доллара — вот это нифига не указатель. Точнее, это, конечно, указатель, но, возможно, обвешанный флажками и в роли литерального значения операнда, а не указателя. Всё это применимо, естественно, если вы знаете, что там должно быть какое-то конкретное число, вроде $0
или $ff
, так-то вообще не надо бояться указателей. :) Как с этим побороться — просто нужно создать функцию, которая вернёт свежий temp
, в который через movi
положит нужную константу.
Кстати, если в шапке tcg/tcg.c
закомментировать #define USE_TCG_OPTIMIZATIONS
, то, внезапно, оптимизация отключится, и будет проще анализировать преобразования кода.
За сим я отправлю заинтересованного в ковырянии QEMU читателя в документацию, даже официальную! А остальным я продемонстрирую обещанную инструментацию для AFL.
Те же и кролик
За полным текстом runtime я, опять же, отошлю читателя в репозиторий, поскольку он (текст) не представляет художественной ценности и честно стырен из qemu_mode
из поставки AFL, и вообще, представляет из себя обычный кусок кода на C. А вот как выглядит сама инструментация:
#include <stdint.h>
extern uint8_t *__afl_area_ptr;
extern uint64_t prev;
void inst_qemu_brcond_i64(uint64_t tag, uint64_t x, uint64_t y, uint64_t z, uint64_t u)
{
__afl_area_ptr[((prev >> 1) ^ tag) & 0xFFFF] += 1;
prev = tag;
}
void inst_qemu_brcond_i32(uint64_t tag, uint64_t x, uint64_t y, uint64_t z, uint64_t u)
{
__afl_area_ptr[((prev >> 1) ^ tag) & 0xFFFF] += 1;
prev = tag;
}
Важно, чтобы у функций-хуков было столько же аргументов, сколько iargs
у соответствующей операции QEMU. Два extern
-а в шапке будут прилинкованы к рантайму в процессе релокации. В принципе, prev
можно было бы прямо здесь и определить, но тогда его нужно определить как static
, иначе он упадет в не поддерживаемую мной секцию COMMON. Собственно, мы, фактически, просто переписали псевдокод из документации, но здесь он машиночитаемый!
Для проверки создадим файл bug.c
:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
char buf[16];
int res = read(0, buf, 4);
if (buf[0] == 'T' && buf[1] == 'E' && buf[2] == 'S' && buf[3] == 'T')
abort();
return res * 0;
}
А ещё — файл forksrv
, который удобно скармливать AFL:
#!/bin/bash
export NATIVE_INST=./instrumentation-examples/afl/afl-native.so
export BPF_INST=./instrumentation-examples/afl/afl-bpf.c.o
exec ./x86_64-linux-user/qemu-x86_64 ./instrumentation-examples/afl/bug
И запустим фаззинг:
AFL_SKIP_BIN_CHECK=1 afl-fuzz -i ../input -o ../output -m none -- ./forksrv
1234
T234
TE34
TES4
TEST <- а это уже в crashes, полторы минуты и 2200 запусков спустя
Пока что скорость не ахти, но в оправдание скажу, что здесь (пока что) не используется важная фича оригинального qemu_mode
: отправка адресов исполняемого кода в fork server. Зато ничего AFL-ного в кодовой базе QEMU теперь нет, и есть надежда эту обобщённую инструментацию когда-нибудь запихнуть в upstream.