Решим простую задачу — выделим в пространстве ядра Linux блок памяти, поместим в него какой-нибудь бинарный код и выполним его. Для этого напишем модуль ядра, в нем определим функцию foo, которая будет играть роль нужного нам бинарного кода, далее при помощи функции module_alloc выделим блок памяти, скопируем в него через memcpy эту функцию целиком и передадим ей управление.
Вот как это выглядит:
Функция exe_init вызывается при загрузке модуля. Результат работы смотрим в логе ядра:
Все работает правильно. А теперь добавим в foo функцию printk для отображения аргумента:
и сдампим 25 байт содержимого функции new_foo() перед тем как передать ей управление:
dump определим как
Загружаем модуль и получаем краш со следующим сообщением в логе:
Каким-то образом мы оказались в функции irq_create_direct_mapping, хотя должны были вызвать printk. Давайте разбираться что произошло.
Вначале посмотрим на дизассемблерный листинг функции foo. Получим его при помощи команды objdump -d:
Функция 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):
Адрес функции foo мы знаем, это 0xffffffffc0000000, значит в RIP = 0xffffffffc0000000 + 0xc + 0x5 = 0xffffffffc00000011 (0xc — смещение к команде e8, 1 байт команды и 4 байта смещения). Смещение мы знаем, т.к. сдампили тело функции. Вычислим куда отправит нас вызов call в функции foo:
Это адрес функции printk:
А теперь тоже самое для случая new_foo, адрес которой 0xffffffffc0007000
Такого адрес в kallsyms нет, но есть 0xffffffff810badf9 — 0x79 = 0xffffffff810bad80
Это та самая функция, на которой случился краш.
Чтобы предотвратить краш, достаточно пересчитать смещение, зная адрес функции new_foo:
После этого исправления крашей не будет, функция new_foo успешно отработает и вернет управление.
Задача решена. Осталось только разобраться почему в дизассемблерном листинге смещение после опкода e8 нулевое, а в дампе функции нет. Для этого надо рассмотреть, что такое релокации и как с ними работает ядро. Но вначале немного о формате ELF.
ELF это аббревиатура Executable and Linkable Format — формат исполнимых и компонуемых файлов. Файл формата ELF представляет собой набор секций. Секция хранит набор объектов, необходимых линкеру для формирования исполняемого образа — инструкции, данные, таблицы символов, записи о релокациях и т.п. Каждая секция описывается заголовком. Все заголовки собраны в таблицу заголовков и по сути представляют собой массив, где каждый элемент имеет индекс. Заголовок секции содержит смещение к началу секции и прочую служебную информацию, например ссылки на другие секции посредством указания индекса в таблице заголовков.
При сборке нашего тестового примера компилятор не знает адрес функции printk, поэтому заполняет место вызова нулевым значением и при помощи записи релокации сообщает ядру, что эту позицию надо заполнить валидным значением. Запись релокации содержит смещение к позиции, где надо внести изменения (позиция релокации), тип релокации и индекс символа в таблице символов, адрес которого надо подставить по указанному смещению. Для чего нужен тип релокации рассмотрим ниже. Заголовок секции записей релокаций ссылается через индексы на заголовки секции с таблицей символов и секции, относительно начала которой задается смещение к позиции релокации.
Посмотреть на содержимое записей релокаций можно при помощи утилиты objdump с ключем -r.
Из дизассемблерного листинга нам известно, что по смещению 0xD необходимо записать адрес функции printk, поэтому ищем в выводе objdump релокацию с такой позицией:
Итак, у нас есть необходимая запись релокации, указывающая на позицию по смещению 0xD, и имя символа, адрес которого надо в эту позицию записать.
Значение (-4). которое добавляется к адресу функции printk, называется addendum, и оно учитывается при вычислении окончательного результата релокации.
Теперь посмотрим на символ printk:
Символ есть, он неопределен внутри модуля (undefined), значит искать его будем в ядре.
Более информативно будет взглянуть на записи релокации и символов в бинарном виде. Сделать это можно при помощи wireshark, он умеет парсить ELF формат. Вот наша запись релокации (копипаст из writeshark, LSB слева):
Сопоставим эту запись с определением соответствующей структуры из <linux/elf.h>:
Тут у нас 8 байт смещения 0x00000000d, 4 байта тип 0x00000002, 4 байта индекс в таблице символов 0x00000022 (или 34 в десятичной) и 8 байт addendum -4.
А вот запись из таблицы символов под номером 34:
и соответствующая структура
Первые 4 байта 0x00000101 — индекс в таблице строк .strtab к имени данного символа, т.е. printk. Поле st_info определяет тип символа, это может быть функция, объект данных и т.п., более детально смотрите в ELF спецификации. Поле st_other пропустим, сейчас оно для нас интереса не представляет, и посмотрим на три последних поля st_shndx, st_value и st_size. st_shndx — индекс заголовка секции, в которой определен символ. Мы видим тут нулевое значение, т.к. символ не определен внутри модуля, его нет в имеющихся секциях.
Соответственно его значение st_value и размер st_size также нулевые. Эти поля заполнит ядро при загрузке модуля.
Для сравнения посмотрим на символ foo, который явно присутствует:
Символ определяет функцию, которая находится в секции .text по адресу относительно начала секции 0x00000000, т.е. в самом начале секции, как мы видели в дизассемблерном листинге, размер функции 22 байта.
Такую же информацию об этом нам покажет и objdump:
Когда ядро загружает модуль, оно находит все Undefined символы и заполняет поля st_value и st_size валидными значениями. Делается это в функции simplify_symbols, файл kernel/module.c:
В параметрах функции передается структура load_info следующего вида
Для нас интерес представляют следующие поля:
— hdr — заголовок ELF файла
— sechdrs — указатель на таблицу заголовков секций
— strtab — таблица имен символов — набор строк, разделенных нулями
— index.sym — индекс заголовка секции, содержащей таблицу символов
Первым делом функция получит доступ к секции с таблицей символов. Таблица символов это массив элементов типа Elf64_Sym:
Далее в цикле проходим по всем символам в таблице, определяя для каждого его имя:
Поле st_shndx содержит индекс заголовка секции, в которой этот символ определен. Если там нулевое значение (наш случай), то этого символа нет внутри модуля, искать его надо в ядре:
Затем приходит очередь релокаций в функции apply_relocations:
В цикле ищем секции релокаций и обрабатываем записи каждой найденной секции в функции apply_relocate_add:
В apply_relocate_add передается указатель на таблицу заголовков секций, указатель на таблицу имен символов, индекс заголовка секции с таблицей символов и индекс заголовка секции релокаций:
Вначале адресуем секцию релокаций:
Затем в цикле перебираем массив ее записей:
Находим секцию для релокации и позицию в ней, т.е. где нам надо внести изменения. Поле sh_info заголовка секции релокации — это индекс заголовка секции для релокации, поле r_offset записи релокации — смещение к позиции внутри секции для релокации:
Aдрес символа, который надо подставить в эту позицию, с учетом addendum. Поле r_info записи релокации содержит индекс этого символа в таблице символов:
Тип релокации определяет конечный результат вычислений, в нашем примере это R_X86_64_PLT32:
Теперь мы можем сами вычислить итоговое val, зная что sym->st_value — адрес функции printk 0xffffffff810b3df9, r_addend равно (-4), смещение к позиции релокации — 0xd от начала текстовой секции модуля, или от начала функции foo, т.е. будет ffffffffc000000d. Подставим все эти значения и получим:
Посмотрим на дамп функции foo, который мы получили в самом начале:
По смещению 0xD находится значение 0xc10b3de8, идентичное тому, которое мы вычислили.
Вот таким образом ядро обрабатывает релокации и получает необходимое смещение для команды ближнего вызова.
При подготовке статьи использовалась версия ядра Linux 5.4.27.
Вот как это выглядит:
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.
arero
Привет! спасибо за статью!
Попытался более подробно вникнуть и возникло пару вопросов, скорее всего потому что я дилетант в асемблере.
0xffffffffc10b3de8 — а почему такой большой офсет? вроде бы просто должно быть c10b3de8 судя по дампу
Вот тут не очень понятно откуда 0x79 взялось?
И самое главное непонятно почему в первый раз ф-ция отработала корректно? ведь при копировании ф-ции foo
у нас опять же смещение в RIP = RIP + offset будет неправильное
ubob74 Автор
Привет.
1. это по требованиям документации, на нее там цитата есть:
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.
т.е. расширяем до 64-битного знакового, если правильно перевел
2. 0x79 — если посмотреть куда попадаем при краше, то это irq_create_direct_mapping+0x79, потому и вычитаем это значение из полученного адреса
3. В первый раз у нас нет никаких call-ов внутри foo, и значит нет офсетов, которые надо пересчитывать