本文根据 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 启动路径相关结构的多个字段。

磁盘上的修改

流程(原文有完整图示):

  1. 用 vmlinux-to-elf 从 bzImage 提取内核 ELF;
  2. 从内核 ELF 找空间与符号;
  3. 添加抽取节并修改 PE 头;
  4. 修改 boot protocol 值;
  5. 用 dummy link 确定运行时大小;
  6. 把运行时与内核、载荷链接;
  7. 把运行时放进新节;
  8. 得到 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)。此外:

  • 修正 SizeOfImageSizeOfCode 以计入新节;
  • 前一节按 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 raxff 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——清晰标出未映射节真是救命)。

参考

  1. Phrack 60-8(jbtzhm 原文)
  2. zImage Kernel Patch writeup
  3. Modify vmlinuz(ARM)
  4. 如何把 vmlinux ELF 重新打包回 bzImage
  5. bootkitty 分析
  6. grabit
  7. PciLeech
  8. Phrack 68-11(LKM 感染)
  9. kSHELF(tmp.0ut 4/5)
  10. vmlinux-to-elf
  11. 移除 .reloc 的内核提交
  12. x86 boot protocol 文档
  13. kmalloc 页对齐相关内核提交
  14. kmatryoshka

上一页:x86-64 Linux ELF 的细粒度加载时 ASLR

下一页:tmp.0ut 第五期混音带