本文根据 tmp.0ut 第五期中 TMZ 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:halfshelf: Loading ELF After The Header Is Gone
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:8239
半加载器系列 2/3
这是 tmp.0ut 第五卷三篇系列的第二篇:
- halfexec:广泛的用户态 ELF 加载,以及通常由 Linux 构建的进程状态;
- halfshelf(本文):直接入口的静态 PIE,提供普通与无头两种输入路径;
- phork:把无头表示连同自己的加载存根打包。
系列越往后范围越窄。本篇是枢纽:保留第一篇的 ELF 运行时义务,但把接受的映像形态收窄到第三篇能干净打包的程度。
引言
ELF 加载器在允许自己“疯一点”时更值得欣赏:不接受所有架构!不跟解释器谈判!不映射六个微妙不同的加载段!只选一种可执行形态、写清范围、拒绝范围外的一切。
halfshelf 正是如此:一个面向窄范围 SHELF 形态静态 PIE 的 Linux x64 用户态加载器——ET_DYN、恰好一个 PT_LOAD、没有 PT_INTERP、直接跳到载荷入口。同一运行时问题有两个前端:halfshelf 从路径读取普通 ELF;headshelf 读取剥离后的加载映像加分离的元数据。前者问“对一个刻意友好的 ELF 最少需要多少加载器”,后者提出一个更陌生的问题:ELF 头与程序头前缀被移除后,哪些事实必须幸存,程序才能正常“醒来”?包括 load bias、动态重定位、TLS、全新的初始栈,以及通常由内核承诺的辅助向量。
SHELF 从哪来
起点是 @ulexec 与 @Anonymous_ 的《Introducing SHELF Loading》(ulexec 网站有另一份副本)。SHELF 结合了通常分开讨论的两个性质:
- 静态链接:运行时不依赖 ld-linux 或一组共享对象;
- 位置无关代码:完整映像可以放在运行时选择的基址上。
结果就是静态位置无关可执行文件:一个自带运行时、却仍可置于 ASLR 之下的 ET_DYN 文件。
静态不代表结构简单:glibc 静态 PIE 仍可能含 PT_DYNAMIC、RELA 表、间接函数解析器与 PT_TLS。glibc 自身在正常启动前会做早期静态 PIE 重定位。有趣的机会在于:这些记录描述的工作可以由一个小加载器直接完成。
SHELF 通过把映像链成一个 PT_LOAD 让加载几何变得“无聊”:若该段从文件偏移 0、虚拟地址 0 开始,每个载荷地址都满足一个等式:
runtime_address = load_bias + image_virtual_address
没有逐段翻译谜题,选定的映射基址就是 load bias。把它称作裸 shellcode 是低估了:它仍是带 libc 启动、重定位、TLS 与进程入口期望的真实链接 C 程序,只是被塑造成可以作为一个位置无关映像旅行。
为什么比 halfexec 更受限
halfexec 处理 ET_EXEC、普通 PIE、带解释器的可执行文件、多个 PT_LOAD 区间,以及与动态链接器的尴尬交接。halfshelf 把大加载器当作来源并修剪选择:
halfexec halfshelf
----------------------------- -----------------------------
ET_EXEC 和 ET_DYN 只支持 ET_DYN
一个或多个 PT_LOAD 段 恰好一个 PT_LOAD
可选 PT_INTERP 拒绝 PT_INTERP
直接或解释器入口 只支持直接入口
通用 ELF 式映射 p_offset = 0, p_vaddr = 0
可复用的运行时阶段仍然共享,但源文件被 vendor 进本次发布。如系列第一篇所说,halfexec 将来可能变成库。
载荷
普通加载器要求输入必须满足以下条件:
- ELF64、小端、x64;
- 当前 ELF 版本、原生 64 字节 ELF 头;
- ET_DYN;
- 原生 56 字节 Elf64_Phdr 记录;
- 恰好一个非空 PT_LOAD;
- 从
p_offset == 0、位于p_vaddr == 0加载; - 没有 PT_INTERP。
同时记录可选的 PT_DYNAMIC、PT_TLS、PT_GNU_STACK 信息。
单一加载规则来自链接器脚本。GNU ld 的 PHDRS 命令允许脚本显式描述输出程序头表;测试脚本创建一个加载段并把所有可分配节都放进去:
PHDRS
{
shelf PT_LOAD FILEHDR PHDRS FLAGS(7);
dynamic PT_DYNAMIC FLAGS(6);
tls PT_TLS FLAGS(4);
}
SECTIONS
{
PROVIDE(__ehdr_start = 0);
. = SIZEOF_HEADERS;
...
.text : { *(.text*) } :shelf
.rodata : { *(.rodata*) } :shelf
.dynamic : { *(.dynamic) } :shelf :dynamic
.data : { *(.data*) } :shelf
.bss : { *(.bss*) } :shelf
.tdata : { *(.tdata*) } :shelf :tls
}
FILEHDR PHDRS 把 ELF 头与程序头字节放进可加载段。这有两重重要性:普通加载器能提供合法的 AT_PHDR;无头加载器也确切知道之后要补回哪个前缀。
显而易见的代价是权限:代码与可变数据共享一个段,所以示例链接器发出一个 RWX 的 PT_LOAD。这是教学取舍,不是 W^X 加固技术——链接器警告是对的,文章也不该假装并非如此。
前端一:磁盘上的普通 ELF
halfshelf 解析自己的初始栈,把目标路径名变成未来 payload 的 argv[0],打开文件,用 fstat 限制大小,读 ELF 头并把文件暂存进固定缓冲区。
校验阶段在提交映射前预扫程序头表:拒绝解释器与第二个加载段,记录 dynamic 与 TLS 区间,把唯一的内存跨度取整到页,用 mmap 预留匿名窗口。之后段映射用 MAP_FIXED 替换该区间——保留预留可以避免“选个地址、解除映射、然后祈祷没有其他分配插进来”的问题。
对唯一加载段,加载器:
- 检查
p_filesz <= p_memsz; - 检查文件区间没有超出暂存输入;
- 按取整后的区间以读写映射;
- 从暂存文件复制
p_filesz字节; - 把到
p_memsz的尾部清零; - 用 mprotect 应用程序头的权限。
匿名映射本来就是零填充的,但显式模型仍然有用:文件字节止于 p_filesz,类 BSS 内存止于 p_memsz。此后没有 execve——加载器在自己的进程里构建新运行时映像,然后替换栈指针、分支到新入口。
前端二:把头拿掉
headshelf 从同一个链接映像出发,但存储方式不同。剥离工具计算:
phdr_bytes = e_phnum * e_phentsize
header_span = e_phoff + phdr_bytes
payload_len = PT_LOAD.p_filesz - header_span
它保留从 header_span 到 p_filesz 的字节作为剥离后的 payload。节头、调试节与加载段之外的其他字节不是运行时 payload,不复制。
原 ELF
0 p_filesz
┌──────────┬───────────────┬───────────────────────────┐
│ Elf64_Ehdr│ Elf64_Phdr[] │ PT_LOAD 需要的字节 │
└──────────┴───────────────┴───────────────────────────┘
└────── header_span ───────┘
分离表示
┌──────────────────────┐ ┌───────────────────────────┐
│ metadata + phdr 字节 │ │ 剥离后的 payload 字节 │
└──────────────────────┘ └───────────────────────────┘
剥离不会重定位逻辑映像里的任何东西:原来位于映像偏移 header_span 的字节被复制回 base + header_span,链接后的虚拟地址在加上 load bias 后仍然有效。这是“删除字节”与“移动字节”的重要区别:磁盘表示更短,重建后的内存布局并没有变短。
两种元数据编码
元数据 sidecar 有两种含义相同的编码。
SHELFMETA1 是文本:冗长、对 od/sed/人眼友好,调试新字段时有用。strip_headers 对附带 test.c 样本生成的真实例子(节选):
SHELFMETA1
entry 0000000000006cc0
phoff 0000000000000040
phnum 0000000000000003
phentsize 0000000000000038
header_span 00000000000000e8
load_offset 0000000000000000
load_vaddr 0000000000000000
load_filesz 00000000000afab8
load_memsz 00000000000afab8
load_flags 0000000000000007
dynamic_vaddr 00000000000a44b8
dynamic_memsz 00000000000001a0
tls_vaddr 00000000000afa88
tls_filesz 0000000000000030
tls_memsz 0000000000000058
tls_align 0000000000000008
phdr_hex 010000000700000000000000...
每行一个键值对、定宽十六进制值,肉眼就能看出字段是否合理,不需要解析器。
SHELFBN1 是定宽二进制记录:8 字节 magic,后跟 17 个小端 64 位值:
0 magic "SHELFBN1"
8 entry 原 e_entry
16 phoff 原 e_phoff
24 phnum 原 e_phnum
32 phentsize 原 e_phentsize
40 header_span 被移除前缀的结束位置
48 load_offset 必须为零
56 load_vaddr 必须为零
64 load_filesz 原 PT_LOAD.p_filesz
72 load_memsz 原 PT_LOAD.p_memsz
80 load_flags mmap R/W/X 位
88 dynamic_vaddr 可选 PT_DYNAMIC 地址
96 dynamic_memsz 可选 PT_DYNAMIC 大小
104 tls_vaddr 可选 PT_TLS 地址
112 tls_filesz 已初始化 TLS 字节数
120 tls_memsz 完整 TLS 字节数
128 tls_align 请求的 TLS 对齐
136 phdr_blob_size 保存的程序头字节数
144 phdr_blob 原程序头字节
固定部分 144 字节。当前测试映像有 3 个 56 字节程序头,二进制元数据为 144 + 168 = 312 字节。加载器自动检测开头 magic,校验基于零的加载几何,把程序头 blob 限制在 4096 字节 scratch 区内,并要求 phdr_blob_size = phnum * phentsize。文本格式为检视优化,二进制格式为无歧义的紧凑记录;保留两者是因为这里的序列化本身就是加载器设计的一部分。
把 ELF 头种回去
元数据与载荷校验后,headshelf 分配一个按 load_memsz 取整的匿名读写映射,在映射基址写一个合成的 Elf64_Ehdr(ELF64、小端、x64、ET_DYN 等稳定身份字段由加载器提供,入口点与程序头几何来自 sidecar),原始程序头字节放到 base + phoff,剥离后的载荷放到 base + header_span:
base + 0 合成 Elf64_Ehdr
base + phoff 恢复的 Elf64_Phdr 数组
base + header_span 第一个剥离后的载荷字节
base + load_filesz 文件初始化映像的结束
base + load_memsz 零填充映像的结束
然后用 mprotect 应用原始 load flags,phdrRuntime = base + phoff 成为将来的 AT_PHDR 值。
既然可执行代码已保留,为什么还要重建头部?因为字节不是 ABI 的全部:静态 libc 启动会检查辅助向量、用程序头表发现 PT_TLS 等结构,还可能引用 __ehdr_start。SHELF 的教训不是“头部从不重要”,而是“头部携带的事实可以分开运输,并在运行时期望的地方重建”。
两个前端、一个共享运行时,但并非完全可互换:sidecar 格式没有 PT_GNU_STACK 权限字段,所以 headshelf 的合成栈总是映射为读写;halfshelf 读原头时,则会从真实 PT_GNU_STACK 条目(若有)推导同一保护。典型条目例如:
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
附带测试二进制都落在同样的默认 RW 栈上,因此该不对称性在代码里真实存在、但在仓库自身测试矩阵里目前观察不到;只有链接时显式声明可执行栈的 SHELF 映像才能看到 halfshelf 与 headshelf 的分歧。另外 headshelf 整体复用了 halfshelf 的消息表,横幅与调试行仍打印 halfshelf:,只有 usage 字符串改了名——纯外观问题,但调试时值得知道。
跳转前的重定位
位置无关代码里仍有些指针的最终值取决于所选 load bias。若映像有 PT_DYNAMIC,共享重定位代码会遍历 Elf64_Dyn 记录并收集 DT_RELA/DT_RELASZ/DT_RELAENT 与 DT_JMPREL/DT_PLTRELSZ/DT_PLTREL。支持的 x64 重定位集刻意很小:
R_X86_64_RELATIVE:
*(base + r_offset) = base + r_addend
R_X86_64_IRELATIVE:
resolver = base + r_addend
*(base + r_offset) = resolver()
加载器做两遍:先写普通 RELATIVE 条目,再运行 IRELATIVE 解析器——此时它们可能使用的普通映像指针已达到最终值。主 RELA 表与 JMPREL 都采用同一顺序。未知重定位会被拒绝而不是跳过:静默忽略重定位会造出一个“可能一直运行到某个更隐蔽指针被触碰”才出问题的程序。
这不是符号解析,也不是动态链接器。窄的静态 PIE 模式让这两种相对形式足以支撑附带的 glibc 样本。
TLS:静态不代表简单
测试程序声明了 __thread int tls_counter = 17;,这一行就迫使加载器提供合法的 x64 线程指针。值得注意的是,运行章节里的纯 hello world 样本没有声明任何 __thread 变量,调试记录仍会打印 preparing PT_TLS runtime state——静态 PIE 自带 PT_TLS 段,那是 glibc 内部启动状态留下的产物,无论链接的程序是否需要。加载器不特判“用户不可见 TLS”,而是对链接器实际发出的任何 PT_TLS 做出反应。
存在 PT_TLS 时,共享栈阶段把它对齐到至少 16 字节,并建立最小初始线程布局:
┌────────────────┬────────────────┬────────────────┐
│ TLS 映像 │ TCB │ tiny DTV │
│ init + zeroes │ 线程指针 │ 一个 module │
└────────────────┴────────────────┴────────────────┘
从 base + tls_vaddr 复制 p_filesz 个初始化字节,其余到 p_memsz 的部分清零,播种一个小的 Dynamic Thread Vector,然后调用:
arch_prctl(ARCH_SET_FS, tcb_address)
在 x64 Linux 上,FS 是常规 TLS 访问序列使用的线程指针基址。缺这一步,二进制可能完成重定位,却在 libc 或样本第一次触碰线程局部状态时死掉。这是受控样本的初始线程模型,不是对任意 libc 版本、动态加载模块或新线程的完整 TLS 实现。
重建进程入口
直接跳到 e_entry 需要内核式栈。加载器分配一块全新栈映射,重建 System V 进程入口布局:
rsp -> argc
argv[0]
...
NULL
envp[0]
...
NULL
auxv type, value
...
AT_NULL, 0
指针不能继续指向加载器的旧栈:参数字符串与环境字符串被复制进目标存储;AT_RANDOM 引用的 16 个随机字节,以及 AT_PLATFORM、AT_BASE_PLATFORM 背后的字符串也一样。大多数辅助向量记录原样保留,映像相关记录被修补:
- AT_PHDR 指向真实或重建的程序头表;
- AT_PHENT、AT_PHNUM 描述该表;
- AT_ENTRY 变成
base + e_entry; - AT_BASE 为 0(没有 ELF 解释器);
- AT_EXECFN 指向目标可见的复制路径名。
栈指针对齐、目标文件描述符关闭,控制转移给 entry = base + e_entry。没有返回地址,入口不是函数调用。payload 看到的是与内核 ELF 启动后相同的东西——尽管布景是另一个用户态可执行文件搭的。
memfd 模式
无头加载器还接受:
cat payload | ./headshelf --memfd payload.binmeta alpha beta
它用 memfd_create 创建匿名文件,把标准输入里的剥离载荷灌进普通暂存缓冲区,并逐块镜像进该内存描述符。描述符对研究“匿名文件传输”模式有用,但不是本实现里执行的来源——映射仍是匿名的,内容来自暂存缓冲区。这里没有 fexecve、没有 execveat、没有可执行临时文件。这个区别值得说明,因为它可能暗示几种不同设计:把普通 ELF 放进 memfd 再让内核执行;直接从 memfd mmap 页面;或把 memfd 当匿名传输、由用户态加载器映射字节。headshelf --memfd 是第三种。
运行
调试模式运行附带样本(节选):
$ HALFSHELF_DEBUG=1 make run
halfshelf: SHELF focused userland exec loader v0.0.1 by TMZ (c) 2026
halfshelf: debug tracing enabled
halfshelf: target type ET_DYN
halfshelf: reserving SHELF load window
halfshelf: load bias = 0x0000741c23d02000
halfshelf: synthetic stack base = 0x0000741c23400000
halfshelf: mapping SHELF PT_LOAD at 0x0000741c23d02000
halfshelf: applying direct entry relocations
halfshelf: preparing PT_TLS runtime state
halfshelf: rebuilding argc/argv/envp/auxv
halfshelf: jumping to SHELF entry = 0x0000741c23d08cc0
hello
从 halfshelf 到 phork
headshelf 会在旁边留下两个产物:元数据与剥离载荷。这种分离让“重建边界”极易检视,但它不是单个自加载文件。phork 是下一步:把加载器存根放在前面,追加同样的 SHELFBN1 事实与剥离字节,并加一个尾部 footer,让存根能在自己内部找到这两部分。
这就是 halfshelf 即使包装器(packer)是更耀眼的最终产物、也依然有用的原因:它先把困难的运行时隔离出来,再加容器格式与自发现逻辑。下一篇《phork: Packing SHELF Back Into One ELF》保留这个运行时,把分发形态从两个分离输入变成一个可执行文件。
最后的话
可执行文件不是它的头,也不只是它的代码字节——还有更多迷人的事实。halfshelf 通过用两种方式实现同一次启动(两者都收敛于重定位、TLS、栈重建与直接分支到入口)把这些事实变得可见。
参考
- Introducing SHELF Loading,tmp.0ut 第一卷
- SHELF Loading,@ulexec
- The Design and Implementation of Userland Exec
- System V ABI: Program Loading and Program Headers
- System V AMD64 ABI
- GNU ld: PHDRS Command
- glibc: static PIE relocation source
- glibc: process and static PIE startup source 9-12. mmap(2)、mprotect(2)、arch_prctl(2)、memfd_create(2) 手册
- getauxval(3) 与 auxiliary-vector 条目