本文根据 tmp.0ut 第五期中 dominikr 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:A deep dive into how the Linux kernel loads executable files
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:5731
引言
长期以来人们知道,Linux 实现的 ELF 可执行格式对“实际接受什么”比规范写的要灵活得多:Brian Raiter 用它做出极小 ELF,netspooky 用它对抗取证分析。但据作者所知,此前从未有人严格分析过 Linux 内核加载可执行文件的准确过程。本文试图补上这个空白,希望为以后的“恶作剧”铺路(本刊姊妹篇《Creating polyglot ELF files for fun and anti-forensics》就是其中一例)。文章假设读者熟悉 C、汇编和 ELF 结构。
方法论
探索内核源码的主要工具是 Elixir Cross Referencer,它便于跨文件、跨内核版本查定义。作者选择长期维护版本 6.18 作为主参考,希望它不会受短期改动影响。由于时间和硬件限制,无法穷尽所有架构与子架构,因此焦点放在 x86 及其主要子架构上:X86_64、X86_X32_ABI 与 X86_32(64 位内核里对应 IA32_EMULATION),名称沿用 Kconfig。
子架构是什么
一些 CPU 架构有多个主要变体。x86 的重大扩展之一是 64 位模式:64 位 CPU 仍能执行 32 位代码,所以同一个内核既可以跑 32 位 Intel 80386 二进制,也可以跑 64 位 AMD x86-64 二进制。
更复杂的是 x32(elf32_x86_64)格式:它面向 64 位 Intel 系统,使用 32 位指针(因此也是 32 位 ELF 格式),却在 64 位模式下执行。其他架构(RISC-V、MIPS/Loongson、LoongArch)也同时支持 32/64 位软件;Loongson 是 MIPS 的兼容扩展,LoongArch 则曾是 MIPS 扩展但不再保证与原始 MIPS 兼容。有些 CPU 还是双端(bi-endian)的,可以按大端或小端运行;还有“中间端”(middle-endian),本文不讨论。
注意,并非所有架构都能同时运行这些变体——这正是作者说自己既没时间也没硬件去理清的全貌。“子架构”不是内核开发者的官方术语,只是本文用来描述这些潜在架构变体的词。
研究开始时的问题
“内核究竟如何加载 ELF?”是启发本研究的总体问题,而真正把作者带进内核源码的具体问题与 X86_X32_ABI 有关。
如前述,Linux ELF 加载器相当灵活:对 e_machine == EM_386 的 32 位 ELF,它不关心 class 字节(EI_CLASS)是 32 还是 64,也不关心字节序(EI_DATA)是大端还是小端;对 e_machine == EM_X86_64 的 64 位 ELF 同理。这说得通:e_machine 已提供足够信息来推导两者(x86 恒为小端,32/64 位各有不同 e_machine 值)。
那 x32 二进制怎么加载?x32 的 e_machine 与 64 位 ELF 一样是 EM_X86_64,但头部确实用 32 位结构。“内核会在 e_machine == EM_X86_64 时去看 class 字节”之类的假设很快被证伪。这个问题自然可以推广到带多个子架构的其他架构。它还引出另一个问题:能否构造一个 ELF——启用 X86_X32_ABI 时作为 x32 文件加载,禁用时却作为另一个 64 位 ELF 加载?
内核里实际发生什么
几十年来 Linux 内核支持过多种可执行文件类型:从 a.out 开始,1995 年前后过渡到 ELF;此外还有 shell 脚本、binfmt_misc,以及某些平台上的 COFF。从用户态系统调用到内核尝试加载之间的过程与本文无关,因此旅程从 fs/exec.c 开始:
binfmt 入口:fs/exec.c#L1651
* 用每个已注册的 binfmt 尝试 load_binary()
* 若当前 handler 设置了 point_of_no_return,继续执行
* 返回 ENOEXEC 则试下一个格式,否则直接退出
关键点:除非某个格式 handler 返回 ENOEXEC 之外的状态,否则内核会一直尝试下一个可执行格式——也就是试遍所有加载器,直到某个说“我能加载”。point_of_no_return 字段就是这个信号(exec.c:1674)。
各格式的尝试顺序既没有很好的文档记录,从源码看也不直观,似乎是 fs/Makefile 里隐式定义的。下面是每个相关 binfmt handler 在什么条件下返回 ENOEXEC、什么条件下继续,按内核处理顺序列出。非 ELF handler 只简单带过。
load_misc_binary(binfmt_misc)
https://elixir.bootlin.com/linux/v6.18/source/fs/binfmt_misc.c#L202
* 几乎什么都能接受,取决于运行时配置
binfmt_misc 排在第一个,允许用户在运行时任意定义新格式。除非命中已定义的匹配,否则它总是返回 ENOEXEC。
load_script(binfmt_script)
https://elixir.bootlin.com/linux/v6.18/source/fs/binfmt_script.c#L34
* 必须以 #! 开头(原页此处误作 !#)
* 跳过首尾空格/Tab,中间非空部分可执行
它永远不会干扰 ELF 加载,因为脚本头要求与 ELF 格式互斥。
load_elf_binary(binfmt_elf)
https://elixir.bootlin.com/linux/v6.18/source/fs/binfmt_elf.c#L832
* 检查 ELFMAG == "\177ELF" (857)
* 类型是 ET_EXEC 或 ET_DYN (860)
* elf_check_arch: (862)
* e_machine == EM_X86_64
* elf_check_fdpic:x86 不支持 (864)
* can_mmap_file:与内容无关 (866)
* load_elf_phdr: (869)
* 检查 e_phentsize == 0x38 (531)
* e_phnum > 0 (537)
* e_phnum*e_phentsize <= 65536 (537)
* 对每个 phdr:
* 若 PT_INTERP:
* 2 <= p_filesz <= PATH_MAX (4096) (890)
* 能否分配内存?否则 ENOMEM (894)
* PATH 以 NUL 结尾 (904)
* 找不到 interp?ENOENT 并退出 (909)
* PT_GNU_STACK:检查 X 标志 (942)
* parse_elf_properties(exec 或可用的 interp) (993)
* 仅 ARM64
* arch_check_elf (1003)
* 仅 ARM64、MIPS、LoongArch
* begin_new_exec (1010)
* 设置 point_of_no_return,加载完成 (1020)
这是 Linux ELF 加载/校验的主体。它印证了许多非正式已知的事实,同时揭示了 ELF 加载的准确顺序与细节。值得注意的是:即使内核支持 32 位与 x32 二进制,这里 e_machine 也必须是 EM_X86_64。
(compat)load_elf_binary(compat_binfmt_elf)
https://elixir.bootlin.com/linux/v6.18/source/fs/compat_binfmt_elf.c
* 基本就是 binfmt_elf,用少量宏加载 32 位 compat 格式
* compat_elf_check_arch:
* e_machine == EM_386 || EM_486 || EM_X86_64
* load_elf_phdr:
* 检查 e_phentsize == 0x20
这是理解 Linux ELF 加载的另一块关键拼图:compat_binfmt_elf.c 只是 binfmt_elf.c 的一层很薄的包装,通过重定义几个关键宏,同时支持 32 位与 x32 二进制。它与普通 load_elf_binary 的关键差异如上。
讨论
回答最初的问题
走完内核内部步骤后,x32 的加载之谜解开了:
内核先把 x32 二进制当作普通 64 位二进制,在 binfmt_elf.c 里加载。e_machine == EM_X86_64 的检查能通过,但 e_phentsize == 0x38 的检查几乎必然失败——因为该字段在 32 位与 64 位 ELF 里位于不同偏移,按 64 位解释会读到垃圾值。
于是内核尝试下一个 binfmt handler,也就是 compat_binfmt_elf.c。这次会成功:EM_X86_64 仍然被允许,而 e_phentsize 检查现在按 32 位 ELF 的正确偏移找 0x20。
至于第二个问题——“只在启用 X86_X32_ABI 时作为 x32 加载”——似乎不可能实现:内核总是先按 64 位尝试,失败后才按 x32 尝试。用 PT_INTERP 解释器路径做开关也不行,因为那会让 binfmt handler 返回 ENOENT,从而停止后续所有 handler。
缺失的字节序检查
另一个有趣的点:任何地方都没有字节序测试。可执行文件的字节序只是隐式假设与当前系统一致。实际中这会在类型检查(ET_EXEC/ET_DYN)处失败——e_type 是第一个多字节字段,也就是字节序第一个起作用的地方(ELFMAG == "\177ELF" 是按字节比较,与字节序无关,所以总能通过)。
内核架构的谨慎
内核开发者似乎刻意不引入依赖外部因素的决策点(例如解释器是否存在 [ENOENT]、能否分配大量内存 [ENOMEM]),而是让执行以致命错误失败。如果内存分配不以 ENOMEM 失败,它本可被用来区分支持五级页表与不支持的 CPU(详见 lm978 的文章)。在 binfmt_elf.c 执行后期,能“非致命”失败的方式似乎只有错误的 phentsize 或非法的 PT_INTERP 头。
把新知识用于“善”
更稳健地识别 ELF 文件
我们已经知道内核实际解析 ELF 的部分少得可怜,以及这让“判断二进制到底是 32 还是 64 位、大端还是小端”变得复杂。能否利用这些知识造出更稳健的识别方法?
首先要确定字节序,因为它影响 ELF 头里每个多字节字段的解释。内核本身不检查字节序,但紧接着就能推出:内核尝试加载字节序错误的文件时,几乎必然在类型检查处失败。e_type 很适合做这个检查:我们关心的值很少(ET_EXEC=2、ET_DYN=3),而且它位于 ELF 头很靠前的位置 0x10——32 位与 64 位 ELF 在这里的字段偏移还没有分叉。
怎么判断 32/64 位?e_machine 不合适,多个子架构可以共享同一个值;但 e_phentsize 字段合适,因为 32 位与 64 位 ELF 有两个不同取值——更准确地说,是位于两个不同位置的 e_phentsize 字段。
最终算法:
1. 检查文件开头是否为 "\177ELF"
2. 从偏移 0x10 按小端读 e_type
若值 < 0xFE 假设为小端,否则假设为大端
若不是 ET_EXEC 或 ET_DYN 则告警
3. 从偏移 0x2A 读 e_phentsize32,从偏移 0x36 读 e_phentsize64
若 e_phentsize32 == 0x20 假设为 32 位
若 e_phentsize64 == 0x38 假设为 64 位
若两个值都匹配,告警“该文件可能是 polyglot ELF”
4. 检查 e_phnum > 0,且 e_phnum*e_phentsize <= 65536
第 1 步与内核做同样的测试,判断文件是否“可能”是 ELF。字节序测试扩展到 值 < 0xFE,是为了正确识别 ET_LOOS、ET_HIOS、ET_LOPROC、ET_HIPROC 这类 ELF 类型。
概念验证 elfid 及其验证
作者用 shell 脚本实现了该算法,取名 elfid——选 shell 是因为任何支持 ELF 二进制的系统都预装它。工具像 Linux 内核那样检查 ELF 文件,若文件不会被加载就打印诊断;输出风格类似 file 工具,发现不一致时会告警。
测试语料用了 Radare2 与 Rizin 的测试集。有趣的是,这些语料里没有任何文件在 32/64 位或字节序标志上存在不一致。对 Brian Raiter 收集的 tiny-x64 运行,工具输出与 GNU file 的对比:
$ file tiny-x64
tiny-x64: ELF, unknown class 176
$ ./elfid tiny-x64
EI_CLASS has illegal value: 176, should be 2 (ELFCLASS64)
EI_DATA has illegal endian value: 41, should be 1 (little)
EI_VERSION has illegal version: 94, should be 1
tiny-x64: ELF 64-bit LSB pie executable, x86-64, version 94 (unknown ABI 64)
语料中出现的所有 e_machine 类型与 OSABI 类型都被加进程序。binutils readelf 或 elfutils readelf 里有更大的 e_machine 数据库,但没有收录,因为许多列出的架构找不到任何真实 ELF 文件。若设置非空的环境变量 FORCE,elfid 遇到错误仍会继续——分析严重损坏的 ELF 时可能有用。
把新知识用于“恶”
利用少见的 e_machine == EM_486
一个意外发现是内核支持 EM_486(6)这个 e_machine 值。它是更常见的 EM_386(3)的别名,但许多工具支持得很差。作者在另一篇文章里记录了把它用于反取证的用法。
利用 PT_GNU_STACK 解析差异
PT_GNU_STACK 程序头用于把 ELF 可执行文件的栈显式标记为可执行或不可执行。内核只使用标志字段的最低有效位(PF_X),而 checksec 之类的工具过去会同时看全部 3 个权限位。于是只要把权限位设成 WE 而不是 RWE,就能骗 checksec 以为文件有不可执行栈。第一个 bug 几年前已修复,但还藏着一个:内核会遍历所有 phdr,每遇到一个 PT_GNU_STACK 就更新一次 executable_stack 标志;而新版 Go 版 checksec 遇到第一个 PT_GNU_STACK 就返回,至今仍受影响。
后续工作
- 如前述,本刊已有同一作者的姊妹篇(polyglot ELF);
- 扩展到 ARM/ARM64、RISC-V 等其他架构;
- 研究其他操作系统(尤其 *BSD 家族)的 ELF 加载器。初步研究显示,可以造出一个能在 FreeBSD Linux 兼容层运行、却不能在真实 Linux 上运行的 ELF。
参考
- Brian Raiter 的微型 ELF 文章
- netspooky 的反取证文章(tmp.0ut 2/3)
- ELF 规范
- Elixir Cross Referencer
- 作者的 BGGP6 writeup(EM_486 相关)
- lm978 的五级页表文章(tmp.0ut 3/22)
- checksec 修复第一个 bug 的提交
- checksec nx.go 相关源码
- ELF 头字段布局(维基百科)
- radare2 测试二进制
- rizin 测试二进制
- Brian Raiter 的 return42 页面
附录 A:elfid 工具
原文以 base64 + gzip 的形式给出了 elfid 的完整实现,解码即可得到脚本:
base64 -d<<'E-O'|zcat>"elfid"&&echo OK
<原文此处为完整 base64 数据块,未在此转录>
E-O
需要精确的脚本内容请直接从原页复制 base64 数据。