Немного о релокациях в ядре Linux

от автора

Решим простую задачу — выделим в пространстве ядра Linux блок памяти, поместим в него какой-нибудь бинарный код и выполним его. Для этого напишем модуль ядра, в нем определим функцию foo, которая будет играть роль нужного нам бинарного кода, далее при помощи функции module_alloc выделим блок памяти, скопируем в него через memcpy эту функцию целиком и передадим ей управление.

Вот как это выглядит:

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/


Комментарии

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

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