本文根据 tmp.0ut 第五期中 TMZ 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:phork: Packing SHELF Back Into One ELF
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:5910
半加载器系列 3/3
这是 tmp.0ut 第五卷三篇系列的最后一部分:
- halfexec:广泛的用户态 ELF 加载器与基线运行时机制;
- halfshelf:单 PT_LOAD 结构与分离的无头表示;
- phork(本文):一个携带该元数据与剥离表示的普通存根 ELF——可以说是三篇里最有趣的一篇。
引言
phork 是作者对 SHELF 打包器的一次(过于复杂的?)尝试:取一个 SHELF payload,从存储主体里移除 ELF 头与程序头表,把缺失的事实打包在旁边,然后整体粘到一个小的 FASM 加载器后面。
输出仍是一个普通可执行文件:Linux 启动的是位于文件最前面的那个加载器;加载器打开自己的文件,从尾部取出 payload,重建运行时映像并跳进去。简而言之:一个自解压/自加载的可执行文件打包器——打包二进制,存根在运行时于内存中解包并运行,不需要临时文件。
载荷约束
打包器很严格,payload 必须满足:
- x64 且小端;
- ET_DYN;
- 静态链接、无 PT_INTERP;
- 恰好一个 PT_LOAD;
PT_LOAD.p_offset == 0;PT_LOAD.p_vaddr == 0;- 可选由 PT_DYNAMIC 描述;
- 可选由 PT_TLS 描述。
零偏移与零虚拟地址的规则让所选映射基址就是 load bias:每个 payload 虚拟地址都变成一次简单加法,加载器里没有隐藏的段放置谜题。一个段也意味着一个最终保护掩码;合并的代码与数据可能需要宽泛权限,这不是最漂亮的 W^X 故事,但这是为了让加载器小到“不需要焚香就能看懂”所做的取舍。
打包时刻:头去了哪里
构建器解析源 ELF 并计算:
header_span = e_phoff + e_phnum * e_phentsize
该跨度覆盖从 ELF 头区域到最后一个程序头条目的全部内容。存储的 payload 从 header_span 开始、到原 PT_LOAD.p_filesz 结束。程序头表之后的任何字节都不会在逻辑映像内部移动——它们只是被切出来存储;运行时回到 mapped_base + header_span。这就是 payload 内部地址无需二次调整的原因:它们的映像偏移仍停留在链接器放置的位置。
被移除的字节由一个紧凑记录 SHELFBN1 替代,它保存重建映像所需的字段和一份原始程序头表的副本。
两种格式,两种职责
最终文件布局:
[stub ELF][SHELFBN1 metadata][stripped payload][SPACK01! footer]
两层格式各司其职:SPACK01! 说明嵌入对象在本文件中的位置;SHELFBN1 说明 payload 在内存中应该长什么样。外层 footer 知道存储偏移,内层记录知道 ELF 语义。
SPACK footer
最后 48 字节总是普通定长格式:
offset size field
0x00 8 "SPACK01!"
0x08 8 meta_off
0x10 8 meta_len
0x18 8 payload_off
0x20 8 payload_len
0x28 8 flags
所有整数都是小端无符号 64 位。从 EOF 开始让发现过程很便宜:加载器只需要文件大小和一次 pread64 就能找到其他一切。
当前 flags:
0x01:把 metadata 与 0xA5 异或;0x02:把 payload 与 0xA5 异或;0x04:对 payload 做 RLE 编码。
存根运行时支持全部三种:会先对任一块去 XOR,并在接触其他内容前运行 RLE 解码器。mkpack 可按请求设置前两种(--xor-meta、--xor-payload,在三个必需路径之后以任意顺序应用)。XOR 对意志坚定的人毫无意义(固定单字节密钥),但它确实演练了真实变换会使用的同一条 flag 位与解码路径。RLE 目前只支持解码:按下文 token 格式写一个正确编码器感觉像独立小项目,留待后续版本。
SHELFBN1 内部
记录以 SHELFBN1 开头,后跟 17 个小端 64 位字段,再跟原始程序头字节:
entry 原 e_entry
phoff 原 e_phoff
phnum 程序头数量
phentsize 单个程序头大小
header_span 从前端移除的字节数
load_offset 必须为零
load_vaddr 必须为零
load_filesz PT_LOAD 的文件大小
load_memsz PT_LOAD 的完整内存大小
load_flags_num 翻译后的 mmap 保护位
dynamic_vaddr PT_DYNAMIC 虚拟地址,或零
dynamic_memsz PT_DYNAMIC 大小,或零
tls_vaddr TLS 初始化器虚拟地址,或零
tls_filesz 已初始化 TLS 字节数
tls_memsz 完整 TLS 块大小
tls_align 所需 TLS 对齐
phdr_blob_size 程序头副本大小
phdr_blob 原始程序头字节
加载器要求 phdr_blob_size == phnum * phentsize,并检查元数据字节数足够、load_filesz 不小于 header_span。剥离 payload 大小按 load_filesz - header_span 无符号计算。若跳过检查,精心构造的 header_span > load_filesz 会让减法下溢成接近 2^64 的数,被直接交给 RLE 解码器当预期输出大小,从而绕过解码器自身的边界检查。这个加载器会校验自己的元数据,正因为如此:攻击者对 sidecar 的控制与对 payload 一样多,两者只有在达成一致后才被信任。
从尾部自举
Linux 执行打包文件时只关心前面的 stub ELF;追加的数据在 stub 加载映像之外,但仍属于文件。stub 从内核原始栈开始,保存 argc、argv、envp 与可执行路径,然后打开 /proc/self/exe——这比信任 argv[0] 更好,argv[0] 可以是相对路径、被改名,或只是调用者撒的谎。
运行时路径:
- 从 EOF 读 48 字节 footer;
- 校验 magic、flags、长度与不重叠;
- 加载并可选用 XOR 解密 SHELFBN1;
- 解析其定长字段并复制保存的程序头;
- 加载存储的 payload;
- 两个 flag 都激活时先 de-XOR 再做 RLE 解码;
- 要求解码后大小等于
load_filesz - header_span; - 预留映像、重建其头部并复制主体;
- 应用重定位、创建 TLS、重建进程栈;
- 切换 rsp 并跳到
base + entry。
打包文件不会通过第二次内核执行变成 payload;stub 在当前进程内完成缺失的加载工作。
没有戏剧性的 RLE
RLE 流使用 1 字节控制 token,bit 7 决定 token 类型;低 7 位存“计数减一”,因此长度范围是 1 到 128。
- bit 7 清零:字面复制接下来的 count 个字节;
- bit 7 置位:把下一个字节重复 count 次。
解码器从 SHELFBN1 拿到精确的预期输出大小,拒绝提前结束、越界读取或展开超过预期 payload 长度的流。
手工示例:编码 12 字节的 AAAAAAAABCDE(八个 A 后跟四个字面字节):
input: 41 41 41 41 41 41 41 41 42 43 44 45 (12 bytes)
tokens: 87 41 (run: 0x80|(8-1), 值 'A')
03 42 43 44 45 (literal: 0x00|(4-1), 然后 "BCDE")
output: 磁盘上 7 字节,内存中 12 字节
0x87 的 bit 7 置位,是 run token:0x87 & 0x7f = 7,加一得计数 8,后跟单个重复字节。0x03 的 bit 7 清零,是 literal token:计数 4,后跟 4 个字面字节。它比不过真正的压缩器,但能很好地吃掉零字节长串,解码器也适合项目规模。
重建映像
加载器把 load_memsz 向上取整到页边界,创建一块匿名读写映射。匿名页从零开始,所以 load_filesz 之后的区域天然成为 BSS 尾部。
在映射基址写合成 Elf64_Ehdr:ELF64、小端、x64、ET_DYN 等稳定身份字段本地填写;入口点、程序头偏移/数量/大小等值来自 SHELFBN1。保存的程序头字节复制到 base + phoff,解码后的 payload 跟在 base + header_span:
base + 0 合成 ELF 头
base + phoff 恢复的程序头表
base + header_span 表之后的原始字节
base + load_filesz 零填充尾部开始
base + load_memsz 映像逻辑结束
填充完成后 mprotect 应用从原 PT_LOAD 捕获的权限;随后把 AT_PHDR 设为映射内恢复的表。
入口前的重定位
位置无关不意味着每个指针都现成:静态 PIE 映像通常含必须使用所选 load bias 的相对重定位。若存在 PT_DYNAMIC,加载器遍历 Elf64_Dyn 条目,收集 DT_RELA/DT_RELASZ/DT_RELAENT、DT_JMPREL/DT_PLTRELSZ/DT_PLTREL。
支持的重定位集:
- R_X86_64_RELATIVE:把
base + addend写到base + offset; - R_X86_64_IRELATIVE:调用位于
base + addend的解析器,把返回值写到base + offset。
普通相对重定位先跑,间接解析器第二遍跑(此时它们可能依赖的指针已修好);主 RELA 表与 JMPREL 共用同样的两遍。小世界之外的任何东西都被拒绝。支持符号、共享对象与真正的解释器会让它变成另一种东西——作者自嘲“我的爱好已经够多了(中年危机?)”。
TLS:咬人的部分
静态 PIE 即使没有 PT_INTERP 也可能带 PT_TLS。直接跳到入口意味着没有动态链接器准备线程局部存储,所以必须由存根来做。phork 把请求的对齐提高到至少 16 字节,然后为三个对象映射空间:
[TLS block][TCB][small DTV]
从 base + tls_vaddr 复制 tls_filesz 个初始化字节,其余到 tls_memsz 清零,在 TLS 区后放置对齐的线程控制块(TCB),并为一个 module 播种小型 Dynamic Thread Vector。最后 arch_prctl(ARCH_SET_FS) 让 x64 的 FS 基址指向该 TCB。此后 payload 可以执行常规的 FS 相对 TLS 访问,而不会在应该是线程指针的地方发现一片虚空。
全新的内核式栈
payload 应该看到进程入口栈,而不是加载器用了一半的工作区。stub 分配新栈映射并重建:argc、argv 指针、NULL、envp 指针、NULL、辅助向量。参数字符串与环境字符串复制进目标存储;AT_RANDOM 背后的 16 字节、AT_PLATFORM 与 AT_BASE_PLATFORM 引用的字符串也一起复制。大多数 auxv 条目原样保留,映像相关值被修补:
- AT_PHDR 指向
base + phoff; - AT_PHENT、AT_PHNUM 与重建的表一致;
- AT_ENTRY 变成
base + entry; - AT_BASE 为 0(没有解释器);
- AT_EXECFN 指向复制的可执行路径。
表与字符串就位后,按 x64 入口 ABI 对齐栈指针;加载器关闭自身文件描述符、安装新 rsp、直接跳进映射好的入口点。
为什么不嵌入完整 ELF
那当然也能工作,但会躲开作者最感兴趣的东西。嵌入完整 ELF 保留了运行时已知道如何重建的、对解析器友好的包装;剥离前缀让存储格式变得显式:可执行字节仍是可执行字节;ELF 事实变成 SHELFBN1;打包文件位置变成 SPACK01!。这不是普适的压缩胜利——stub 与元数据本身有成本——但胜利在架构上:每一层只携带其消费者需要的事实,最终输出仍是单个可直接执行的文件。
工具:一个构建器太和平了
压缩包含构建打包文件的代码,以及独立的检视、校验与提取工具。
打包:utils/mkpack.asm
mkpack <stub-elf> <input-shelf-elf> <output-packed> [--xor-meta] [--xor-payload]
两个 flag 相互独立、顺序无关;都不带时仍是默认行为,产生与之前完全相同的 flags = 0 包。
检视/校验/提取:utils/spack_tool.asm(同样用 FASM 写)。把两个 flag 同时打到 argv/TLS 样本上:
$ mkpack ./phork input_tls packed --xor-meta --xor-payload
$ spack_tool inspect packed
spack magic: SPACK01!
file_size: 727171
footer_off: 727123
meta_off: 7907
meta_len: 312
payload_off: 8219
payload_len: 718904
flags: 3
$ ./packed alpha beta; echo $?
argv/tls ok 20
7
flags = 3 即 0x01 | 0x02,两个 bit 都生效,打包文件仍与无 flag 版本行为完全一致。de-XOR 在任何东西接触嵌入字节前发生,因此正确性从不依赖它。
样本与运行
tarball 还包含一些为打包器量身定制的样本二进制。调试运行(节选):
$ PHORK_DEBUG=1 make run
...
spack: self loading SHELF packer stub
phork: debug tracing enabled
phork: reserving SHELF load window
phork: load bias = 0x000075d18f306000
phork: mapping SHELF PT_LOAD at 0x000075d18f306000
phork: synthetic stack base = 0x000075d18ea00000
phork: applying direct entry relocations
phork: preparing PT_TLS runtime state
phork: rebuilding argc/argv/envp/auxv
phork: jumping to SHELF entry = 0x000075d18f30ccc0
hello
最后的话
原始 SHELF 工作问了一个漂亮的问题:如果静态 PIE 已经知道如何在任何地方生存,那么在它不再是一个“真正”程序之前,我们能移除多少 ELF?phork 尽管有自觉的设计限制,仍追问:“被剥离的东西能否携带自己的加载器,并以一个普通可执行文件的形态到达?”——事实证明可以,小 phorky。作者在文末加了一句玩笑:“(no DMCA plz Mickey)”。
参考
- Introducing SHELF Loading,tmp.0ut 第一卷
- SHELF Loading,@ulexec
- The Design and Implementation of Userland Exec
- GNU ld PHDRS 文档
- halfexec: Assembly x64 ELF Linux Loader,tmp.0ut 第五卷
- halfshelf: Loading ELF After The Header Is Gone,tmp.0ut 第五卷
下一页:借助可达性分析实现自解压