迷你可执行文件再探
摘要
本文重新审视了在 Linux 上创建极小 ELF 可执行文件的技术,探讨如何通过滥用头部字段和重叠结构将大小缩减至 45 字节,同时保持与 ELF 规范的兼容性。
暂无内容
查看缓存全文
缓存时间: 2026/06/24 04:48
# 微型可执行文件重访
来源:https://www.muppetlabs.com/~breadbox/software/tiny/revisit.html
(或“地平线上乌云渐聚”)
---
几次有人对我最初的短文评论说,我最终创造的不是真正的 ELF 可执行文件。相反,它只是一个文件,碰巧被当前版本的 Linux 内核**误认为**是 ELF 可执行文件。
这个观点很合理。那个 45 字节的文件显然不符合 ELF 规范的许多要求。但你能怪我吗?明知还有可能做出更离谱的事,我怎么能停在即将把 ELF 规范扔出窗外的前一刻呢?
但为了满足这些纯粹主义者,以及我们所有人内心中的清教徒一面,我写了这篇续篇。
---
那么。我们有一个缩减到 45 字节的可执行文件。现在我们想让它严格遵守已发布的标准,同时仍然保持尽可能小。
我们偏离正道的那一点,是当我们开始摆弄 ELF 头中的“未使用”字段时。那么让我们回到那一点之前:
```nasm
BITS 32
org 0x08048000
ehdr: ; Elf32_Ehdr
db 0x7F, "ELF", 1, 1, 1 ; e_ident
times 9 db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsz ; e_ehsize
dw phdrsz ; e_phentsize
dw 1 ; e_phnum
dw 0 ; e_shentsize
dw 0 ; e_shnum
dw 0 ; e_shstrndx
ehdrsz equ $ - ehdr
phdr: ; Elf32_Phdr
dd 1 ; p_type
dd 0 ; p_offset
dd $$ ; p_vaddr
dd $$ ; p_paddr
dd filesz ; p_filesz
dd filesz ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
phdrsz equ $ - phdr
_start:
xor eax, eax
inc eax
mov bl, 42
int 0x80
filesz equ $ - $$
```
这是我们九十一字节的版本。那么我们是否就只能这样了?不,不尽然。当我们让 ELF 头和程序头表重叠八个字节时,我们并没有违反任何规则。ELF 规范明确允许文件中不同数据结构之间的重叠。所以让我们在这里做同样的事:
```nasm
; tiny.asm
BITS 32
org 0x08048000
ehdr:
db 0x7F, "ELF", 1, 1, 1 ; e_ident
times 9 db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsz ; e_ehsize
dw phdrsz ; e_phentsize
phdr: dd 1 ; e_phnum ; p_type
; e_shentsize
dd 0 ; e_shnum ; p_offset
; e_shstrndx
ehdrsz equ $ - ehdr
dd $$ ; p_vaddr
dd $$ ; p_paddr
dd filesz ; p_filesz
dd filesz ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
phdrsz equ $ - phdr
_start: xor eax, eax
inc eax
mov bl, 42
int 0x80
filesz equ $ - $$
```
这样我们就得到了八十三字节。还有什么可以做的?似乎不多了。绝望之下,我们可能重新拿起 ELF 规范,再读一遍,寻找一些东西。
有没有关于初始寄存器值的任何保证?只有一个寄存器:edx。规范说的是它将包含零,或者一个最终关闭过程的地址。所以实际上没有任何保证。继续找。
啊哈:程序头表结构的 p_paddr 字段!头中每个其他不适用于 Intel 架构,或不适用于可执行文件(至少不适用于我们的可执行文件)的字段,ELF 规范都要求设置为零。但对于 p_paddr 字段,规范说它的内容是**未指定**的。所以我们终于有四个字节可以玩了。
我们能拿它们做什么?自然是用来存放我们程序的一部分。当然,我们不能把整个程序放进去,所以我们需要浪费四个字节中的两个用于 jmp 指令,以便跳到程序的其余部分。但这仍然给我们留下两个可用的字节,而我们程序的第一条指令正好是两个字节长。
```nasm
; tiny.asm
BITS 32
org 0x08048000
ehdr:
db 0x7F, "ELF", 1, 1, 1 ; e_ident
times 9 db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsz ; e_ehsize
dw phdrsz ; e_phentsize
phdr: dd 1 ; e_phnum ; p_type
; e_shentsize
dd 0 ; e_shnum ; p_offset
; e_shstrndx
ehdrsz equ $ - ehdr
dd $$ ; p_vaddr
_start: xor eax, eax ; p_paddr
jmp short part2
dd filesz ; p_filesz
dd filesz ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
phdrsz equ $ - phdr
part2: inc eax
mov bl, 42
int 0x80
filesz equ $ - $$
```
所以。八十一字节。仅此而已吗?
p_paddr 字段之后的下一个字段是 p_filesz 字段。如果我们能把 jmp 指令和这个字段重叠,我们就可以再挤进一条指令。但可惜,该字段的第一个字节是整个文件的大小,跳转到那里不明智。剩下的字节是零。这个方法看起来不太有希望。
那 p_paddr 之前的字段呢?那是程序要加载到的地址。嗯,我们已经知道我们不必使用默认的 0x08048000。我们至少需要保持地址页对齐,但我们应该能够将一个两字节指令放入地址的高半部分。然而,我们的 xor 指令不行。记住这是小端序。我们程序当前的字节是:
```
31C0 xor eax, eax
EB10 jmp short part2
40 part2: inc eax
B32A mov bl, 42
CD80 int 0x80
```
xor 指令会将 C0 作为最高字节,它设置了高位,而 Linux 不喜欢我们把代码放在那里。(一般来说,在 32 位可执行文件中,内存的高半部分保留给动态分配的地址:堆、栈和共享对象库。)
另一方面,“mov bl, 42”指令会给我们一个完全可以接受的最高地址字节。所以,我们可以将加载地址改为 0x2AB30000,并稍作重新排列,得到以下代码:
```nasm
; tiny.asm
BITS 32
org 0x2AB30000
ehdr:
db 0x7F, "ELF", 1, 1, 1 ; e_ident
times 9 db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsz ; e_ehsize
dw phdrsz ; e_phentsize
phdr: dd 1 ; e_phnum ; p_type
; e_shentsize
dd 0 ; e_shnum ; p_offset
; e_shstrndx
ehdrsz equ $ - ehdr
dw 0 ; p_vaddr
_start: mov bl, 42
xor eax, eax ; p_paddr
jmp short part2
dd filesz ; p_filesz
dd filesz ; p_memsz
dd 5 ; p_flags
dd 0x1000 ; p_align
phdrsz equ $ - phdr
part2: inc eax
int 0x80
filesz equ $ - $$
```
现在我们到了七十九字节。如果我们能再找到存放“inc eax”指令的一个字节的空间,我们就可以把 p_addr 字段中的跳跃替换为“int 0x80”,从而让整个程序**合法地**位于头内部!就差这一个可恶的字节……
也许我们可以让程序的最后一个字节溢出到下一个字段?那将是 p_filesz 字段,它指定了文件中程序段的大小。程序的最后一个字节是 0x80,它作为我们的文件大小显然太大了。也就是说:是的,我们可以谎报文件的大小,Linux 也会睁一只眼闭一只眼——但我们应该在这里保持正直。
然而,p_filesz 之后的字段是 p_memsz。如果这两个字段的顺序颠倒一下,就不会有问题了。我们之前看过这个字段:它指定了将程序加载到内存时要分配多少内存。将该字段设置为大于文件大小是完全合理的。
但这里有办法利用这个字段:我们可以跳过 p_filesz 跳入 p_memsz。如果我们这样做,我们会发现我们实际上能取得进展:
```nasm
; tiny.asm
BITS 32
org 0x2AB30000
ehdr:
db 0x7F, "ELF", 1, 1, 1 ; e_ident
times 9 db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsz ; e_ehsize
dw phdrsz ; e_phentsize
phdr: dd 1 ; e_phnum ; p_type
; e_shentsize
dd 0 ; e_shnum ; p_offset
; e_shstrndx
ehdrsz equ $ - ehdr
dw 0 ; p_vaddr
_start: mov bl, 42
xor eax, eax ; p_paddr
jmp short part2
dd filesz ; p_filesz
part2: inc eax ; p_memsz
int 0x80
db 0
dd 7 ; p_flags
dd 0x1000 ; p_align
phdrsz equ $ - phdr
filesz equ $ - $$
```
于是我们到达了七十六字节!
这个版本的缺点是程序请求了相当大的一块内存。确切地说是 0x0080CD40 字节,也就是八兆字节。(注意,我们现在必须将可写标志添加到 p_flags,以允许该内存被零初始化。)当然,这些内存实际上从未被访问过,所以考虑到虚拟内存等机制,这几乎没什么关系。这个数字真正指示的是 Linux 在哪个地址会报告段错误,而不是尝试分配一页 RAM。所以实际上没有造成什么损害。
但事实证明,有一种方法可以用一个字节跳过四个字节。怎么做到?当然是使用一个五字节的指令!考虑一个 nice、无害的指令,比如“cmp eax, imm32”。它唯一的效果就是设置标志。好吧,还有推进指令指针:
```nasm
; tiny.asm
BITS 32
org 0x2AB30000
ehdr:
db 0x7F, "ELF", 1, 1, 1 ; e_ident
times 9 db 0
dw 2 ; e_type
dw 3 ; e_machine
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw ehdrsz ; e_ehsize
dw phdrsz ; e_phentsize
phdr: dd 1 ; e_phnum ; p_type
; e_shentsize
dd 0 ; e_shnum ; p_offset
; e_shstrndx
ehdrsz equ $ - ehdr
dw 0 ; p_vaddr
_start: mov bl, 42
xor eax, eax ; p_paddr
inc eax
cmp eax, 00000000h ; p_filesz
int 0x80 ; p_memsz
...
```
相似文章
Zig ELF 二进制文件代码高尔夫 (2025)
深入技术探讨如何缩小 Zig ELF 二进制文件的大小,从 2180K 缩减至 500 字节以下,通过去除调试信息、切换到 ReleaseSmall 以及使用 freestanding 目标。
Zig ELF 链接器改进开发日志
新的 Zig ELF 链接器现在支持外部库和 C 源码的快速增量编译,在 x86_64 Linux 上能够实现毫秒级重建。
Tiny-Lua-Compiler: 可能是有史以来最小的 Lua 编译器
Tiny-Lua-Compiler 是一个用于教学的、自举的 Lua 5.1 编译器和虚拟机,完全用纯 Lua 编写。其设计目标是体积足够小以便于研究,同时又功能完备到足以处理真实的语言特性。
字节码虚拟机在意外场景中的应用 (2024)
本文探讨了字节码虚拟机的出人意料的应用,特别是Linux内核中的eBPF以及编译后二进制文件中用于调试信息的DWARF表达式。
我在Arduino UNO(2KB内存)上写了个微型类Unix“操作系统”,带Shell和文件系统
Arc1011发布KernelUNO,为Arduino UNO打造的类Unix Shell与文件系统,仅占用2KB RAM,提供22条命令用于硬件控制与文件操作。