static noinline int foo(int ret) { return (ret + 2); } static int exe_init(void) { int ret = 0; int (*new_foo)(int); ret = foo(0); printk(KERN_INFO "ret=%d\n", ret); new_foo = module_alloc(PAGE_SIZE); set_memory_x((unsigned long)new_foo, 1); printk(KERN_INFO "foo=%lx new_foo=%lx\n", (unsigned long)foo, (unsigned long)new_foo); memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE); ret = new_foo(1); printk(KERN_INFO "ret=%d\n", ret); vfree(new_foo); return 0; }
Функция exe_init вызывается при загрузке модуля. Результат работы смотрим в логе ядра:
[ 6972.522422] ret=2 [ 6972.522443] foo=ffffffffc0000000 new_foo=ffffffffc0007000 [ 6972.522457] ret=3
Все работает правильно. А теперь добавим в foo функцию printk для отображения аргумента:
static noinline int foo(int ret) { printk(KERN_INFO "ret=%d\n", ret); return (ret + 2); }
и сдампим 25 байт содержимого функции new_foo() перед тем как передать ей управление:
memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE); dump((unsigned long)new_foo);
dump определим как
static inline void dump(unsigned long x) { int i; for (i = 0; i < 25; i++) \ pr_cont("%.2x ", *((unsigned char *)(x) + i) & 0xFF); \ pr_cont("\n"); }
Загружаем модуль и получаем краш со следующим сообщением в логе:
[ 8482.806092] ret=0 [ 8482.806092] ret=2 [ 8482.806111] foo=ffffffffc0000000 new_foo=ffffffffc0007000 [ 8482.806113] 53 89 fe 89 fb 48 c7 c7 24 10 00 c0 e8 e8 3d 0b c1 8d 43 02 5b c3 66 2e 0f [ 8482.806135] invalid opcode: 0000 [#1] SMP NOPTI [ 8482.806639] CPU: 0 PID: 5081 Comm: insmod Tainted: G O 5.4.27 #12 [ 8482.807669] Hardware name: innotek GmbH VirtualBox/VirtualBox, BIOS VirtualBox 12/01/2006 [ 8482.808560] RIP: 0010:irq_create_direct_mapping+0x79/0x90
Каким-то образом мы оказались в функции irq_create_direct_mapping, хотя должны были вызвать printk. Давайте разбираться что произошло.
Вначале посмотрим на дизассемблерный листинг функции foo. Получим его при помощи команды objdump -d:
Disassembly of section .text: 0000000000000000 <foo>: 0: 53 push %rbx 1: 89 fe mov %edi,%esi 3: 89 fb mov %edi,%ebx 5: 48 c7 c7 00 00 00 00 mov $0x0,%rdi c: e8 00 00 00 00 callq 11 <foo+0x11> 11: 8d 43 02 lea 0x2(%rbx),%eax 14: 5b pop %rbx 15: c3 retq 16: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1) 1d: 00 00 00
Функция foo расположена в начале текстовой секции. По смещению 0xC расположен опкод команды ближнего вызова (near call) e8 — ближнего, потому что выполняется в текущем сегмента кода, значение селектора не изменяется. Следующие 4 байта — это смещение относительно значения в регистре RIP, на которое будет передано управление, т.е. RIP = RIP + offset, согласно документации Intel (Intel 64 and IA-32 Architectures Software Developer’s Manual, Instruction Set Reference A-Z):
A relative offset (rel16 or rel32) is generally specified as a label in assembly code. But at the machine code level, it is encoded as a signed, 16- or 32-bit immediate value.
This value is added to the value in the EIP(RIP) register. In 64-bit mode the relative offset is always a 32-bit immediate value which is sign extended to 64-bits before it is added to the value in the RIP register for the target calculation.
Адрес функции foo мы знаем, это 0xffffffffc0000000, значит в RIP = 0xffffffffc0000000 + 0xc + 0x5 = 0xffffffffc00000011 (0xc — смещение к команде e8, 1 байт команды и 4 байта смещения). Смещение мы знаем, т.к. сдампили тело функции. Вычислим куда отправит нас вызов call в функции foo:
0xffffffffc00000011 + 0xffffffffc10b3de8 = 0xffffffff810b3df9
Это адрес функции printk:
# cat /proc/kallsyms | grep ffffffff810b3df9 ffffffff810b3df9 T printk
А теперь тоже самое для случая new_foo, адрес которой 0xffffffffc0007000
0xffffffffc0007011 + 0xffffffffc10b3de8 = 0xffffffff810badf9
Такого адрес в kallsyms нет, но есть 0xffffffff810badf9 — 0x79 = 0xffffffff810bad80
# cat /proc/kallsyms | grep ffffffff810bad80 ffffffff810bad80 T irq_create_direct_mapping
Это та самая функция, на которой случился краш.
Чтобы предотвратить краш, достаточно пересчитать смещение, зная адрес функции new_foo:
memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE); unsigned int delta = (unsigned long)printk - (unsigned long)new_foo - 0x11; *(unsigned int *)((void *)new_foo + 0xD) = delta;
После этого исправления крашей не будет, функция new_foo успешно отработает и вернет управление.
Задача решена. Осталось только разобраться почему в дизассемблерном листинге смещение после опкода e8 нулевое, а в дампе функции нет. Для этого надо рассмотреть, что такое релокации и как с ними работает ядро. Но вначале немного о формате ELF.
ELF это аббревиатура Executable and Linkable Format — формат исполнимых и компонуемых файлов. Файл формата ELF представляет собой набор секций. Секция хранит набор объектов, необходимых линкеру для формирования исполняемого образа — инструкции, данные, таблицы символов, записи о релокациях и т.п. Каждая секция описывается заголовком. Все заголовки собраны в таблицу заголовков и по сути представляют собой массив, где каждый элемент имеет индекс. Заголовок секции содержит смещение к началу секции и прочую служебную информацию, например ссылки на другие секции посредством указания индекса в таблице заголовков.
При сборке нашего тестового примера компилятор не знает адрес функции printk, поэтому заполняет место вызова нулевым значением и при помощи записи релокации сообщает ядру, что эту позицию надо заполнить валидным значением. Запись релокации содержит смещение к позиции, где надо внести изменения (позиция релокации), тип релокации и индекс символа в таблице символов, адрес которого надо подставить по указанному смещению. Для чего нужен тип релокации рассмотрим ниже. Заголовок секции записей релокаций ссылается через индексы на заголовки секции с таблицей символов и секции, относительно начала которой задается смещение к позиции релокации.
Посмотреть на содержимое записей релокаций можно при помощи утилиты objdump с ключем -r.
Из дизассемблерного листинга нам известно, что по смещению 0xD необходимо записать адрес функции printk, поэтому ищем в выводе objdump релокацию с такой позицией:
000000000000000d R_X86_64_PC32 printk-0x0000000000000004
Итак, у нас есть необходимая запись релокации, указывающая на позицию по смещению 0xD, и имя символа, адрес которого надо в эту позицию записать.
Значение (-4). которое добавляется к адресу функции printk, называется addendum, и оно учитывается при вычислении окончательного результата релокации.
Теперь посмотрим на символ printk:
$ objdump -t exe.ko | grep printk 0000000000000000 *UND* 0000000000000000 printk
Символ есть, он неопределен внутри модуля (undefined), значит искать его будем в ядре.
Более информативно будет взглянуть на записи релокации и символов в бинарном виде. Сделать это можно при помощи wireshark, он умеет парсить ELF формат. Вот наша запись релокации (копипаст из writeshark, LSB слева):
0d 00 00 00 00 00 00 00 02 00 00 00 22 00 00 00 fc ff ff ff ff ff ff ff | | | || | | | +---- Смещение -------+ +-- Тип ---++--Индекс-+ +---- addendum ------+
Сопоставим эту запись с определением соответствующей структуры из <linux/elf.h>:
typedef struct elf64_rela { Elf64_Addr r_offset; /* Location at which to apply the action */ Elf64_Xword r_info; /* index and type of relocation */ Elf64_Sxword r_addend; /* Constant addend used to compute value */ } Elf64_Rela;
Тут у нас 8 байт смещения 0x00000000d, 4 байта тип 0x00000002, 4 байта индекс в таблице символов 0x00000022 (или 34 в десятичной) и 8 байт addendum -4.
А вот запись из таблицы символов под номером 34:
01 01 00 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
и соответствующая структура
typedef struct elf64_sym { Elf64_Word st_name; /* Symbol name, index in string tbl */ unsigned char st_info; /* Type and binding attributes */ unsigned char st_other; /* No defined meaning, 0 */ Elf64_Half st_shndx; /* Associated section index */ Elf64_Addr st_value; /* Value of the symbol */ Elf64_Xword st_size; /* Associated symbol size */ } Elf64_Sym;
Первые 4 байта 0x00000101 — индекс в таблице строк .strtab к имени данного символа, т.е. printk. Поле st_info определяет тип символа, это может быть функция, объект данных и т.п., более детально смотрите в ELF спецификации. Поле st_other пропустим, сейчас оно для нас интереса не представляет, и посмотрим на три последних поля st_shndx, st_value и st_size. st_shndx — индекс заголовка секции, в которой определен символ. Мы видим тут нулевое значение, т.к. символ не определен внутри модуля, его нет в имеющихся секциях.
Соответственно его значение st_value и размер st_size также нулевые. Эти поля заполнит ядро при загрузке модуля.
Для сравнения посмотрим на символ foo, который явно присутствует:
08 00 00 00 02 00 02 00 00 00 00 00 00 00 00 00 16 00 00 00 00 00 00 00
Символ определяет функцию, которая находится в секции .text по адресу относительно начала секции 0x00000000, т.е. в самом начале секции, как мы видели в дизассемблерном листинге, размер функции 22 байта.
Такую же информацию об этом нам покажет и objdump:
$ objdump -t exe.ko | grep foo 0000000000000000 l F .text 0000000000000016 foo
Когда ядро загружает модуль, оно находит все Undefined символы и заполняет поля st_value и st_size валидными значениями. Делается это в функции simplify_symbols, файл kernel/module.c:
/* Change all symbols so that st_value encodes the pointer directly. */ static int simplify_symbols(struct module *mod, const struct load_info *info) { ...
В параметрах функции передается структура load_info следующего вида
struct load_info { const char *name; /* pointer to module in temporary copy, freed at end of load_module() */ struct module *mod; Elf_Ehdr *hdr; unsigned long len; Elf_Shdr *sechdrs; char *secstrings, *strtab; unsigned long symoffs, stroffs, init_typeoffs, core_typeoffs; struct _ddebug *debug; unsigned int num_debug; bool sig_ok; #ifdef CONFIG_KALLSYMS unsigned long mod_kallsyms_init_off; #endif struct { unsigned int sym, str, mod, vers, info, pcpu; } index; };
Для нас интерес представляют следующие поля:
— hdr — заголовок ELF файла
— sechdrs — указатель на таблицу заголовков секций
— strtab — таблица имен символов — набор строк, разделенных нулями
— index.sym — индекс заголовка секции, содержащей таблицу символов
Первым делом функция получит доступ к секции с таблицей символов. Таблица символов это массив элементов типа Elf64_Sym:
Elf64_Shdr *symsec = &info->sechdrs[info->index.sym]; Elf64_Sym *sym = (void *)symsec->sh_addr;
Далее в цикле проходим по всем символам в таблице, определяя для каждого его имя:
for (i = 1; i < symsec->sh_size / sizeof(Elf_Sym); i++) { const char *name = info->strtab + sym[i].st_name;
Поле st_shndx содержит индекс заголовка секции, в которой этот символ определен. Если там нулевое значение (наш случай), то этого символа нет внутри модуля, искать его надо в ядре:
switch (sym[i].st_shndx) { ..... case SHN_UNDEF: // это 0 ksym = resolve_symbol_wait(mod, info, name); /* Ok if resolved. */ if (ksym && !IS_ERR(ksym)) { sym[i].st_value = kernel_symbol_value(ksym); break; }
Затем приходит очередь релокаций в функции apply_relocations:
static int apply_relocations(struct module *mod, const struct load_info *info) { unsigned int i; int err = 0; /* Now do relocations. */ for (i = 1; i < info->hdr->e_shnum; i++) { .....
В цикле ищем секции релокаций и обрабатываем записи каждой найденной секции в функции apply_relocate_add:
if (info->sechdrs[i].sh_type == SHT_RELA) // нашли секцию релокаций err = apply_relocate_add(info->sechdrs, info->strtab, info->index.sym, i, mod);
В apply_relocate_add передается указатель на таблицу заголовков секций, указатель на таблицу имен символов, индекс заголовка секции с таблицей символов и индекс заголовка секции релокаций:
int apply_relocate_add(Elf64_Shdr *sechdrs, const char *strtab, unsigned int symindex, unsigned int relsec, struct module *me) {
Вначале адресуем секцию релокаций:
Elf64_Rela *rel = (void *)sechdrs[relsec].sh_addr;
Затем в цикле перебираем массив ее записей:
for (i = 0; i < sechdrs[relsec].sh_size / sizeof(*rel); i++) {
Находим секцию для релокации и позицию в ней, т.е. где нам надо внести изменения. Поле sh_info заголовка секции релокации — это индекс заголовка секции для релокации, поле r_offset записи релокации — смещение к позиции внутри секции для релокации:
/* This is where to make the change */ loc = (void *)sechdrs[sechdrs[relsec].sh_info].sh_addr + rel[i].r_offset;
Aдрес символа, который надо подставить в эту позицию, с учетом addendum. Поле r_info записи релокации содержит индекс этого символа в таблице символов:
/* This is the symbol it is referring to. Note that all undefined symbols have been resolved. */ sym = (Elf64_Sym *)sechdrs[symindex].sh_addr + ELF64_R_SYM(rel[i].r_info); val = sym->st_value + rel[i].r_addend;
Тип релокации определяет конечный результат вычислений, в нашем примере это R_X86_64_PLT32:
switch (ELF64_R_TYPE(rel[i].r_info)) { ...... case R_X86_64_PLT32: if (*(u32 *)loc != 0) goto invalid_relocation; val -= (u64)loc; // вычисляем итоговое значение *(u32 *)loc = val; // и заполняем позицию релокации break; .....
Теперь мы можем сами вычислить итоговое val, зная что sym->st_value — адрес функции printk 0xffffffff810b3df9, r_addend равно (-4), смещение к позиции релокации — 0xd от начала текстовой секции модуля, или от начала функции foo, т.е. будет ffffffffc000000d. Подставим все эти значения и получим:
val = (u32)(0xffffffff810b3df9 - 0x4 - 0xffffffffc000000d) = 0xc10b3de8
Посмотрим на дамп функции foo, который мы получили в самом начале:
53 89 fe 89 fb 48 c7 c7 24 10 00 c0 e8 e8 3d 0b c1 8d 43 02 5b c3 66 2e 0f
По смещению 0xD находится значение 0xc10b3de8, идентичное тому, которое мы вычислили.
Вот таким образом ядро обрабатывает релокации и получает необходимое смещение для команды ближнего вызова.
При подготовке статьи использовалась версия ядра Linux 5.4.27.
ссылка на оригинал статьи https://habr.com/ru/post/504442/
Добавить комментарий