Решим простую задачу — выделим в пространстве ядра 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.