Хабрахабр

Часть 0. Требуется эльф для работы в Матрице. Возможна релокация

Да, в сущности, ничего другого и не содержит. Внимание: содержит системное программирование.

Ну там про эльфов. Давайте представим, что вам дали задание написать фэнтезийно-фантастическую игру. Вы с детства мечтали написать что-нибудь эдакое и, не раздумывая, соглашаетесь. И про виртуальную реальность. Упс, неувязочка. Ну, где наша не пропадала… Наученный богатым программистским опытом, вы отправляетесь в Гугл, вводите «Elf specification» и идёте по ссылкам. Вскоре вы понимаете, что о мире эльфов вы знаете по большей части из анекдотов со старого башорга и прочих разрозненных источников. Вот эта ведёт на какую-то PDF-ку… так, что тут у нас… какой-то Elf32_Sword — эльфийские мечи — похоже, то что нужно. О! Точно то, что нужно, да к тому же как систематизировано!.. 32 — это, по-видимому, уровень персонажа, а две четвёрки в следующих столбцах — это урон, наверное.

Ах да, контест был, естественно, на время. Как говорилось в одной задаче по олимпиадному программированию после пары абзацев подробного текста на тему Японии, самураев и гейш: «Как вы уже поняли, задача будет совсем не об этом». В общем, пятиминутку упоротости объявляю закрытой.

В принципе, что в нём только не хранят — нативные программы, библиотеки статические, библиотеки динамические, всякое implementation specific, вроде crashdump-ов… Используется он, например, на Linux и многих других Unix-like системах, да, говорят, даже на телефоны его поддержку раньше активно запихивали в патченных прошивках. Сегодня я попробую рассказать про разбор файла в 64-битном формате ELF. Так и я думал. Казалось бы, поддержать формат хранения программ из серьёзных операционных систем должно быть сложно. Но мы будем поддерживать весьма специфический use case: загрузку байт-кода eBPF из .o-файлов. Да так оно, наверное, и есть. Просто для дальнейших экспериментов мне понадобится какой-нибудь серьёзный (то есть не наколеночный) кроссплатформенный байт-код, который можно получить из C, а не вручную писать, поэтому eBPF — он простой и для него есть LLVM-бекенд. Почему так? А ELF парсить мне нужно просто как контейнер, в который этот байт-код кладётся компилятором.

Конечная цель — сделать загрузчик, который позволит читать скомпилированные в eBPF с помощью Clang программы на C — те, которые у меня есть — в объёме, достаточном для продолжения экспериментов. На всякий случай уточню: статья носит характер exploratory programming и не претендует на роль исчерпывающего руководства.

Он содержит те самые буквы E, L, F, которые можно увидеть, если попытаться открыть его текстовым редактором, и некоторые глобальные переменные. Начиная с нулевого смещения в ELF лежит заголовок. (Здесь и далее я руководствуюсь документацией на 32-битный формат и elf.h, знающим про 64-битный. Собственно, заголовок — это единственная структура в файле, расположенная по фиксированному смещению, и он содержит информацию, чтобы разыскать остальные структуры. Так что, если заметите ошибки — смело поправляйте)

Помните эти забавные статьи из серии «все следующие утверждения ложны»? Первое, что нас встречает в файле — это поле unsigned char e_ident[16]. Вы собрались читать его как Elf64 под Little endian — ну, удачи… Вот этот массив байт и является своеобразной сигнатурой того, что находится внутри и как это парсить. Вот тут примерно так же: ELF может содержать в себе 32- или 64-битный код, Little или Big Endian, да ещё и под десяток архитектур процессоров.

Если они не совпадают, то есть основания полагать, что это какие-то неправильные пчёлы. С первыми четырьмя байтами всё просто — это [0x7f, 'E', 'L', 'F']. Для простоты мы будем работать только с 64-битными файлами (а бывает ли 32-битный eBPF?). Следующий байт содержит класс персонажа файла: ELFCLASS32 или ELFCLASS64 — разрядность. Последний интересующий нас байт в этой структуре указывает на endianness файла — будем работать только с «родным» для нашего процессора порядком байт. Если класс оказался ELFCLASS32 — просто выходим с ошибкой: всё равно структуры «поплывут», а sanity check сделать не помешает.

На всякий случай уточню: работая с форматом ELF на C не следует вычитывать каждый инт по хитро вычисленному смещению — elf.h содержит необходимые структуры, и даже номера байтов в e_ident: EI_MAG0, EI_MAG1, EI_MAG2, EI_MAG3, EI_CLASS, EI_DATA… Нужно просто привести указатель на вычитанные или отображённые в память данные из файла к указателю на структуру и читать.

А именно, проверим, что e_machine == EM_BPF (то есть он «под архитектуру процессора eBPF»), e_type == ET_REL, e_shoff != 0. Кроме e_ident заголовок содержит и другие поля, некоторые мы просто проверим, а некоторые используем для дальнейшего разбора, но потом. Двумя последними проверками мы проверяем, что нужная нам информация (как бы для линковки) в файле имеется. Последняя проверка имеет следующий смысл: файл может содержать информацию для линковки (section table и секции), для запуска (program table и сегменты) или оба типа. Также проверим, что версия формата имеет значение EV_CURRENT.

Сразу оговорюсь, я не буду проверять валидность файла, предполагая, что если уж мы его загружаем в свой процесс, то мы ему доверяем. В коде ядра или других программ, работающими с недоверенными файлами, так поступать, естественно, ни в коем случае нельзя.

Информация о том, где искать таблицу секций, находится в заголовке. Как я уже говорил, нас интересует linking view файла, то есть таблица секций и сами секции. Некоторые старшие номера секций зарезервированы, и фактически в таблице не присутствуют. Там же указан её размер, а также размер одного элемента — он может быть и больше, чем sizeof(Elf64_Shdr) (как это отразится на номере версии формата, честно скажу, не знаю). Нас интересует, видимо, только SHN_UNDEF (ноль тоже зарезервирован — отсутствующая секция; кстати, как вы понимаете, её заголовок в таблице всё же имеется) SHN_ABS. Отсылка к ним имеет специальное значение. Впрочем, SHN_ABS мне, похоже, тоже пока не нужен. Символ, «определённый в секции SHN_UNDEF» на самом деле undefined, а в SHN_ABS — на самом деле имеет абсолютное значение и не релоцируется.

Таблица строк

Фактически, если const char *strtab — это таблица строк, то имя sh_name — это просто strtab + sh_name. Здесь мы впервые натыкаемся на string tables — таблицы строк, используемых в файле. Строки могут пересекаться (точнее, одна может являться суффиксом другой). Да, это просто строка, начинающаяся с некого индекса, и продолжающаяся до нулевого байта. У секций могут быть имена, тогда в ELF Header поле e_shstrndx будет указывать на секцию таблицы строк (той, которая для имён секций, если их несколько), а поле sh_name в заголовке секции — на конкретную строку.

Последний понятно почему: значение-часовой, завершает последнюю строку. Первый (нулевой) и последний байты таблицы строк содержат нулевые символы. А вот нулевое смещение задаёт отсутствующее или пустое имя — в зависимости от контекста.

Загрузка секций

Не знаю, как оба, но каждое по отдельности из этих значений может быть 0: в одном случае секция «остаётся на диске», поскольку там лежит какая-то служебная информация. В заголовке каждой секции имеются два адреса: один, sh_addr — это адрес загрузки (куда секция будет помещена в памяти), другой, sh_offset — смещение в файле, по которому эта секция там лежит. Честно говоря, пока мне не приходилось обрабатывать адрес загрузки — куда загрузилось, туда и загрузилось 🙂 Впрочем, у нас и программы, прямо скажем, специфические. В другом — секция не грузится с диска, например, её просто нужно выделить, и забить нулями (.bss).

А поскольку у нас тут всё-таки фэнтези, то связь с оператором будет телепатическая. А теперь интересное: по технике безопасности в Матрицу без оператора, оставшегося на базе, как известно, не ходят. В общем, кратенько обсудим процесс линковки. Ах да, я же объявил пятиминутку упоротости завершённой.

Тут я даже описывать подробно не буду — просто открываете dlopen, вытягиваете символы через dlsym, при завершении программы закрываете с помощью dlclose. Для моего эксперимента мне потребуется часть кода, скомпилированного в обычную so-шку, загружаемую обычной libdl. Просто есть некий контекст: возможность по имени получить указатель. Впрочем, даже это — уже детали реализации, не относящиеся к нашему загрузчику ELF-файлов.

Вообще, набор инструкций eBPF представляет собой торжество выровненного машинного кода: инструкция всегда занимает 8 байтов и имеет структуру

struct { uint8_t opcode; uint8_t dst:4; uint8_t src:4; uint16_t offset; uint32_t imm;
};

Причём многие поля в каждой конкретной инструкции могут не использоваться — экономия места под «машинный» код — это не про нас.

Вот патчинг такой составной инструкции и называется R_BPF_64_64. На самом деле, за первой инструкцией может сразу идти вторая, не содержащая никаких опкодов, а просто расширяющая immediate поле с 32-х до 64-х бит.

Поле sh_info заголовка укажет на то, какую секцию мы патчим, а sh_link — из какой таблицы брать описание символов. Для того, чтобы выполнить релокацию, ещё раз просмотрим таблицу секций на предмет sh_type == SHT_REL.

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 bitcode. А у LLVM есть свои расширенные аналоги утилит из binutils, с именами вида llvm-<имя команды>. Поэтому для удобства рекомендую создать скрипт: А ещё они понимают 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

Код.

QInst: лучше день потерять, потом за пять минут долететь (пишем инструментацию тривиально) Часть 1.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть