关于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

一切正常。现在,我们将printk函数添加到foo中以显示参数:

static noinline int foo(int ret)
{
	printk(KERN_INFO "ret=%d\n", ret);
	return (ret + 2);
}

在将控制权传递给它之前,转储new_foo()函数的内容的25个字节:

	memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE);
	dump((unsigned long)new_foo);

转储定义为

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

尽管我们不得不调用printk,但还是以某种方式结束了irq_create_direct_mapping函数。让我们找出发生了什么。

首先,看一下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调用命令e8的操作码位于-附近,因为它在当前代码段中执行,所以选择器值不会更改。接下来的4个字节是相对于RIP寄存器中的值的偏移量,控制将转移到该值中。根据英特尔文档(英特尔64和IA-32体系结构软件开发人员手册,指令集参考AZ),RIP = RIP +偏移:
相对偏移量(rel16或rel32)通常在汇编代码中指定为标签。但是在机器代码级别,它被编码为带符号的16位或32位立即数。
此值将添加到EIP(RIP)寄存器中的值。在64位模式下,相对偏移始终是32位立即数,在将其添加到RIP寄存器中的值以进行目标计算之前,将其符号扩展为64位。

我们知道函数foo的地址,它是0xffffffffc0000000,所以在RIP = 0xffffffffc0000000 + 0xc + 0x5 = 0xffffffffc00000011(0xc是e8指令的偏移量,该指令的1个字节,该偏移量的4个字节)。我们知道偏移量,因为 甩掉身体的功能。让我们计算将我们发送到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代表可执行和可链接格式-可执行文件和可组合文件的格式。 ELF文件是部分的集合。该部分存储了链接器形成可执行映像所需的一组对象-指令,数据,符号表,重定位记录等。每个部分都有一个标题描述。所有标头都收集在标头表中,并且本质上是一个数组,其中每个元素都有一个索引。节标题包含到节开头的偏移量和其他开销信息,例如,通过在标题表中指定索引来链接到其他节。

在组装我们的测试用例时,编译器不知道printk函数的地址,因此它用零值填充调用位置,并使用重定位记录告诉内核该位置必须用有效值填充。重定位记录包含要更改的位置(重定位位置)的偏移量,重定位的类型以及符号表中符号的索引,必须在指定的偏移量处替换其地址。重定位的类型是什么?重定位记录的该部分的标题通过索引指向带有字符和部分表的该部分的标题,相对于该字符的开头部分指定了重定位位置的偏移量。

您可以使用带有-r开关的objdump实用程序来查看重定位记录的内容。
从反汇编的清单中,我们知道在偏移量0xD处必须写入printk函数的地址,因此我们在以下位置寻找objdump输出:

000000000000000d R_X86_64_PC32     printk-0x0000000000000004

因此,我们有必要的重定位记录,指示偏移量0xD处的位置,以及将地址写入该位置的符号的名称。

值(-4)。添加到printk函数的地址的地址称为附录,在计算重定位的最终结果时将其考虑在内。

现在看一下printk符号:

$ objdump -t exe.ko | grep printk
0000000000000000         *UND*	0000000000000000 printk

有一个符号,它在模块内部是未定义的(未定义),因此我们将在内核中搜索它。

以二进制形式查看重定位和符号的记录将更为有用。这可以使用wireshark来完成,它可以解析ELF格式。这是我们的重定位项(从左侧的LSB的writeshark复制粘贴):

  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,字符表0x00000022中的4个字节索引(或十进制的34个)和8个字节的附录-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表中此字符名称的索引,即印刷品。 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

内核加载模块时,它将查找所有未定义的字符,并用有效值填充st_value和st_size字段。这是在kernel / module.c文件的simple_symbols函数中完成的:

/* 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;

考虑到附录,在此位置要替换的字符的地址。重定位条目的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;
	.....

现在我们可以自己计算最终值,知道sym-> st_value是printk函数0xffffffff810b3df9的地址,r_addend是(-4),重定位位置的偏移量是从模块文本部分的开头或从foo函数的开头开始的0xd。将为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,该值与我们计算出的值相同。

这就是内核如何处理重定位并为close call命令获取必要的偏移量的方式。

在准备本文时,使用了Linux内核版本5.4.27。

All Articles