本文根据 tmp.0ut 第五期中 bah 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:Static Kernel Patching Redux
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:7742
引言
Phrack 60-8 里 jbtzhm 写了一篇如何修改磁盘上的内核映像以植入后门的文章。作者觉得这是很有趣的后门思路,但此后没见到有人对现代 x86 内核映像做同样尝试,于是开始思考如何复活这项技术。原文章发表以来,大家已经全面转向 64 位系统,UEFI 成为主流;BIOS 路径虽然仍存在于虚拟化环境,但内核与引导加载器对它的实现也经历了大改。
Phrack 那篇文章之外的先例有限:一些专门支持 32 位 ARM 的工作涉及重新打包内核、调整解压用的各种偏移;bootkitty 与作者的 bootkit grabit 展示了挂钩实现;PciLeech(DMA 攻击工具)通过运行时挂钩实现 Linux UEFI 支持,也有相关代码;Phrack 68-11 里感染 LKM 的工作在改动代码后依然可用。
本文讨论作者修改 x86 内核映像的方法:涉及 UEFI PE、内核 ELF、UEFI 与 BIOS 引导过程的相关部分,以及一些链接技巧。该技术同时支持 UEFI 与 BIOS 启动路径,最终以 kSHELF(tmp.0ut #4 描述)作为可加载的最终载荷,载荷空间约 128KB,通常足够(不够还能再分阶段加载组件)。
完整源码在 https://github.com/bahorn/skp。核心工作始于 2024 年底,2026 年做了大重写以修 bug、提升可靠性;已自动化并测试了 Linux 5.15 到 7.0 的 bzImage(有无 GRUB 均可),BIOS 与 UEFI 路径都支持。大部分情况可用,但某些内核配置可能破坏它。
修改 bzImage
x86 内核映像以 UEFI PE 可执行文件形式存在,同时保留支持 BIOS 引导加载器(有时是 GRUB,或虚拟化常用的 QEMU 直接内核引导)的结构。修改映像让内核加载载荷,需要改动 PE 头、为运行时组件新增节,并修改 BIOS 启动路径相关结构的多个字段。
磁盘上的修改
流程(原文有完整图示):
- 用 vmlinux-to-elf 从 bzImage 提取内核 ELF;
- 从内核 ELF 找空间与符号;
- 添加抽取节并修改 PE 头;
- 修改 boot protocol 值;
- 用 dummy link 确定运行时大小;
- 把运行时与内核、载荷链接;
- 把运行时放进新节;
- 得到 patched bzImage。
提取内核与符号化
bzImage 由加载器/头组件与内嵌的压缩 ELF 组成,加载器在启动时解压 ELF 并映射进内存。从 bzImage 解出内核 ELF 不难(内核树里甚至有脚本),但该 ELF 没有符号,难以确定内核内部位置。好在多数内核包含支持 /proc/kallsyms 的结构,含全部符号及其偏移;实现解析较复杂,作者使用开源项目 vmlinux-to-elf:
uv run --tools path/to/vmlinux-to-elf source.bzImage out.elf
编译与链接运行时
运行时以独立于内核的通用目标文件构建,内部各组件分别构建(UEFI 代码用 -msabi 等不同 cflags),再用 ld -r 打包给 patcher 使用。运行时只在补丁过程中与内核相关:此时用内核 ELF 提取的偏移和链接时才能确定的值与内核链接,这些值通过追加进链接器脚本传入。补丁期链接也会加入最终载荷(比加载运行时更依赖内核)。链接脚本把各组件线性排列(节选):
OUTPUT_FORMAT("binary")
SECTIONS
{
.uefi_hook : { *(.uefi_hook) }
.code32_hook : { *(.code32_hook) }
# Stage1,UEFI 阶段(BIOS 启动时跳过)
.stage1.text.start : ALIGN(16) { *(.stage1.text.start) }
.stage1.text : ALIGN(16) {
*(.stage1.text) *(.stage1.rodata) *(.stage1.data)
*(.stage1.bss) *(.stage1.data.rel.local)
}
# Stage 2:进入内核后运行,用于加载真正的 payload
.stage2 : ALIGN(16) { *(.stage2.*) }
# payload 加载器
.kshelf_loader.start : { *(.kshelf_loader.text.start) }
.kshelf_loader.text : ALIGN(16) {
*(.kshelf_loader.text) *(.kshelf_loader.rodata)
*(.kshelf_loader.data) *(.kshelf_loader.bss)
*(.kshelf_loader.data.rel.local)
}
# 载荷本身
.payload : ALIGN(16) { *(.text) *(.data) *(.bss) }
_kshelf_loader_end = .;
...
}
一个漂亮的细节:payload 是可选的,在 kshelf-loader 里以 weak 属性定义:
__attribute__((weak)) unsigned char payload[0];
__attribute__((weak, section(".data"))) unsigned int payload_len = 0;
补丁期运行时被链接两次:第一次用 dummy 值确定最终运行时大小;第二次用真实值——内核空间(BIOS 路径放运行时的位置)、要修补的地址、调用 kallsyms_lookup_name() 之前需要的符号偏移,以及补丁过程自己算出的 UEFI 入口点。各偏移通过解析链接器的 map 文件(-Map=path/to/mapfile)获得。
调整 PE 头并加入运行时
PE 头要改的地方不少:先移除 PE 上任何签名(Secure Boot 用);旧内核还要移除 .reloc 节(引导不需要它,6.7 起已移除),腾出空间定义自己的节。新节给 RWX 权限,空间足以容纳运行时与载荷(大小来自 dummy link)。此外:
- 修正
SizeOfImage与SizeOfCode以计入新节; - 前一节按 jbtzhm 的方式为 BIOS 路径做填充;
- 禁用 NX_COMPAT 标志(补丁过程目前不支持);
- 把 UEFI Entrypoint 指向新节。
这一步与上一步链接需要“跳舞”:先用 dummy 值确定大小,再传入原 UEFI 入口点与若干 BIOS 路径偏移重新链接。完整逻辑见项目 src/patch-bzimage/add_data.py。
挂钩 BIOS 路径
BIOS 引导加载器用 x86 boot protocol 引导内核。项目只关注 5.15+,因此只需考虑 Protocol 2.15。boot protocol 在 bzImage 偏移 0x1F1 处定义了固定头,供内核与引导加载器通信。相关字段:
code32_start(偏移 0x214):内核转入保护模式时跳转的地址;relocatable_kernel(0x234):内核是否可重定位;pref_address(0x258):内核首选加载地址。
三者都会被改:code32_start 指向作者的 hook(hook 位于固定地址 0x100_000 + 相对内核映像的偏移,不是 PE 头里的虚拟地址);内核被标记为不可重定位;pref_address 设为 0x100_000 以避免相关问题。
运行时
运行时多阶段,按 UEFI 还是 BIOS 启动分支;6.6 之前 UEFI 路径需要挂钩 UEFI runtime service(更复杂,否则容易 panic)——6.6/6.7 前后 x86 启动路径有大量改动,因此这两个版本很关键。
总体流程(原文有完整图示):
BIOS 启动 UEFI 启动
↓ ↓
code32_start Hook Setup ExitBootServices() Hook
↓ ↓
└──────────────┬─────────────────────┤
↓ ↓
Decompression Hook Direct Patching(>6.6) 或 Hook A Runtime Service(全部版本)
↓ ↓
把 payload 复制到 .text 尾部空位 ↓
↓ ↓
Hook initcall 调用 payload ────────┘
↓
Runtime Services Hook
↓
Run the Payload!
code32_start hook(BIOS 入口)
该 hook 在内核转入保护模式时运行,对启动代码做一个小补丁:内核解压完成后调用准备好的另一个 hook。补丁很简单(_offset_bios_entry 是解压 hook 的地址):
push _offset_bios_entry
ret
为此要扫描磁盘内核找两种模式之一(都跳到解压后的内核),6.6 前后各一种,可靠覆盖所有版本:
post 6.6: 4c 89 fe ff e0 f4 eb fd 66 0f 1f 44 00 00
pre 6.6: e8 XX XX XX XX 5e ff e0
即 arch/x86/boot/compressed/head_64.S 中的 jmp rax(ff e0)指令及周边上下文,被替换成对解压 hook 的调用。一个怪癖:内核在 hook 被调用前已重定位,会切掉追加代码,所以不能用相对偏移;原内核映射仍留在低地址固定位置(0x100_000 + 偏移),因此用 push/ret 跳过去。设置完成后调用原入口点(应为 0x100_000)继续启动。
UEFI 入口点
UEFI 入口点已被指向我们的代码,固件启动后直接跳到这里。此时对 ExitBootServices() 挂钩——它总在启动时被调用以离开 UEFI Boot Services 环境。实现只需修改 SystemTable->BootServices 结构里的一个指针(固件会把 SystemTable 传给我们):
EFI_EXIT_BOOT_SERVICES orig_exitbootservices = (EFI_EXIT_BOOT_SERVICES) 0x41424344;
EFI_SYSTEM_TABLE *systable = (EFI_SYSTEM_TABLE *) 0x41424344;
EFI_BOOT_SERVICES *bootservices = (EFI_BOOT_SERVICES *) 0x41424344;
void main(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable) {
bootservices = SystemTable->BootServices;
systable = SystemTable;
orig_exitbootservices = bootservices->ExitBootServices;
bootservices->ExitBootServices = exit_bootservices_hook;
}
设置 hook 的代码用 C 实现,由一个小 stub(UEFI 入口点指向它)调用;stub 保存寄存器并调用原入口点。原入口点经链接步骤传入。正是这种用全局变量保存 SystemTable 引用的做法,让代码目前不适合 NX_COMPAT。
ExitBootServices() hook
这里对 6.6+ 内核有两个选项,更早的版本只有一个:
- 较新内核此时已解压并在内存中,可采用与 BIOS 路径相同的做法(下节详述):把新代码复制进
.text段通常用作填充的部分,用 initcall hook 跳到它。寻找含内核的内存区域通过检查每个映射的大小实现,足够大就用链接期得到的 magic 值确认是不是内核。 - 旧内核或找不到内核映射时,改为挂钩 UEFI runtime service 让内核运行 payload。
UEFI 启动时内核会访问若干 UEFI runtime services:例如设置 efivarfs(运行系统通常位于 /sys/firmware/efi/efivars)发生在 initcall 阶段早期,会调用 RT->GetNextVariableName();更晚时内核从 integrity_load_keys() 调用 RT->GetVariable()(在所有 initcall 完成后)。skp 用前者,因为它在启动过程中更早被调用。runtime hook 路径在 payload 设置完成后需要恢复并调用原函数——这只是简单的结构体更新。
解压 hook
BIOS 路径从低地址(仍映射、含我们代码)继续:把补丁复制进刚解压的内核并挂钩 initcall。UEFI 路径此时仍在 Boot Services,但内核已解压,位于 ExitBootServices() hook 可访问的区域。
为了让内建模块等功能在启动时运行代码,内核实现了 initcalls 机制(内核早期就有,作者惊讶原 Phrack 文章没用它)。initcall 分级别,每一级以列表存储,由 do_initcalls()(内核 /init/main.c)按级启动;级别列表的元素是“从列表位置到目标函数的偏移”(&level[initcall] + level[initcall])。作者挂钩其中一个,把执行重定向到 payload——payload 得先进入内核内存。
为此使用 .text 段里的空位作“腔体”(通常填满 0xCC),补丁期搜索足够大的 0xCC 区容纳 payload;initcall 被改成指向这块回收空间——它是相对内核起始的已知偏移,BIOS 路径里经 rax 寄存器传入,UEFI 路径里就是找到的内存区域起点。作者选择挂钩 regulator_init_complete() initcall:在内核 ELF 里用正则(__initcall__kmod_core[_0-9a-z]*regulator_init_complete[_0-9a-z]*)找到符号;选它没有特别理由,只是很多内核构建里都能找到,任何广泛使用的 initcall 都行。这个解压 hook 与 bootkitty 类似,但更进一步:直接把代码注入内核,而不只是禁用模块签名检查。
运行 payload
各路径汇合后要让有用代码跑起来。作者使用改编自 tmp.0ut #4 的 kSHELF 加载器。
从 initcall 路径运行时,代码被干净地映射进内核内存。内核相对代码加载位置偏移已知,补丁期又从内核 ELF 知道 kallsyms_lookup_name() 的相对地址,因此可以算出它;解析出 kallsyms_lookup_name() 后,实现“把 kSHELF 映射进内存并设为可执行”的加载器所需的其余符号就容易解析了。payload 映射完成后,内核在返回并完成设置后继续启动。
UEFI runtime hook 有几个跨内核版本可靠工作的痛点。UEFI runtime service 被调用时,内核进入临时 mm:UEFI 相关内存被映射,同时内核也保持映射,因此在知道内核地址的前提下(注意 UEFI 与 Linux 的 ABI 差异)可以自由调用内核函数。方法是读栈取 __efi_call + 40 的地址,从而推出内核基址(若知道 __efi_call 相对内核基址的偏移):
mov rax, [rsp]
sub rax, __efi_call + 40
此后可以调用内核里任何符号。但仍有复杂性:有些内核能自由调用、分配内存,另一些(如 5.15)是坏的。最初尝试禁用抢占(percpu 值),但较新内核里会破坏 vmalloc() 等调用,而且很难从这个作用域切出去。变通方案:用带 GFP_ATOMIC 的 kmalloc() 自由分配 payload 内存——约 128KB 的分配正合适(通常更想用 vmalloc(),但自 5.4 起 kmalloc() 也能满足安全调用 set_memory_x() 所需的页对齐)。
随后用 execute_in_process_context()(workqueue 子系统的一部分)在更正常的环境里调用 payload;调用它需要禁用中断,否则它会立即运行传入函数。做法:调用 irq_enter_rcu()、保存 eflags、调用 execute_in_process_context()、恢复 eflags、调用 irq_exit_rcu()。不恢复 eflags 内核会抱怨 firmware bug(它期望 eflags 一致,irq enter/exit 函数会轻微弄乱它)。作者用 irq_{enter,exit} 是因为它们能用 kallsyms_lookup_name() 解析,比直接改 percpu 值更可靠:
asm ("pushf; pop %0" : "=rm" (flags) : : "memory"); // 保存 eflags
irq_enter_rcu(); // 这会禁用抢占
execute_in_process_context(start, ew); // 调度 payload 运行
irq_exit_rcu(); // 清理
asm ("mov %0, %%rax; push %%rax; popf" : : "rm" (flags) : "rax");
随后就应在补丁后的内核里自由运行 kSHELF 了。示例启动日志(节选):
[ 2.556448] PATCHED KERNEL
[ 2.556716] payload_len: 6072
[ 2.557044] Called via UEFI Runtime hook
[ 2.557399] Loading payload
[ 2.557903] relative relocation: 1689
...
[ 2.559118] relocating sym: register_ftrace_function
[ 2.559772] relocating sym: commit_creds
[ 2.560154] relocating sym: kallsyms_lookup_name
[ 2.560599] relocating sym: prepare_creds
...
[ 2.562075] RX
[ 2.562255] RW
[ 2.562403] Entrypoint: ffff888101ca8370
[ 2.562898] Greetings from SHELF
[ 2.567905] efivars: Registered efivars operations
...
[ 2.602202] rootkit: Loaded >:-)
作为 kSHELF 的替代,也可以像 kmatryoshka 那样用现有内核基础设施加载普通 LKM。
结论
如果对后续工作感兴趣,有几个方向:
- 支持更多架构(现代 aarch64 系统也用 UEFI);
- 支持 UEFI Secure Boot:用 bootkit 组件注册自定义 MOK,从而引导这些补丁内核;
- 像早期 32 位 ARM 工作那样重新打包内核:用 “donor” 内核树替换未打包内核后重建——可能更费劲、不如本文方法可靠,但对 x86 之外的架构是个选项;
- Unified Kernel Images(UKI)越来越常见,支持它们会很有趣。
作者感谢 jbtzhm 的 Phrack 原文,以及让这类工作成为可能的 FOSS 维护者:marin-m(vmlinux-to-elf,没有它项目不可能完成)、WerWolv(ImHex,配合模式文件让作者理解了 bzImage 结构)、hasherezade(PE-Bear,帮助调试 PE 补丁代码里的棘手 bug——清晰标出未映射节真是救命)。
参考
- Phrack 60-8(jbtzhm 原文)
- zImage Kernel Patch writeup
- Modify vmlinuz(ARM)
- 如何把 vmlinux ELF 重新打包回 bzImage
- bootkitty 分析
- grabit
- PciLeech
- Phrack 68-11(LKM 感染)
- kSHELF(tmp.0ut 4/5)
- vmlinux-to-elf
- 移除 .reloc 的内核提交
- x86 boot protocol 文档
- kmalloc 页对齐相关内核提交
- kmatryoshka
上一页:x86-64 Linux ELF 的细粒度加载时 ASLR
下一页:tmp.0ut 第五期混音带