本文根据 tmp.0ut 第五期中 TMZ 的文章编译整理,非逐字翻译;文中观点均属于原作者。

原文:halfexec: Assembly x64 ELF Linux Loader

文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:6462

半加载器系列 1/3

halfexec 是 TMZ 为 tmp.0ut 第五卷写的三篇系列的第一篇:

  1. halfexec(本文):在用户态重建足够的 execve,加载常见的 ELF;
  2. halfshelf:把输入限制为静态 PIE,然后去掉它的“头”;
  3. phork:把剥离后的映像、元数据与加载器塞回一个文件。

各篇文章可独立阅读,但按编号顺序读更顺。代码库如汇编项目预期的那样相当密集。

引言

ELF 是那种“悄悄蒙混过关”的格式:你运行一个文件,Linux 点个头,最终有些东西落在 _start,而大多数有趣机制都藏在幕后。方便,但有点不过瘾。

控制权到达用户代码前到底发生了什么?这条路径有多少能在用户态复现?走到哪一步它会从“好玩的加载器”变成 execve(2) 的可悲仿制品?本文给出作者的回答:一个用 FASM 写的小型 x64 Linux ELF 加载器。它花了作者将近四年才“完成”,期间反复修改,也用来创建了新项目(2022 年底作者在 Twitter 贴过刚开始编码时的图片,链接在原文)。

取名 halfexec,因为:它不是内核、不是完整的动态链接器、不是完整执行路径——作者自嘲当时缺乏创意。目标是造一个足够小、人类可读,又能跑日常 ELF 的加载器。最初版本只适用于特定的小可执行文件,后来每提交一次就多加一些功能、多支持一些 ELF,作者因此深入钻研了这个文件格式的内部。目前支持的形态包括:ET_EXEC、ET_DYN/PIE、静态与动态 libc 二进制、无 libc 的小样本、PT_INTERP 交接(普通动态链接二进制与 BusyBox——BusyBox 是开发期间的主要目标)。作者还一直想要一份能用于其他项目的汇编代码。

直接跑:

$ make
$ HALFEXEC_DEBUG=1 ./halfexec /bin/true

make run 会一次性构建并跑完整测试矩阵。

加载器实际做什么

高层流程:

  1. 解析自己的启动栈,找到目标路径;
  2. 打开目标,把整个文件暂存进内存;
  3. 校验 ELF 头;
  4. 判断是 ET_EXEC 还是 ET_DYN;
  5. 映射主映像;
  6. 按需从 PT_INTERP 加载解释器;
  7. 对直接入口的 ET_DYN 按需应用一小部分重定位;
  8. 按需建立最小 TLS 运行时状态;
  9. 重建一个全新的栈,包含 argc、argv、envp 与 auxv;
  10. 跳到正确的入口点。

听起来相当正常,但细节里藏着所有有趣的 bug。

ELF 加载小地图

进入实现选择前,先把“ELF 文件”与它描述的“进程映像”分开:ELF 不是整块拷到一个地址再执行。加载器读 ELF 头、跟随程序头表,把每个程序头当作一条“如何构建新地址空间”的指令。

.text.data.dynsym.rela.dyn 这类节主要是链接器视角的文件视图;运行时真正重要的是程序头表:PT_LOAD、PT_INTERP、PT_DYNAMIC、PT_TLS 等条目描述运行时实际需要什么。

ELF 文件                              进程内存
┌──────────────────┐              ┌──────────────────────┐
│ ELF 头           │── e_phoff ──>│ 加载器读取 PHDR      │
├──────────────────┤              ├──────────────────────┤
│ 程序头           │── PT_LOAD RX─>│ 代码映射            │
│                  │── PT_LOAD RW─>│ 数据 + 清零 BSS     │
│                  │── PT_INTERP─> │ 动态链接器映像      │
├──────────────────┤              ├──────────────────────┤
│ 段字节           │── 复制/映射─>│ 映射后的段字节      │
└──────────────────┘              └──────────────────────┘

对每个 PT_LOAD,加载器组合两类信息:p_offset/p_filesz(文件里哪些字节)、p_vaddr/p_memsz(放到哪里、内存多大)、p_flags(最终页是否可读/写/执行)。当 p_memsz 大于 p_filesz 时,剩余字节填零——这就是 .bss 之类未初始化数据在内存里出现、却不在文件里占空间的方式。

最后是进程启动状态:只映射代码不够,入口点期待一个按内核布局组织的栈:

                映射段

         按需重定位 / 准备 TLS

     ┌────────── 初始栈 ──────────┐
     │ argc │ argv[] │ NULL │ envp[] │ NULL │ auxv │
     └─────────────────────────────┘

       entry = load_bias + e_entry

               _start

静态直接入口映像在重定位与建栈后可直接进入其入口;若存在 PT_INTERP,则把指定的动态链接器也映射进来,控制权交给它的入口,而 AT_ENTRY、AT_PHDR、AT_PHNUM 等辅助向量条目仍描述主可执行文件。动态链接器随后完成符号解析,最终把控制权转给程序。这就是通常藏在 execve(2) 里的工作;halfexec 没有复现内核与动态链接器的全部特性,但严格遵循同样的骨架。

首先:整文件进内存

早期就定下一个塑造一切的设计:先把整个文件映射进内存。更优雅或可扩展的做法存在,但作者不想在已经很密集的代码里再引入暂存缓冲区的复杂度(并认为这是将来值得做的改进)。

整读目标的好处:头校验直接、程序头遍历容易、PT_INTERP 路径提取简单(作者说现在觉得“简单”,当时绝不简单)、段填充可以简单地从缓冲区拷到映射、ET_EXEC 子进程路径可以把暂存映像复制进 scratch 内存。代价是文件必须能装进固定缓冲区——对一个人可读的小加载器来说还算公平。

ET_DYN 与 ET_EXEC 不是同一个问题

这是加载器里最重要的分叉。把它们当成“差不多的东西加几个小差异”会让代码更糟。

ET_DYN

ET_DYN 是“友好”的情况:映像可重定位,加载器可以扫描全部 PT_LOAD、计算最低与最高虚拟地址跨度、预留一块相邻匿名映射、从预留推出 load bias、再把每个 PT_LOAD 放到 base + p_vaddr。这是愿意配合的 ELF 形态。

ET_EXEC

ET_EXEC 就不同了:虚拟地址固定。内核创建全新地址空间时没问题,但用户态加载器已经跑在同一个进程里、还要把目标映射到自己头顶上,就麻烦得多。

作者的办法是“体面地作弊”:加载器在父进程里准备好能做的一切,然后 fork。子进程把一小段跳板(trampoline)与暂存的 ELF 复制到 scratch 映射并从那里执行;从这段 scratch 代码里,才能安全地用 MAP_FIXED 把 ET_EXEC 的段放到真实固定地址,而不会砸掉自己仍在依赖的加载器文本。作者说这是整个项目里最令人满足的部分之一,也让人理解为什么内核通常在新进程映像里做这件事然后收工。

fork/跳板拆分(精简到有趣的部分):

forkTransformExec:
    mov rax, SYS_FORK
    syscall
    test rax, rax
    js forkFailed
    test rax, rax                 ; 0 表示我们现在在子进程里执行
    jz .child
    ; 父进程:wait4() 子进程并镜像其退出状态,然后结束
    ...
    .child:
    ; Scratch 映射布局:
    ;   [EXEC_CTX][trampoline code][padding][staged ELF file image]
    ;
    ; 跳板完全运行在这块高地址 scratch 区域里,同时把 ET_EXEC 的
    ; PT_LOAD 段重新映射到最终固定虚拟地址
    ...

父进程从不碰 MAP_FIXED;只有运行在自己不依赖的 scratch 内存里的子进程,才被允许覆盖原加载器文本所在的地址。

麻烦从哪开始

任何 ELF 加载器的大部分真实工作都围绕 PT_LOAD。识别出可加载段后要:

  • 把段起点向下取整到页边界;
  • 处理页边界与 p_vaddr 之间的页内偏移;
  • 把总映射长度向上取整到整页;
  • 在最终位置 mmap 可写内存;
  • 复制文件字节;
  • 把 memsz - filesz 的尾部清零(.bss);
  • 把 ELF 的 PF_* 标志翻译成 Linux 的 PROT_*;
  • 用 mprotect 把段设为最终权限。

对病毒作者来说是熟悉而享受的工作。单看每一项都不稀奇,但有很多小地方容易错(用汇编写更甚):

  • 混淆文件偏移与运行时地址;
  • 忘记 filesz 可以小于 memsz;
  • 忘记页对齐;
  • 假设 ELF 权限位与 Linux 保护位直接对应。

最后一条其实不对应(凭什么要对应):PF_R/PF_W/PF_X(0x4/0x2/0x1)与 PROT_READ/PROT_WRITE/PROT_EXEC(0x1/0x2/0x4)的位位置不同——读和执行正好互换,写只是巧合对齐。加载器里的 ELF_FLAGS_TO_PROT 宏专门掩盖这个差异,每个位一个 test/or 对:

macro ELF_FLAGS_TO_PROT flags_reg, out_reg
{
  local .no_read, .no_write, .done
    xor out_reg, out_reg
    test flags_reg, PF_R
    jz .no_read
    or out_reg, PROT_READ
  .no_read:
    test flags_reg, PF_W
    jz .no_write
    or out_reg, PROT_WRITE
  .no_write:
    test flags_reg, PF_X
    jz .done
    or out_reg, PROT_EXEC
  .done:
}

不聪明,但这是整个加载器里唯一被允许同时知道两套位布局的地方。

PT_INTERP:知道何时该停手

作者不想让项目变成完整动态链接器。目标带 PT_INTERP 时,不去实现 ld-linux 剩下的部分——“那条路通向疯狂”。更合理的做法:

  1. 在暂存的目标映像里校验解释器路径;
  2. 打开并暂存解释器;
  3. 把解释器作为另一个 ET_DYN 映像映射;
  4. 重建栈;
  5. 把 AT_BASE 补成解释器基址;
  6. 跳到解释器入口。

主可执行文件仍是主可执行文件,AT_ENTRY 仍描述它的入口;但最终控制权交给解释器,因为那种情况下它才是负责收尾的组件。作者引用网络梗:“活不多,但是正经活。”

一点重定位支持就够用

对直接入口的 ET_DYN,加载器只处理一小部分重定位:

  • R_X86_64_RELATIVE;
  • R_X86_64_IRELATIVE。

作者想要足以支撑真实测试矩阵的重定位逻辑,又不让项目变成“重定位动物园”。小集合带来更多价值,也让两遍结构非常清晰:先修普通 RELATIVE 重定位,再跑 IRELATIVE 解析器。顺序反了会出问题——解析器代码喜欢运行在“明显指针已被先修好”的世界里。

手工重建栈

作者想让进程入口状态显式化:被加载的程序不应保留指向加载器原栈的指针——那很草率,也是制造混乱 bug 的好办法。所以 halfexec 创建一块全新的栈映射并重建 argc、argv[]、envp[]、auxv[],同时复制所有参数字符串、环境字符串、AT_RANDOM、AT_PLATFORM、AT_BASE_PLATFORM。然后修补需要匹配新现实的 auxv 条目:AT_PHDR、AT_PHENT、AT_PHNUM、AT_BASE、AT_ENTRY、AT_EXECFN。

这是犯错代价极高的区域,比如:忘记复制某个 payload 字符串、让 AT_EXECFN 指向旧栈、放错 argv/envp/auxv 之间的 NULL 终止符、栈对齐搞错。这些 bug 发生在别人身上时极具教育意义,发生在自己凌晨两点的调试里就没那么美好了。

TLS:刚好够用

作者希望 PT_TLS 二进制在支持的矩阵里能跑,为此做了大量研究(此前 TLS 对他几乎是 ELF 的未探索区)。加载器自己构建最小 TLS 布局:TLS 块、TCB、一个小 DTV,然后用 arch_prctl 让 %fs 指向合成的 TCB。这不是完整 TLS 运行时,只是够让支持的情形工作、并让机制在代码里可见而不被敷衍过去。

测试真实形态很重要

halfexec 没有停留在单个玩具二进制上,而是覆盖一组小而有用的形态:

  • 无 libc 的 C 版 ET_EXEC;
  • 无 libc 的 C 版 ET_DYN;
  • 无 libc 的汇编 ET_EXEC;
  • 侧重 argv 的无 libc 回归探针;
  • libc 静态 ET_EXEC;
  • libc 动态 ET_EXEC;
  • libc 动态 PIE(ET_DYN);
  • libc 静态 PIE(ET_DYN);
  • PT_INTERP 二进制。

用需要更多支持的随机 ELF 跑很可能遇到 bug。每种新形态都考验一个不同假设;只测一个 “Hello World” 时几乎一切正常,一旦混合 ET_EXEC、PIE、PT_INTERP、libc 启动、TLS 与 argv/envp,加载器就会(通常很大声地)告诉你捷径在哪里。

运行效果

用调试模式跑 ET_DYN 二进制(/bin/whoami)的示例输出(节选):

$ HALFEXEC_DEBUG=1 ./halfexec /bin/whoami
halfexec v0.0.1 by TMZ (c) 2026
halfexec: debug tracing enabled
halfexec: target type ET_DYN
halfexec: reserving ET_DYN load window
halfexec: load bias = 0x000077279c35e000
halfexec: synthetic stack base = 0x000077279ba00000
halfexec: mapping PT_LOAD at ...
halfexec: loading PT_INTERP image
halfexec: interpreter base = 0x000077279c326000
halfexec: rebuilding argc/argv/envp/auxv
halfexec: jumping to interpreter entry = 0x000077279c3432c0
root

作者坦言在汇编里实现调试模式非常枯燥,底层要打印点信息就得做很多工作;但完成后与代码相处愉快得多,“胜过无休止的 gdb 会话和晦涩注释”。

代码组织

为保持可读,加载器按阶段拆分:

halfexec
 ├── halfexec.asm         主控制流
 ├── utils.inc            常量、结构体与宏
 ├── Makefile
 ├── tests                各测试形态
 └── runtime
     ├── validate.inc     ELF 校验与早期分发
     ├── interp.inc       解释器加载
     ├── reloc.inc        直接入口重定位子集
     ├── stack.inc        TLS 与栈重建
     ├── trampoline.inc   ET_EXEC 子进程变换路径
     ├── rwdata.inc       可变运行时状态
     └── rodata.inc       消息与只读数据

这种拆分帮助很大:汇编一旦全部堆在一个文件里、每个标签都想同时干两件事,就会迅速变得不可读。项目读起来更像一场导览而不是一堵操作码墙;作者也在认为有帮助的地方加了注释。没有注释,三个月后很难想起那个 mov rax 为什么在那里。

halfexec 能变成库吗?

能。上述阶段拆分已经完成了大部分概念工作:可复用表面可以把校验、地址预留、段映射、重定位、TLS 与栈构造暴露成基于显式加载器结构的独立入口;调用者选择所需阶段并提供策略(接受的 ELF 类型、段数量、解释器支持与最终交接)。当前项目没有这样打包,因为它的 include 共享一份全局状态布局、并假设主加载器里的标签存在;做成真正的库需要定义所有权、输入、clobber、错误与功能边界。作者想做这次重构,后续文章会复用合适的小阶段以保持自包含。

为什么是这种形态

这个加载器本可以更“野心勃勃”:支持更多重定位类型、校验更多世界、长成更接近完整运行时的东西,或者成为某个邪恶业务的一部分。这些都很有趣——作者个人最感兴趣的是最后一项。

最后的话

这不是 ELF 加载的最终结论:它没有加固、不打算支持所有重定位、不是新动态链接器、不试图与内核竞争。它想做的是更简单的事:展示真实 ELF 加载如何工作、让 ABI 细节可见、支持人们熟悉的常见可执行形态、保持足够的可读性让未来的自己(以及希望其他人)仍能从中学习,并且 100% 用汇编。有时最好的系统项目,就是让熟悉的东西再次显得可疑一阵子的那种。

参考

  • System V ABI: ELF Object File Format
  • System V AMD64 ABI
  • elf(5)、execve(2)、mmap(2)、getauxval(3)、ld.so(8)
  • Linux fs/binfmt_elf.c

上一页:代码虚拟化概览

下一页:halfshelf:ELF 头消失后仍然加载