迷你可执行文件再探

Hacker News Top 论文

摘要

本文重新审视了在 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)

Lobsters Hottest

深入技术探讨如何缩小 Zig ELF 二进制文件的大小,从 2180K 缩减至 500 字节以下,通过去除调试信息、切换到 ReleaseSmall 以及使用 freestanding 目标。

Zig ELF 链接器改进开发日志

Hacker News Top

新的 Zig ELF 链接器现在支持外部库和 C 源码的快速增量编译,在 x86_64 Linux 上能够实现毫秒级重建。