内核符号重定位:从 CVE-2024-26816 说开去

前言 & 背景

CVE-2024-26816 1/sys/kernel/notes 的内容是当前运行的 vmlinux 镜像的 .notes 节,里面存有内核函数 hypercall_page 的地址。此信息可用于绕过 KASLR 保护。

探究内容:x86 内核启动过程中,KASLR 对内核整体镜像做偏移时,是如何修改 .notes 节中的 hypercall_page 地址的?

分析过程

初始链接阶段

对于 OLK-6.6 的 x86 内核,若编译时开启了 CONFIG_XEN 选项,则 arch/x86/xen/xen-head.S 会将汇编函数 hypercall_page 加入到 vmlinux 镜像的 .note.Xen 节:

 1/*
 2 * arch/x86/xen/xen-head.S
 3 *
 4 * It is included in arch/x86/kernel/head_64.S:
 5 *     #include "../../x86/xen/xen-head.S"
 6 */
 7
 8#ifdef CONFIG_XEN
 9SYM_CODE_START(hypercall_page)
10	...
11SYM_CODE_END(hypercall_page)
12
13	/* .note.Xen */
14	ELFNOTE(Xen, XEN_ELFNOTE_HYPERCALL_PAGE, _ASM_PTR hypercall_page)
15
16#endif

链接脚本 arch/x86/kernel/vmlinux.lds.S 中指出,vmlinux 的 .notes 节由各链接文件的 .note.* 节组合形成:

 1/* arch/x86/kernel/vmlinux.lds.S */
 2SECTIONS
 3{
 4	.text : ... {
 5		...
 6	}
 7	/* include/asm-generic/vmlinux.lds.h */
 8	RO_DATA(PAGE_SIZE) --(expand)--> NOTES --(expand)-->
 9		.notes : ... {
10			BOUNDED_SECTION_BY(.note.*, _notes)
11		}
12	.data : ... {
13		...
14	}
15}
16
17/*
18 生成 arch/x86/kernel/vmlinux.lds 中的:
19 .notes : AT(ADDR(.notes) - 0xffffffff80000000) { __start_notes = .; KEEP(*(.note.*)) __stop_notes = .; }
20*/

在链接生成可执行2的 vmlinux 时,arch/x86/Makefile 指定了链接器选项 --emit-relocs

1ifdef CONFIG_X86_NEED_RELOCS
2LDFLAGS_vmlinux := --emit-relocs --discard-none
3else
4LDFLAGS_vmlinux :=
5endif

该选项为 vmlinux 中所有涉及*重定位(Relocation)*的节3生成对应的 .rela.* 节。由于 .notes 节带有 hypercall_page,而后者是一个全局符号4涉及重定位,因此会生成 .rela.notes 节。注意 rela.notes.info 字段(即下方的 Inf)的值为 22,为 .notes 的序号。

1$ readelf -SW vmlinux
2[Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
3...
4[22] .notes            NOTE            ffffffff8255a2fc 175a2fc 0000f0 00   A  0   0  4
5[23] .rela.notes       RELA            0000000000000000 18571990 000018 18   I 81  22  8

“链接后”阶段

vmlinux 完成链接并生成后,arch/x86/Makefile.postlink 会被触发。此文件的开头注释解释了这一*阶段(Pass)*的工作:

  1. Separate relocations from vmlinux into vmlinux.relocs.
  2. Strip relocations from vmlinux.

具体体现为该文件中 $(call cmd,relocs)$(call cmd,strip_relocs) 两个过程:

 1CMD_RELOCS = arch/x86/tools/relocs
 2OUT_RELOCS = arch/x86/boot/compressed
 3quiet_cmd_relocs = RELOCS  $(OUT_RELOCS)/$@.relocs
 4      cmd_relocs = \
 5        mkdir -p $(OUT_RELOCS); \
 6        $(CMD_RELOCS) $@ > $(OUT_RELOCS)/$@.relocs; \
 7        $(CMD_RELOCS) --abs-relocs $@
 8
 9# scripts/Makefile.lib
10quiet_cmd_strip_relocs = RSTRIP  $@
11      cmd_strip_relocs = \
12        $(OBJCOPY) --remove-section='.rel.*' --remove-section='.rel__*' \
13                   --remove-section='.rela.*' --remove-section='.rela__*' $@
14
15# `@true` prevents complaint when there is nothing to be done
16
17vmlinux: FORCE
18        @true
19ifeq ($(CONFIG_X86_NEED_RELOCS),y)
20        $(call cmd,relocs)
21        $(call cmd,strip_relocs)
22endif

重点关注 relocs 过程,将 cmd_relocs 中的 Makefile 语句展开后得到如下 Bash 命令:

1mkdir -p arch/x86/boot/compressed
2arch/x86/tools/relocs vmlinux > arch/x86/boot/compressed/vmlinux.relocs
3arch/x86/tools/relocs --abs-relocs vmlinux

通过解读 arch/x86/tools/relocs 工具的源码,其实质是按照 ELF 格式解析 vmlinux,通过节头表(Section Header)遍历所有的节,找到其中的 .rela 节并从 r_offset5 中获取所有需要被重定位修改的代码地址,将这些地址罗列成一个列表 relocs64,并最终将其输出形成 vmlinux.relocs

 1static int do_reloc64(..., Elf_Rel *rel, ...) {
 2	unsigned r_type = ELF64_R_TYPE(rel->r_info);
 3	ElfW(Addr) offset = rel->r_offset;
 4	switch (r_type) {
 5	case R_X86_64_64:
 6		add_reloc(&relocs64, offset);
 7		break;
 8	}
 9}
10
11static void emit_relocs(int as_text, int use_real_mode) {
12	do_reloc = do_reloc64;
13	walk_relocs(do_reloc); // iterate through all sections, process those of .rela
14	sort_relocs(&relocs64);
15	for (i = 0; i < relocs64.count; i++)
16		write_reloc(relocs64.offset[i], stdout);
17}

随后,根据 arch/x86/boot/compressed/Makefile,将 vmlinux.binvmlinux.relocs 前后拼接在一起形成 vmlinux.bin.all,并最终经过压缩形成内核镜像产物。

1# vmlinux.bin is:
2#       vmlinux stripped of debugging and comments
3# vmlinux.bin.all is:
4#       vmlinux.bin + vmlinux.relocs
5vmlinux.bin.all-y := $(obj)/vmlinux.bin
6vmlinux.bin.all-$(CONFIG_X86_NEED_RELOCS) += $(obj)/vmlinux.relocs

实践验证方式

可以通过编译一个简单的 C 程序,来了解重定位节的内容。

 1# 向链接器传入 --emit-relocs 选项,
 2# 生成带有重定位节的可执行文件
 3echo "int main() { return 0; }" | gcc -x c - -Wl,--emit-relocs -g
 4
 5# 解析重定位节,其中 .rela.text
 6# 会包含关于 main() 的重定位信息
 7readelf -r ./a.out
 8
 9# 通过 GDB 查看其 Offset 列的地址,可以看到
10# 就是 start() 调用 main() 的位置

内核启动阶段

在完成建立早期页表 、进入 64 位模式等一系列工作后,执行流转入内核镜像自带的解压器,开始执行解压内核的工作,即 arch/x86/boot/compressed/misc.c 中的 extract_kernel()。此过程涉及内核地址随机化(KASLR):

 1/*
 2 * input_{data,len}: 压缩镜像的起始地址和长度,全局变量
 3 */
 4extract_kernel()
 5	choose_random_location(input_data, input_len, &output, ..., &virt_addr)
 6		random_addr = find_random_phys_addr(min_addr, output_size);
 7		*output = random_addr;
 8		random_addr = find_random_virt_addr(LOAD_PHYSICAL_ADDR, output_size);
 9		*virt_addr = random_addr;
10	entry_offset = decompress_kernel(outbuf:output, virt_addr, ...)
11		__decompress(input_data, input_len, ..., outbuf, output_len, ...)
12		handle_relocations(outbuf, output_len, virt_addr)

KASLR 的本质是整体内核镜像的随机偏移。可以看到,choose_random_location() 选定的物理地址空间偏移量和虚拟地址空间偏移量分别体现在 outputvirt_addr 两个地址上。__decompress() 将内核解压缩至 output 地址,此时 output 上承载的就是上文提到的 vmlinux.bin.all。随后 handle_relocations() 开始解析 vmlinux.bin.all,从中找到 vmlinux.relocs,并结合这些重定向信息以及随机偏移,对内核镜像中各个需要重定位的位置实施修改。

 1void handle_relocations(void *output, unsigned long output_len, unsigned long virt_addr)
 2{
 3	...
 4	/*
 5	 * ... Each relocation table entry is the kernel
 6	 * address of the location which needs to be updated stored as a
 7	 * 32-bit value which is sign extended to 64 bits.
 8	 *
 9	 * Format is:
10	 *
11	 * kernel bits...
12	 * 0 - zero terminator for 64 bit relocations
13	 * 64 bit relocation repeated
14	 * 0 - zero terminator for inverse 32 bit relocations
15	 * 32 bit inverse relocation repeated
16	 * 0 - zero terminator for 32 bit relocations
17	 * 32 bit relocation repeated
18	 *
19	 * So we work backwards from the end of the decompressed image.
20	 */
21	for (reloc = output + output_len - sizeof(*reloc); *reloc; reloc--) {
22		...
23	}
24#ifdef CONFIG_X86_64
25	while (*--reloc) {
26		...
27	}
28	for (reloc--; *reloc; reloc--) {
29		...
30	}
31#endif
32}

注意:此刻解压缩器应该已经在使用虚拟地址,并且(早期)页表已有建立,但这个页表应该实现的是 VA-PA 完全一致的映射,即 VA 的值完全等于 PA。而 .rela.* 节中记录的是内核加载的虚拟地址6

 1/* arch/x86/kernel/vmlinux.lds.S */
 2SECTIONS
 3{
 4	. = __START_KERNEL;
 5	.text : ... {
 6		...
 7	}
 8	...
 9}
10/* arch/x86/include/asm/page_types.h */
11#define LOAD_PHYSICAL_ADDR	((CONFIG_PHYSICAL_START ...
12#define __START_KERNEL		(__START_KERNEL_map + LOAD_PHYSICAL_ADDR)
13/* arch/x86/include/asm/page_64_types.h */
14#define __START_KERNEL_map	_AC(0xffffffff80000000, UL)

因此有了 map 这个变量,作用于 extended 上。这个 extended 是(由 .rela.* 记录的)内核镜像中引用了某个全局符号的位置,本应属于内核编译时地址,但由于有链接器脚本,该地址等价于内核加载虚拟地址。由于此刻正在(通过 self map)使用物理地址,因此通过 map 将其转变为带偏移的内核加载物理地址

最后再对这些内核镜像位置实施修改,添加运行时虚拟地址的随机偏移量。

 1void handle_relocations(void *output, unsigned long output_len, unsigned long virt_addr)
 2{
 3	unsigned long delta, map, ptr;
 4	unsigned long min_addr = (unsigned long)output;
 5
 6	delta = min_addr - LOAD_PHYSICAL_ADDR;
 7
 8	/*
 9	 * The kernel contains a table of relocation addresses. Those
10	 * addresses have the final load address of the kernel in virtual
11	 * memory. We are currently working in the self map. So we need to
12	 * create an adjustment for kernel memory addresses to the self map.
13	 * This will involve subtracting out the base address of the kernel.
14	 */
15	map = delta - __START_KERNEL_map;
16
17	if (IS_ENABLED(CONFIG_X86_64))
18		delta = virt_addr - LOAD_PHYSICAL_ADDR;
19
20	...
21
22	/* 64 bit relocation repeated */
23	for (reloc--; *reloc; reloc--) {
24		long extended = *reloc;
25		extended += map; // 内核镜像中某个使用到全局符号的位置(全局符号被调用处)
26
27		ptr = (unsigned long)extended;
28		if (ptr < min_addr || ptr > max_addr)
29			error("64-bit relocation outside of kernel!\n");
30
31		*(uint64_t *)ptr += delta; // 修改镜像中的指令内容,调整全局符号的地址
32	}
33}

参考资料


  1. 与 CVE 关联的补丁并不能修复这个问题,要打上后续修补补丁:76e9762d6637 (“x86/boot: Ignore relocations in .notes sections in walk_relocs() too”) 才能真正完成修复。 ↩︎

  2. 即类型为 ET_EXEC 的 ELF 文件。 ↩︎

  3. 可理解为带有*可重定位(relocatable)*的符号。 ↩︎

  4. SYM_CODE_START(hypercall_page) 展开后可见 .globl 标记 。 ↩︎

  5. 关于这些与重定位相关的字段的具体含义,可以阅读 System V ABI 的描述。 ↩︎

  6. 用到的 __START_KERNEL_mapx86 虚拟地址空间排布 中映射至物理地址 0 处的虚拟地址,由此开始的 512 MB 的空间为内核代码的映射区。 ↩︎


Vim: Search for What You Select