Автор статьи https://github.com/Nalen98
Добрый день!
Тема моего исследования в рамках летней стажировки «Summer of Hack 2019» в компании Digital Security была «Декомпиляция eBPF в Ghidra». Нужно было разработать на языке Sleigh систему трансляции байткода eBPF в PCode Ghidra для возможности проводить дизассемблирование, а также декомпиляцию eBPF-программ. Результатом исследования является разработанное расширение для Ghidra, которое добавляет поддержку eBPF-процессора. Исследование, как и у других стажёров, можно по праву считать «первопроходным», поскольку ранее в других инструментах реверс-инжиниринга не было возможности проводить декомпиляцию eBPF.
Предыстория
Эта тема досталась мне по большой иронии судьбы, потому что с eBPF я ранее не была знакома, и Ghidr-ой ранее не пользовалась, поскольку была некая догма, что «IDA Pro лучше». Как оказалось, это не совсем так.
Знакомство с Ghidra оказалось очень стремительным, поскольку её разработчики составили очень грамотную и доступную документацию. Так же, пришлось освоить язык процессорной спецификации Sleigh, на котором и осуществилась разработка. Разработчики постарались на славу и создали очень подробную документацию как для самого инструмента, так и для Sleigh, за что им огромное спасибо.
По другую сторону баррикад находился extended Berkeley Packet Filter. eBPF представляет собой виртуальную машину в ядре Linux, позволяющую загружать произвольный пользовательский код, который может применяться для трассировки процессов и фильтрации пакетов в пространстве ядра. Архитектура представляет из себя регистровую машину RISC с 11 64-битными регистрами, программным счётчиком и 512-байтным стеком. Для eBPF существует ряд ограничений:
- циклы запрещены;
- доступ к памяти возможен только через стек (про него будет отдельная история);
- функции ядра доступны только через специальные функции-обертки (eBPF-helpers).
Структура eBPF-технологии. Источник изображения: http://www.brendangregg.com/ebpf.html.
В основном, эта технология используется для сетевых задач — отладки, фильтрации пакетов и так далее на уровне ядра. Поддержка eBPF добавлена с 3.15 версии ядра, на «Linux plumbers conference 2019» этой технологии было посвящено довольно много докладов. Но у eBPF, в отличие от Ghidra, документация недоработанная и многого не содержит. Поэтому, уточнения и недостающую информацию пришлось искать на просторах Интернета. На поиски ответов ушло довольно много времени, и остаётся только надеется, что технологию доработают и создадут нормальную документацию.
Плохая документация
Для того, чтобы разработать спецификацию на Sleigh, нужно сначала разобраться, как работает архитектура целевого процессора. И тут мы обращаемся к официальной документации.
Она содержит ряд недоработок:
-
Неполно описана структура инструкций eBPF.
В большинстве спецификаций, например Intel x86, обычно указывается, на что уходит каждый бит инструкции, к какому блоку принадлежит. К сожалению, в спецификации eBPF эти подробности либо разбросаны по всему документу, либо вообще отсутствуют, в результате чего приходится черпать недостающие крупицы из деталей реализации в ядре Linux.
Например, в струкуре инструкции
op:8, dst_reg:4, src_reg:4, off:16, imm:32
ни слова не сказано, что offset (off) и immediate (imm) являютсяsigned
, и это крайне важно, поскольку это влияет на работу от арифметических инструкций до джампов. Помогли исходники ядра Linux. -
Нет полного представления о всех возможных мнемониках архитектуры.
В некоторых документациях указываются не только все инструкции, их операнды, но и их семантика на C, случаи применения, особенности операндов и так далее. В документации eBPF указаны классы инструкций, но этого мало для разработчика. Рассмотрим их по-подробнее.
Все инструкции у eBPF 64-битные, кроме
LDDW
(Load double word), она имеет размер 128 бит, в ней происходит конкатенация двух imm по 32 бита. eBPF-инструкции имеют следующую структуру.
eBPF instruction encodingСтруктура поля
OPAQUE
зависит от класса инструкций (ALU/JMP, Load/Store).Например, класс инструкций
ALU
:
ALU instructions encodingи класс
JMP
имеют свою структуру поля:
Branch instructions encodingДля Load/Store инструкций структура иная:
Load/Store instructions encodingРазобраться в этом помогла неофициальная документация eBPF.
-
Отсутствует информация о call-helpers, на которых построено большинство логики eBPF-программ для ядра Linux.
И это крайне странно, поскольку хелперы — самое важное, что есть в eBPF-программах, они как раз и выполняют те задачи, на которые и заточена технология.
Взаимодействие eBPF с ядерными функциями
Программа выдёргивает эти функции из ядра, а они как раз и осуществляют работу с процессами, манипулируют сетевыми пакетами, работают с eBPF maps, обращаются к сокетам, взаимодействуют с userspace-ом. Несмотря на то, что функции таки ядерные, в официальной документации стоило бы написать о них по-подробнее. Полная информация найдена в исходниках Linux.
- Ни слова о «tail calls».
Хвостовые вызовы eBPF. Источник изображения: https://cilium.readthedocs.io/en/latest/bpf/#tail-calls.
Хвостовые вызовы — это механизм, который позволяет одной eBPF-программе вызывать другую, не возвращаясь к предыдущей, то есть прыжки между разным eBPF-программами. В разработанном расширении они не реализованы, подробную информацию можно найти в документации Cilium.
Плохая документация и ряд архитектурных особенностей eBPF были главными «занозами» в разработке, поскольку они порождали другие проблемы. К счастью, большинство из них были решены успешно.
О среде разработки
Не все разработчики знают, что для создания и редактирования Sleigh-кода и вообще всех файлов расширений/плагинов для Ghidra есть довольно удобный инструмент — Eclipse IDE с поддержкой плагинов GhidraDev и GhidraSleighEditor. При создании расширения оно будет сразу оформлено в виде рабочего проекта, есть довольно удобная подсветка для Sleigh-кода, а также чекер основных ошибок в синтаксисе языка.
В Eclipse можно запускать Ghidra (уже с включенным расширением), проводить отладку, что крайне удобно. Но, пожалуй, самой крутой возможностью является поддержка режима "Ghidra Headless", не нужно перезапускать Ghidr-у с GUI по 100500 раз, чтобы найти ошибку в коде, все процессы осуществляются в фоновом режиме.
Блокнот можно закрыть! А загрузить Eclipse можно с официального сайта. Чтобы установить плагин, в Ecplise выберите Help → Install New Software..., нажмите Add и выберите zip-архив плагина.
Разработка расширения
Для расширения были разработаны файлы процессорной спецификации, загрузчик, который наследуется от основного ELF-загрузчика и расширяет его возможности в плане распознавания eBPF-программ, обработчик релокаций для реализации eBPF Maps в дизассемблере и декомпиляторе Ghidra, а также анализатор для определения сигнатур eBPF-хелперов.
Файлы расширения в виде проекта в Eclipse IDE
Теперь о главных файлах:
.cspec
— указывается, какие типы данных используются, сколько памяти на них выделяется в eBPF, устанавливается размер стека, устанавливается метка «stackpointer» на регистр R10
, расписывается соглашение о вызовах. Соглашение (как и остальное) было реализовано согласно документации:
Therefore, eBPF calling convention is defined as:
- R0 — return value from in-kernel function, and exit value for eBPF program
- R1 — R5 — arguments from eBPF program to in-kernel function
- R6 — R9 — callee saved registers that in-kernel function will preserve
- R10 — read-only frame pointer to access stack
Примечание Digital Security: из-за проблем с оформлением разметки кода содержимое некоторых файлов возможно представить только в виде скринов. Весь код представлен также в репозитории проекта на Github.
Перед тем, как продолжить описывать файлы разработки, остановлюсь на небольшой строчке .cspec
-файла.
<stackpointer register="R10" space="ram"/>
Она является главным источником зла при декомпиляции eBPF в Ghidra, и с неё началось увлекательное путешествие в стек eBPF, который имеет ряд неприятных моментов, и который принёс больше всего боли в разработку.
All we need is… Stack
Обратимся к официальной документации ядра:
Q: Can BPF programs access instruction pointer or return address?
A: NO.
Q: Can BPF programs access stack pointer?
A: NO. Only frame pointer (register R10) is accessible. From compiler point of view it's necessary to have stack pointer. For example, LLVM defines register R11 as stack pointer in its BPF backend, but it makes sure that generated code never uses it.
Процессор не имеет ни указателя инструкции (IP), ни указателя стека (SP), а последний для Ghidra крайне важен, и от него зависит качество декомпиляции. В cspec
-файле необходимо указать, какой регистр является stackpointer-ом (что было продемонстрировано выше). R10
— единственный регистр eBPF, который позволяет обращаться к стеку программы, это framepointer, он статичен и всегда нулевой. Вешать метку «stackpointer» на R10
в cspec
-файле в корне неправильно, но других вариантов нет, поскольку тогда Ghidra не будет работать со стеком программы. Соответственно, оригинальный SP отсутствует, и ничто его не заменяет в архитектуре eBPF.
Из этого вытекает несколько проблем:
-
Поле "Stack Depth" в Ghidra будет гарантированно нулевым, поскольку мы просто обязаны назначить
R10
стекпионтером в данных условиях архитектуры, а по своей сути он всегда нулевой, что было аргументировано ранее. "Stack Depth" будет отражать именно регистр с меткой «stackpointer».И с этим придется смириться, таковы особенности архитектуры.
-
Инструкции, которые оперируют с
R10
(то есть, осуществляющие работу со стеком) зачастую не декомпилируются. Ghidra не декомпилирует как правило то, что считает мёртвым кодом (то есть сниппеты, которые никогда не выполнятся). И посколькуR10
неизменяемый, то многие store/load инструкции распознаются Ghidr-ой как deadcode и исчезают из декомпилятора.К счастью, эта проблема была решена написанием кастомного анализатора, а также объявлением дополнительного адресного пространства с eBPF-хелперами в
pspec
— файле, на что натолкнул один из разработчиков Ghidra в Issue проекта.
Разработка расширения (continued)
.ldefs
описывает особенности процессора, определяет файлы спецификации.
Файл .opinion
устанавливает соответствие между загрузчиком и процессором.
В .pspec
объявлен программный счётчик, но у eBPF он неявный и в спецификации никак не используется, поэтому только в целях проформы. Кстати, PC
у eBPF арифметический, а не адресный (указывает на инструкцию, а не конкретный байт программы), имейте это в виду при джампах.
В файле также задано дополнительное адресное пространство для eBPF-хелперов, здесь они объявлены в качестве символов.
.sinc
файл — самый объёмный файл расширения, здесь определены все регистры, структура инструкции eBPF, токены, мнемоника и семантика инструкций на языке Sleigh.
define space ram type=ram_space size=8 default;
define space register type=register_space size=4;
define space syscall type=ram_space size=2;
define register offset=0 size=8 [ R0 R1 R2 R3 R4 R5 R6 R7 R8 R9 R10 PC ];
define token instr(64)
imm=(32, 63) signed
off=(16, 31) signed
src=(12, 15)
dst=(8, 11)
op_alu_jmp_opcode=(4, 7)
op_alu_jmp_source=(3, 3)
op_ld_st_mode=(5, 7)
op_ld_st_size=(3, 4)
op_insn_class=(0, 2)
;
We'll need this token to operate with LDDW instruction, which has 64 bit imm value
define token immtoken(64)
imm2=(32, 63)
;
To operate with registers
attach variables [ src dst ] [ R0 R1 R2 R3 R4 R5 R6 R7 R8 R9 R10 _ ];
…
:ADD dst, src is src & dst & op_alu_jmp_opcode=0x0 & op_alu_jmp_source=1 & op_insn_class=0x7 { dst=dst + src; }
:ADD dst, imm is imm & dst & op_alu_jmp_opcode=0x0 & op_alu_jmp_source=0 & op_insn_class=0x7 { dst=dst + imm; }
…
Загрузчик eBPF расширяет основные возможности ELF-загрузчика, чтобы он мог распознать, что у программы, которую вы загрузили в Ghidra, процессор — eBPF. Для него в ElfConstants
Ghidra выделена BPF-константа, и по ней загрузчик определяет eBPF-процессор.
java
package ghidra.app.util.bin.format.elf.extend;
import ghidra.app.util.bin.format.elf.;
import ghidra.program.model.lang.;
import ghidra.util.exception.*;
import ghidra.util.task.TaskMonitor;
public class eBPF_ElfExtension extends ElfExtension {
@Override
public boolean canHandle(ElfHeader elf) {
return elf.e_machine() == ElfConstants.EM_BPF && elf.is64Bit();
}
@Override
public boolean canHandle(ElfLoadHelper elfLoadHelper) {
Language language = elfLoadHelper.getProgram().getLanguage();
return canHandle(elfLoadHelper.getElfHeader()) &&
"eBPF".equals(language.getProcessor().toString()) &&
language.getLanguageDescription().getSize() == 64;
}
@Override
public String getDataTypeSuffix() {
return "eBPF";
}
@Override
public void processGotPlt(ElfLoadHelper elfLoadHelper, TaskMonitor monitor) throws CancelledException {
if (!canHandle(elfLoadHelper)) {
return;
}
super.processGotPlt(elfLoadHelper, monitor);
}
}
Обработчик релокаций необходим для реализации eBPF maps в дизассемблере и декомпиляторе. Взаимодействие с ними осуществляется через ряд хелперов, функции используют файловый дескриптор для указания на map-ы. Основываясь на таблице релокаций, видно, что загрузчик патчит инструкцию LDDW
, которая формирует Rn
для этих хелперов (например, bpf_map_lookup_elem(…)
).
Поэтому, обработчик парсит таблицу релокаций программы, находит адреса релокаций (инструкции), а также собирает строковую информацию об имени мапа. Далее, обращаясь к таблице символов, вычисляет реальные адреса этих мапов и патчит инструкции.
java
public class eBPF_ElfRelocationHandler extends ElfRelocationHandler {
@Override
public boolean canRelocate(ElfHeader elf) {
return elf.e_machine() == ElfConstants.EM_BPF;
}
@Override
public void relocate(ElfRelocationContext elfRelocationContext, ElfRelocation relocation,
Address relocationAddress) throws MemoryAccessException, NotFoundException {
ElfHeader elf = elfRelocationContext.getElfHeader();
if (elf.e_machine() != ElfConstants.EM_BPF) {
return;
}
Program program = elfRelocationContext.getProgram();
Memory memory = program.getMemory();
int type = relocation.getType();
int symbolIndex = relocation.getSymbolIndex();
long value;
boolean appliedSymbol = true;
//Relocations with maps always have type 0x1. Since eBPF hasn't names of constants (types) of relocations, it was decided to use magic //number 1.
if (type == 1) {
try {
int SymbolIndex= relocation.getSymbolIndex();
ElfSymbol Symbol = elfRelocationContext.getSymbol(SymbolIndex);
String map = Symbol.getNameAsString();
SymbolTable table = program.getSymbolTable();
Address mapAddr = table.getSymbol(map).getAddress();
String sec_name = elfRelocationContext.relocationTable.getSectionToBeRelocated().getNameAsString();
if (sec_name.toString().contains("debug")) {
return;
}
value = mapAddr.getAddressableWordOffset();
Byte dst = memory.getByte(relocationAddress.add(0x1));
memory.setLong(relocationAddress.add(0x4), value);
memory.setByte(relocationAddress.add(0x1), (byte) (dst + 0x10));
}
catch(NullPointerException e) {}
}
if (appliedSymbol && symbolIndex == 0) {
markAsWarning(program, relocationAddress, Long.toString(type),
"applied relocation with symbol-index of 0", elfRelocationContext.getLog());
}
}
}
Результат дизассемблирования и декомпиляции eBPF
И в итоге, получаем дизассемблер и декомпилятор eBPF! Пользуйтесь на здоровье!
Расширение на GitHub: eBPF for Ghidra.
Релизы тут: здесь.
P.S.
Огромное спасибо Digital Security за интересную стажировку, особенно наставникам из отдела исследований (Александр и Николай). Низкий вам поклон!
Автор: forkyforky