Внимание: содержит системное программирование. Да, в сущности, ничего другого и не содержит.
Давайте представим, что вам дали задание написать фэнтезийно-фантастическую игру. Ну там про эльфов. И про виртуальную реальность. Вы с детства мечтали написать что-нибудь эдакое и, не раздумывая, соглашаетесь. Вскоре вы понимаете, что о мире эльфов вы знаете по большей части из анекдотов со старого башорга и прочих разрозненных источников. Упс, неувязочка. Ну, где наша не пропадала… Наученный богатым программистским опытом, вы отправляетесь в Гугл, вводите «Elf specification» и идёте по ссылкам. О! Вот эта ведёт на какую-то PDF-ку… так, что тут у нас… какой-то Elf32_Sword
— эльфийские мечи — похоже, то что нужно. 32 — это, по-видимому, уровень персонажа, а две четвёрки в следующих столбцах — это урон, наверное. Точно то, что нужно, да к тому же как систематизировано!..
Как говорилось в одной задаче по олимпиадному программированию после пары абзацев подробного текста на тему Японии, самураев и гейш: «Как вы уже поняли, задача будет совсем не об этом». Ах да, контест был, естественно, на время. В общем, пятиминутку упоротости объявляю закрытой.
Сегодня я попробую рассказать про разбор файла в 64-битном формате ELF. В принципе, что в нём только не хранят — нативные программы, библиотеки статические, библиотеки динамические, всякое implementation specific, вроде crashdump-ов… Используется он, например, на Linux и многих других Unix-like системах, да, говорят, даже на телефоны его поддержку раньше активно запихивали в патченных прошивках. Казалось бы, поддержать формат хранения программ из серьёзных операционных систем должно быть сложно. Так и я думал. Да так оно, наверное, и есть. Но мы будем поддерживать весьма специфический use case: загрузку байт-кода eBPF из .o
-файлов. Почему так? Просто для дальнейших экспериментов мне понадобится какой-нибудь серьёзный (то есть не наколеночный) кроссплатформенный байт-код, который можно получить из C, а не вручную писать, поэтому eBPF — он простой и для него есть LLVM-бекенд. А ELF парсить мне нужно просто как контейнер, в который этот байт-код кладётся компилятором.
На всякий случай уточню: статья носит характер exploratory programming и не претендует на роль исчерпывающего руководства. Конечная цель — сделать загрузчик, который позволит читать скомпилированные в eBPF с помощью Clang программы на C — те, которые у меня есть — в объёме, достаточном для продолжения экспериментов.
Заголовок
Начиная с нулевого смещения в ELF лежит заголовок. Он содержит те самые буквы E, L, F, которые можно увидеть, если попытаться открыть его текстовым редактором, и некоторые глобальные переменные. Собственно, заголовок — это единственная структура в файле, расположенная по фиксированному смещению, и он содержит информацию, чтобы разыскать остальные структуры. (Здесь и далее я руководствуюсь документацией на 32-битный формат и elf.h
, знающим про 64-битный. Так что, если заметите ошибки — смело поправляйте)
Первое, что нас встречает в файле — это поле unsigned char e_ident[16]
. Помните эти забавные статьи из серии «все следующие утверждения ложны»? Вот тут примерно так же: ELF может содержать в себе 32- или 64-битный код, Little или Big Endian, да ещё и под десяток архитектур процессоров. Вы собрались читать его как Elf64 под Little endian — ну, удачи… Вот этот массив байт и является своеобразной сигнатурой того, что находится внутри и как это парсить.
С первыми четырьмя байтами всё просто — это [0x7f, 'E', 'L', 'F']
. Если они не совпадают, то есть основания полагать, что это какие-то неправильные пчёлы. Следующий байт содержит класс персонажа файла: ELFCLASS32
или ELFCLASS64
— разрядность. Для простоты мы будем работать только с 64-битными файлами (а бывает ли 32-битный eBPF?). Если класс оказался ELFCLASS32
— просто выходим с ошибкой: всё равно структуры «поплывут», а sanity check сделать не помешает. Последний интересующий нас байт в этой структуре указывает на endianness файла — будем работать только с «родным» для нашего процессора порядком байт.
На всякий случай уточню: работая с форматом ELF на C не следует вычитывать каждый инт по хитро вычисленному смещению — elf.h
содержит необходимые структуры, и даже номера байтов в e_ident
: EI_MAG0
, EI_MAG1
, EI_MAG2
, EI_MAG3
, EI_CLASS
, EI_DATA
… Нужно просто привести указатель на вычитанные или отображённые в память данные из файла к указателю на структуру и читать.
Кроме e_ident
заголовок содержит и другие поля, некоторые мы просто проверим, а некоторые используем для дальнейшего разбора, но потом. А именно, проверим, что e_machine == EM_BPF
(то есть он «под архитектуру процессора eBPF»), e_type == ET_REL
, e_shoff != 0
. Последняя проверка имеет следующий смысл: файл может содержать информацию для линковки (section table и секции), для запуска (program table и сегменты) или оба типа. Двумя последними проверками мы проверяем, что нужная нам информация (как бы для линковки) в файле имеется. Также проверим, что версия формата имеет значение EV_CURRENT
.
Сразу оговорюсь, я не буду проверять валидность файла, предполагая, что если уж мы его загружаем в свой процесс, то мы ему доверяем. В коде ядра или других программ, работающими с недоверенными файлами, так поступать, естественно, ни в коем случае нельзя.
Таблица секций
Как я уже говорил, нас интересует linking view файла, то есть таблица секций и сами секции. Информация о том, где искать таблицу секций, находится в заголовке. Там же указан её размер, а также размер одного элемента — он может быть и больше, чем sizeof(Elf64_Shdr)
(как это отразится на номере версии формата, честно скажу, не знаю). Некоторые старшие номера секций зарезервированы, и фактически в таблице не присутствуют. Отсылка к ним имеет специальное значение. Нас интересует, видимо, только SHN_UNDEF
(ноль тоже зарезервирован — отсутствующая секция; кстати, как вы понимаете, её заголовок в таблице всё же имеется) SHN_ABS
. Символ, «определённый в секции SHN_UNDEF
» на самом деле undefined, а в SHN_ABS
— на самом деле имеет абсолютное значение и не релоцируется. Впрочем, SHN_ABS
мне, похоже, тоже пока не нужен.
Таблица строк
Здесь мы впервые натыкаемся на string tables — таблицы строк, используемых в файле. Фактически, если const char *strtab
— это таблица строк, то имя sh_name
— это просто strtab + sh_name
. Да, это просто строка, начинающаяся с некого индекса, и продолжающаяся до нулевого байта. Строки могут пересекаться (точнее, одна может являться суффиксом другой). У секций могут быть имена, тогда в ELF Header поле e_shstrndx
будет указывать на секцию таблицы строк (той, которая для имён секций, если их несколько), а поле sh_name
в заголовке секции — на конкретную строку.
Первый (нулевой) и последний байты таблицы строк содержат нулевые символы. Последний понятно почему: значение-часовой, завершает последнюю строку. А вот нулевое смещение задаёт отсутствующее или пустое имя — в зависимости от контекста.
Загрузка секций
В заголовке каждой секции имеются два адреса: один, sh_addr
— это адрес загрузки (куда секция будет помещена в памяти), другой, sh_offset
— смещение в файле, по которому эта секция там лежит. Не знаю, как оба, но каждое по отдельности из этих значений может быть 0: в одном случае секция «остаётся на диске», поскольку там лежит какая-то служебная информация. В другом — секция не грузится с диска, например, её просто нужно выделить, и забить нулями (.bss
). Честно говоря, пока мне не приходилось обрабатывать адрес загрузки — куда загрузилось, туда и загрузилось :) Впрочем, у нас и программы, прямо скажем, специфические.
Релокация
А теперь интересное: по технике безопасности в Матрицу без оператора, оставшегося на базе, как известно, не ходят. А поскольку у нас тут всё-таки фэнтези, то связь с оператором будет телепатическая. Ах да, я же объявил пятиминутку упоротости завершённой. В общем, кратенько обсудим процесс линковки.
Для моего эксперимента мне потребуется часть кода, скомпилированного в обычную so-шку, загружаемую обычной libdl
. Тут я даже описывать подробно не буду — просто открываете dlopen
, вытягиваете символы через dlsym
, при завершении программы закрываете с помощью dlclose
. Впрочем, даже это — уже детали реализации, не относящиеся к нашему загрузчику ELF-файлов. Просто есть некий контекст: возможность по имени получить указатель.
Вообще, набор инструкций eBPF представляет собой торжество выровненного машинного кода: инструкция всегда занимает 8 байтов и имеет структуру
struct {
uint8_t opcode;
uint8_t dst:4;
uint8_t src:4;
uint16_t offset;
uint32_t imm;
};
Причём многие поля в каждой конкретной инструкции могут не использоваться — экономия места под «машинный» код — это не про нас.
На самом деле, за первой инструкцией может сразу идти вторая, не содержащая никаких опкодов, а просто расширяющая immediate поле с 32-х до 64-х бит. Вот патчинг такой составной инструкции и называется R_BPF_64_64
.
Для того, чтобы выполнить релокацию, ещё раз просмотрим таблицу секций на предмет sh_type == SHT_REL
. Поле sh_info
заголовка укажет на то, какую секцию мы патчим, а sh_link
— из какой таблицы брать описание символов.
typedef struct
{
Elf64_Addr r_offset;
Elf64_Xword r_info;
} Elf64_Rel;
Вообще-то, бывают секции релокации двух видов: REL
и RELA
— вторая в явном виде содержит дополнительное слагаемое, но я её пока не встречал, поэтому просто добавим assertion на то, что она и вправду не встретится, и будем обрабатывать. Далее я буду добавлять к тому значению, что записано в инструкциях, адрес символа. А откуда его взять? Тут, как мы уже знаем, возможны варианты:
- Символ ссылается на секцию
SHN_ABS
. Тогда просто берёмst_value
- Символ ссылается на секцию `SHN_UNDEF. Тогда вытягиваем внешний символ
- В остальных случаях просто патчим ссылку на другую секцию того же файла`
Как попробовать самому
Во первых, что почитать? Кроме уже указанной спецификации имеет смысл почитать этот файл, в котором команда iovisor собирает информацию, добытую из Linux kernel по eBPF.
Во вторых, как собственно, с этим всем работать? Для начала нужно откуда-то получить ELF-файл. Как сказано на StackOverfow, нам поможет команда
clang -O2 -emit-llvm -c bpf.c -o - | llc -march=bpf -filetype=obj -o bpf.o
Во вторых, нужно как-то получить эталонный разбор файла на кусочки. В обычной ситуации нам бы помогла команда objdump
:
$ objdump
Использование: objdump <параметры> <файл(ы)>
Отображает информацию из объекта <файл(ы)>.
Должен быть указан по крайней мере один из следующих ключей:
-a, --archive-headers Display archive header information
-f, --file-headers Display the contents of the overall file header
-p, --private-headers Display object format specific file header contents
-P, --private=OPT,OPT... Display object format specific contents
-h, --[section-]headers Display the contents of the section headers
-x, --all-headers Display the contents of all headers
-d, --disassemble Display assembler contents of executable sections
-D, --disassemble-all Display assembler contents of all sections
--disassemble=<sym> Display assembler contents from <sym>
-S, --source Intermix source code with disassembly
-s, --full-contents Display the full contents of all sections requested
-g, --debugging Display debug information in object file
-e, --debugging-tags Display debug information using ctags style
-G, --stabs Display (in raw form) any STABS info in the file
-W[lLiaprmfFsoRtUuTgAckK] or
--dwarf[=rawline,=decodedline,=info,=abbrev,=pubnames,=aranges,=macro,=frames,
=frames-interp,=str,=loc,=Ranges,=pubtypes,
=gdb_index,=trace_info,=trace_abbrev,=trace_aranges,
=addr,=cu_index,=links,=follow-links]
Display DWARF info in the file
-t, --syms Display the contents of the symbol table(s)
-T, --dynamic-syms Display the contents of the dynamic symbol table
-r, --reloc Display the relocation entries in the file
-R, --dynamic-reloc Display the dynamic relocation entries in the file
@<file> Read options from <file>
-v, --version Display this program's version number
-i, --info List object formats and architectures supported
-H, --help Display this information
Но в данном случае она бессильна:
$ objdump -d test-bpf.o
test-bpf.o: формат файла elf64-little
objdump: невозможно выполнить дизассемблирование для архитектуры UNKNOWN!
Точнее, секции-то она покажет, а вот с дизассемблированием проблема. Тут мы вспоминаем, что собирали с помощью LLVM. А у LLVM есть свои расширенные аналоги утилит из binutils, с именами вида llvm-<имя команды>
. Они, например, понимают LLVM bitcode. А ещё они понимают eBPF — наверняка это зависит от параметров компиляции, но раз уж оно скомпилировало, то и распарсить, наверное, всегда должно. Поэтому для удобства рекомендую создать скрипт:
vim test-bpf.c # Подставить редактор по вкусу
clang -Oz -emit-llvm -c test-bpf.c -o - | llc -march=bpf -filetype=obj -o test-bpf.o
llvm-objdump -d -t -r test-bpf.o
Тогда для такого исходника:
#include <stdint.h>
extern uint64_t z;
uint64_t func(uint64_t x, uint64_t y)
{
return x + y + z;
}
Будет такой результат:
$ ./compile-bpf.sh
test-bpf.o: file format ELF64-BPF
Disassembly of section .text:
0000000000000000 func:
0: bf 20 00 00 00 00 00 00 r0 = r2
1: 0f 10 00 00 00 00 00 00 r0 += r1
2: 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
0000000000000010: R_BPF_64_64 z
4: 79 11 00 00 00 00 00 00 r1 = *(u64 *)(r1 + 0)
5: 0f 10 00 00 00 00 00 00 r0 += r1
6: 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 g F .text 00000038 func
0000000000000000 *UND* 00000000 z
Код.
Часть 1. QInst: лучше день потерять, потом за пять минут долететь (пишем инструментацию тривиально)
prs123
Ещё для загрузки в микроконтроллеры некоторые среды используют ELF в качестве промежуточного результата