本文根据 tmp.0ut 第五期中 TMZ 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:halfexec: Assembly x64 ELF Linux Loader
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:6462
半加载器系列 1/3
halfexec 是 TMZ 为 tmp.0ut 第五卷写的三篇系列的第一篇:
- halfexec(本文):在用户态重建足够的 execve,加载常见的 ELF;
- halfshelf:把输入限制为静态 PIE,然后去掉它的“头”;
- 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 会一次性构建并跑完整测试矩阵。
加载器实际做什么
高层流程:
- 解析自己的启动栈,找到目标路径;
- 打开目标,把整个文件暂存进内存;
- 校验 ELF 头;
- 判断是 ET_EXEC 还是 ET_DYN;
- 映射主映像;
- 按需从 PT_INTERP 加载解释器;
- 对直接入口的 ET_DYN 按需应用一小部分重定位;
- 按需建立最小 TLS 运行时状态;
- 重建一个全新的栈,包含 argc、argv、envp 与 auxv;
- 跳到正确的入口点。
听起来相当正常,但细节里藏着所有有趣的 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 剩下的部分——“那条路通向疯狂”。更合理的做法:
- 在暂存的目标映像里校验解释器路径;
- 打开并暂存解释器;
- 把解释器作为另一个 ET_DYN 映像映射;
- 重建栈;
- 把 AT_BASE 补成解释器基址;
- 跳到解释器入口。
主可执行文件仍是主可执行文件,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
上一页:代码虚拟化概览