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

原文:phork: Packing SHELF Back Into One ELF

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

半加载器系列 3/3

这是 tmp.0ut 第五卷三篇系列的最后一部分:

  1. halfexec:广泛的用户态 ELF 加载器与基线运行时机制;
  2. halfshelf:单 PT_LOAD 结构与分离的无头表示;
  3. 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 语义。

最后 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] 可以是相对路径、被改名,或只是调用者撒的谎。

运行时路径:

  1. 从 EOF 读 48 字节 footer;
  2. 校验 magic、flags、长度与不重叠;
  3. 加载并可选用 XOR 解密 SHELFBN1;
  4. 解析其定长字段并复制保存的程序头;
  5. 加载存储的 payload;
  6. 两个 flag 都激活时先 de-XOR 再做 RLE 解码;
  7. 要求解码后大小等于 load_filesz - header_span
  8. 预留映像、重建其头部并复制主体;
  9. 应用重定位、创建 TLS、重建进程栈;
  10. 切换 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)”。

参考

  1. Introducing SHELF Loading,tmp.0ut 第一卷
  2. SHELF Loading,@ulexec
  3. The Design and Implementation of Userland Exec
  4. GNU ld PHDRS 文档
  5. halfexec: Assembly x64 ELF Linux Loader,tmp.0ut 第五卷
  6. halfshelf: Loading ELF After The Header Is Gone,tmp.0ut 第五卷

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

下一页:借助可达性分析实现自解压