在 ELF(Executable and Linkable Format)文件格式的世界里,极限精简一直是一项极具挑战性的工程。2021 年,开发者 Nathan Otterness 在其博客上发表了一篇关于最小 ELF 可执行文件的深度技术文章,系统性地展示了如何在现代 Linux 环境下(内核 5.14 及以上,x86_64 架构)将一个打印 "Hello, world!\n" 的程序压缩至仅 120 字节。这篇文章不仅是对传统极简 ELF 技术的传承,更是在 64 位时代对格式限制与内核加载行为的深度探索。
技术背景与目标设定
Nathan Otterness 的这项工作源于多年前他读到的一篇经典文章 —— 由 breadbox 撰写的《A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux》。那篇文章展示了在 32 位 Linux 环境下创建一个仅 45 字节的 ELF 可执行文件的方法,这一数字在当时堪称惊艳。然而,随着 Linux 内核的安全策略日趋严格,加之 64 位架构的普及,那个 45 字节的极限案例已无法在现代系统上正常运行。Otterness 设定了一个明确的目标:在现代 x86_64 Linux 环境下,创建一个能够打印完整 "Hello, world!\n" 字符串并以退出码 0 正常退出的最小 ELF 可执行文件。与原版文章不同,他选择使用 NASM 汇编器作为工具,并采用 Intel 语法编写 x86_64 汇编代码,这使得整个过程更易于理解和复现。
在初始阶段,Otterness 构建了一个包含完整 section 信息(.text 和 .shstrtab)的最小有效 ELF 文件。这个版本已经经过了相当程度的精简,但仍然包含了 383 字节的数据。在这个阶段,使用标准的 Linux 工具如 readelf 和 objdump 仍然能够正确解析文件内容,这为后续的破坏性优化提供了一个可验证的基准点。值得注意的是,即使是这个最初的版本,也已经远小于常规编译器(如 gcc)生成的默认可执行文件,后者的体积通常在数十 KB 甚至数百 KB 级别。
移除 Section 信息:第一轮大幅精简
ELF 文件通常包含两类头部结构:程序头部(Program Header)和 section 头部(Section Header)。程序头部告诉内核哪些部分需要加载到内存以及加载到哪里,这部分信息对于可执行文件的运行是必需的。而 section 头部主要用于链接过程,包含了各种节区的元数据,对于纯粹的程序加载和执行并非必要。Otterness 意识到,如果只关心程序能否运行,完全可以删除所有 section 相关的信息。具体操作是将 ELF 头部的 section 头部数量字段设置为 0,并完全移除 .shstrtab 等 section 定义。这一步骤将文件大小从 383 字节直接降至 173 字节,节省了超过 200 字节的空间。
这一阶段虽然失去了使用 objdump 等工具反汇编代码的能力,但 readelf 仍然能够正确识别文件结构并报告没有 section 的事实。重要的是,内核的 ELF 加载器对这种 "不完整" 的 ELF 文件仍然能够正确处理,因为它只需要程序头部提供的信息即可完成加载和执行。这为后续更激进的优化奠定了基础:既然 section 信息可以被完全移除,那么 ELF 和程序头部中那些未被内核验证的字段是否也可以被利用呢?
代码优化:指令层面的极限压缩
在移除 section 信息后,程序的实际代码(.text 段)占用了 39 字节,共 8 条指令。虽然这已经是一个非常小的程序,但 Otterness 仍然找到了进一步压缩的空间。优化的核心思路是利用更短的指令编码替代原本较长的指令,同时充分利用 Linux 内核在程序启动时自动将所有寄存器初始化为 0 这一特性。
具体而言,原始的 mov eax, 1(用于设置 write 系统调用号)占用 5 字节,可以替换为 xor eax, eax(2 字节)加上 inc eax(2 字节)的组合,总计 4 字节,节省 1 字节。类似地,mov edi, 1(设置文件描述符参数)可以替换为 mov edi, eax,因为 eax 已经在前一步被设置为 1,这从 5 字节减少到 2 字节。对于系统调用参数的传递,使用 32 位寄存器(如 esi、edx)相比 64 位寄存器能够节省字节数,只要地址和长度能够在 32 位范围内表示即可。在设置退出状态时,mov al, 60 比 mov eax, 60 更加紧凑,因为它只需要 2 字节而不是 5 字节。这些优化将代码部分从 39 字节压缩至 23 字节,整个文件大小降至 157 字节。
头部覆写:利用未验证字段
接下来是整个优化过程中最具创意的部分:利用 ELF 和程序头部中那些内核加载器并不真正验证的字段,将代码和数据 "塞" 进这些原本被视为元数据的区域。Otterness 采用了一种系统性的方法来确定哪些字节可以被覆写:他编写了一个测试程序,逐个用随机数据替换头部中的每个字节,然后尝试运行,通过二分查找定位哪些字段是安全的。
测试结果显示,ELF 头部的物理地址字段、程序头部的对齐字段、以及多个填充字节区域都可以被任意覆写而不会影响程序执行。依据这一发现,Otterness 将代码和 "Hello, world!\n" 字符串的一部分迁移到了这些 "空闲" 的头部字节中。这一步骤将文件大小从 157 字节进一步压缩至 126 字节,减少了 31 字节。此时,字符串已经超出了程序头部的边界 6 字节,这是因为没有足够连续的未验证字节来容纳整个 14 字节的字符串。
头部重叠:最终极限
在 126 字节的基础上,Otterness 发现了最后一个重大优化机会:让 ELF 头部和程序头部发生重叠。ELF 头部的末尾包含 section 头部数量、section 头部大小、以及 section 名称字符串表索引等字段。由于我们已经将 section 头部数量设置为 0(表示没有 section),后面两个字段实际上变得无关紧要。Otterness 尝试将程序头部紧邻 ELF 头部的末尾放置,使得程序头部的 type 字段(必须是 1,表示 PT_LOAD)恰好与 ELF 头部的 section 头部大小字段重叠,而 flags 字段则与 section 名称字符串表索引字段重叠。由于这些字段在我们的场景中恰好都是 0 或不需要的值,重叠得以成功实现。
经过这最后一步优化,最终版本的文件大小定格在 120 字节。这是一个极具里程碑意义的数字,因为它恰好等于一个 ELF 头部(64 字节)加上一个程序头部(56 字节)的总大小,也就是说,如果不包含代码和数据,这就是一个 "空"ELF 文件所能达到的最小尺寸。通过巧妙的重叠技术,代码和数据被无缝地融入了这个最小化的头部结构中。值得注意的是,Otterness 还提到通过进一步缩短字符串(如改为 8 字节的 "Hi!!!!!\n"),可以将大小进一步降低至 114 字节,而这已经非常接近在现代 Linux 上不使用非标准技巧所能达到的理论下限。
结论与工程启示
Otterness 的这项工作揭示了 ELF 格式在极简场景下的深层结构特性。尽管现代 Linux 内核已经不再允许那些在 2000 年代初期可行的极端技巧(如完全缺失的程序头部),但通过精心设计,仍然可以在遵守基本格式要求的前提下实现惊人的精简。从工程实践的角度看,这种极限压缩技术的直接应用价值或许有限,但它所体现的思路 —— 深刻理解底层格式规范、系统地测试边界条件、创造性地利用规范中的 "灰色地带"—— 对于系统编程、嵌入式开发以及安全研究等领域都具有重要的借鉴意义。
资料来源:Nathan Otterness 的博客文章《Tiny ELF Files: Revisited in 2021》