Zig ELF 二进制文件代码高尔夫 (2025)
摘要
深入技术探讨如何缩小 Zig ELF 二进制文件的大小,从 2180K 缩减至 500 字节以下,通过去除调试信息、切换到 ReleaseSmall 以及使用 freestanding 目标。
<p><a href="https://lobste.rs/s/agojmb/golfing_zig_elf_binaries_2025">评论</a></p>
查看缓存全文
缓存时间: 2026/05/20 16:29
# 高尔夫化 Zig ELF 二进制文件
来源:https://ctf.gg/blog/zig-binary-golfing
1. https://ctf.gg/
2. 博客 (https://ctf.gg/blog)
3. 高尔夫化 Zig ELF 二进制文件
## 高尔夫化 Zig ELF 二进制文件
2025年1月29日 · 阅读时间 11 分钟 · pwn (https://ctf.gg/tags/pwn)
上一篇文章:调试 GDB 自动补全 (https://ctf.gg/blog/debugging-gdb-autocomplete#post-title)
下一篇文章:WolvCTF 2025: "wasm4" (https://ctf.gg/blog/wolvctf-2025-wasm4#post-title)
## 目录
我们能从 Zig 二进制文件中去除多少冗余?从一段什么都不做的普通 Zig 程序开始:
```
zig build-exe main.zig -target x86_64-linux-gnu
du -hk main
# 2180 main
```
一个什么都不做的二进制文件居然有 2180K!考虑到最小的可执行 ELF 文件大约只有 80 字节,2180K 确实相当臃肿。如果去除调试信息会怎样?
```
zig build-exe main.zig -target x86_64-linux-gnu -fstrip
du -hk main
# 192 main
```
仅通过剥离调试信息就节省了 1988K。不过 192K 仍然离我们 80 字节的目标很远。目前我们仍在 Debug 模式下编译,现在切换到 ReleaseSmall(据我所知相当于 gcc/clang 的 -Os)。
```
zig build-exe main.zig -target x86_64-linux-gnu -fstrip -OReleaseSmall
du -hk main
# 12 main
```
现在只有 12K 了!仅通过从 Debug 切换到 ReleaseSmall 就节省了 180K。下一步是启用函数和数据节,以便链接器能剥离未引用的函数或数据。
```
zig build-exe main.zig -target x86_64-linux-gnu -fstrip -OReleaseSmall -ffunction-sections -fdata-sections --gc-sections
du -hk main
# 12 main
```
……什么变化也没有。我猜 ReleaseSmall 已经处理了这项优化。查看 ELF 段会发现有不少不必要的段:
```
There are 9 section headers, starting at offset 0x2068:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .rodata PROGBITS 00000000010001c8 000001c8
0000000000000954 0000000000000000 AMS 0 0 8
[ 2] .eh_frame_hdr PROGBITS 0000000001000b1c 00000b1c
00000000000000bc 0000000000000000 A 0 0 4
[ 3] .eh_frame PROGBITS 0000000001000bd8 00000bd8
00000000000003d4 0000000000000000 A 0 0 8
[ 4] .text PROGBITS 0000000001001fac 00000fac
0000000000001041 0000000000000000 AX 0 0 4
[ 5] .tbss NOBITS 0000000001002ff0 00001ff0
000000000000000d 0000000000000000 WAT 0 0 8
[ 6] .bss NOBITS 0000000001004000 00002000
0000000000003108 0000000000000000 WA 0 0 4096
[ 7] .comment PROGBITS 0000000000000000 00002000
000000000000001c 0000000000000001 MS 0 0 1
[ 8] .shstrtab STRTAB 0000000000000000 0000201c
0000000000000045 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude), D (mbind),
l (large), p (processor specific)
```
`.eh_frame` 和 `.eh_frame_hdr` 是为了提供展开信息而生成的,对运行二进制文件并非必须。`.comment` 段包含无用的元数据。`.tbss` 是线程局部存储段,由于程序没有使用任何线程,也是不必要的。
```
zig build-exe main.zig -target x86_64-freestanding-none -fstrip -OReleaseSmall
# warning(link): unexpected LLD stderr:
# ld.lld: warning: cannot find entry symbol _start; not setting start address
wc -c main
# 472 main
```
将目标从 `x86_64-linux-gnu` 切换到 `x86_64-freestanding-none` 可以去掉二进制文件中大部分额外内容,降至 472 字节。现在查看段会发现除了 2 个段之外,其他都被移除了:
```
There are 3 section headers, starting at offset 0x118:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .comment PROGBITS 0000000000000000 000000e8
000000000000001c 0000000000000001 MS 0 0 1
[ 2] .shstrtab STRTAB 0000000000000000 00000104
0000000000000014 0000000000000000 0 0 1
Key to Flags:
(同上省略)
```
但有些不对劲:二进制文件中不再包含任何可执行代码。这是因为我们需要修改可执行文件的入口点。现在平台是独立环境(freestanding),入口点是 `_start` 而非 `main`。
```zig
const syscall1 = @import("std").os.linux.syscall1;
export fn _start() void {
_ = syscall1(.exit, 0);
}
```
编译命令未改变,二进制文件大小略有增加:
```
zig build-exe main.zig -target x86_64-freestanding-none -fstrip -OReleaseSmall
wc -c main
# 616 main
```
不过这次二进制文件中确实包含了一些可执行代码:
```
There are 4 section headers, starting at offset 0x168:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000001001120 00000120
000000000000000b 0000000000000000 AX 0 0 4
[ 2] .comment PROGBITS 0000000000000000 0000012b
000000000000001c 0000000000000001 MS 0 0 1
[ 3] .shstrtab STRTAB 0000000000000000 00000147
000000000000001a 0000000000000000 0 0 1
Key to Flags:
(同上省略)
```
查看 .text 段的大小,只包含 11 字节的代码。额外多出的 605 字节来自何处?用 readelf 进一步检查 ELF,发现共有 4 个程序段。每个程序段占用 56 字节,总计 `56 * 4 = 224` 字节。
```
Elf 文件类型为 EXEC (可执行文件)
入口点 0x1001120
共有 4 个程序头,起始于偏移量 64
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000001000040 0x0000000001000040 0x0000e0 0x0000e0 R 0x8
LOAD 0x000000 0x0000000001000000 0x0000000001000000 0x000120 0x000120 R 0x1000
LOAD 0x000120 0x0000000001001120 0x0000000001001120 0x00000b 0x00000b R E 0x1000
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x0
Section to Segment mapping:
分段 段 ...
00
01
02 .text
03
```
`GNU_STACK` 完全是可选的,仅作为 Linux 内核的提示。`PHDR` 同样是不必要的,两个 `LOAD` 段可以合并成一个大的 RWX 段。我们无法直接从命令行控制程序段,因此需要编写链接脚本。这个脚本创建了一个包含所有可执行代码和数据的单一 RWX 段,将 4 个段缩减为 1 个。
```
ENTRY(_start)
PHDRS
{
code PT_LOAD FLAGS(7);
}
SECTIONS
{
. = SIZEOF_HEADERS;
.text : ALIGN(1) { *(.text.*) }
.rodata : ALIGN(1) { *(.rodata.*) }
.data : ALIGN(1) { *(.data.*) }
.bss : ALIGN(1) { *(.bss.*) }
}
```
使用链接脚本重新编译,二进制文件降至 `616 - 56 * 3 = 448` 字节。
```
zig build-exe main.zig -target x86_64-freestanding-none -fstrip -OReleaseSmall -T linker.ld
wc -c main
# 448 main
```
我们将注意力转回到二进制文件的节头中。Linux 内核完全忽略节头,因此可以安全地移除它们而不影响二进制文件。`.comment` 和 `.shstrtab` 的内容也可以剥离,因为它们没有被任何程序段映射。
```
There are 4 section headers, starting at offset 0xc0:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000078 00000078
000000000000000b 0000000000000000 AX 0 0 4
[ 2] .comment PROGBITS 0000000000000000 00000083
000000000000001c 0000000000000001 MS 0 0 1
[ 3] .shstrtab STRTAB 0000000000000000 0000009f
000000000000001a 0000000000000000 0 0 1
Key to Flags:
(同上省略)
```
这里我们可以利用编译器布局 ELF 文件的方式:
```
ELF Header
Program segments
Section data (ALLOC)
Section data
Section headers
```
标记为 `ALLOC` 的节是被程序段映射且程序执行所需的节。ELF 文件的创建方式使得节头和非 ALLOC 节都位于文件末尾的一个连续块中。要剥离额外的元数据,我们可以截断最后一个 `ALLOC` 节之后的所有数据。
```python
from pwnc.minelf import ELF
elf = ELF(open("main", "rb").read())
offset = 0
for section in elf.sections:
if section.flags & elf.Section.Flags.ALLOC != 0:
offset = section.offset + section.size
elf.header.section_offset = 0
elf.header.number_of_sections = 0
elf.header.section_name_table_index = 0
elf.raw_elf_bytes = elf.raw_elf_bytes[:offset]
elf.write("main")
```
编译并修补后,得到一个 131 字节的二进制文件。好多了。
```
zig build-exe main.zig -target x86_64-freestanding-none -fstrip -OReleaseSmall -T linker.ld
python3 patch.py
wc -c main
# 131 main
```
现在我们可以对二进制文件中的代码进行一些优化以节省几个字节。反汇编显示该函数仍然试图返回(尽管程序在此之前已退出),并且末尾有一个奇怪的额外存根函数。
```
main: 文件格式 elf64-x86-64
PT_LOAD#0 的反汇编:
0000000000000078 <_start>:
78: 6a 3c push 60
7a: 58 pop rax
7b: 31 ff xor edi, edi
7d: 0f 05 syscall
7f: c3 ret
0000000000000080 <getauxval>:
80: 31 c0 xor eax, eax
82: c3 ret
```
将函数标记为 `noreturn` 可以消除一个多余的 `ret` 指令。
```zig
const syscall1 = @import("std").os.linux.syscall1;
export fn _start() noreturn {
_ = syscall1(.exit, 0);
unreachable;
}
```
```
main: 文件格式 elf64-x86-64
PT_LOAD#0 的反汇编:
0000000000000078 <_start>:
78: 6a 3c push 60
7a: 58 pop rax
7b: 31 ff xor edi, edi
7d: 0f 05 syscall
7f: 31 c0 xor eax, eax
81: c3 ret
```
从 `syscall1` 切换到 `syscall0` 消除了 `xor edi, edi`。
```zig
const syscall0 = @import("std").os.linux.syscall0;
export fn _start() noreturn {
_ = syscall0(.exit);
unreachable;
}
```
```
main: 文件格式 elf64-x86-64
PT_LOAD#0 的反汇编:
0000000000000078 <_start>:
78: 6a 3c push 60
7a: 58 pop rax
7b: 0f 05 syscall
7d: 31 c0 xor eax, eax
7f: c3 ret
```
`_start` 已经被标记为 `noreturn`,那么 `xor eax, eax ; ret` 是从哪来的?我们可以临时用 `-fno-strip` 重新编译并转储二进制文件,找出多余指令的来源。
```
main: 文件格式 elf64-x86-64
.text 的反汇编:
0000000000000078 <_start>:
78: 6a 3c push 60
7a: 58 pop rax
7b: 0f 05 syscall
000000000000007d <getauxval>:
7d: 31 c0 xor eax, eax
7f: c3 ret
```
这里 `getauxval` 是怎么冒出来的???这是一个独立环境,根本不应该使用辅助值。由于该函数未被任何东西引用,添加 `-flto` 编译选项来剥离未使用的函数和数据,即可移除多余的代码。
```
zig build-exe main.zig -target x86_64-freestanding-none -fstrip -OReleaseSmall -T linker.ld -flto
python3 patch.py
wc -c main
# 125 main
```
```
main: 文件格式 elf64-x86-64
PT_LOAD#0 的反汇编:
0000000000000078 <_start>:
78: 6a 3c push 60
7a: 58 pop rax
7b: 0f 05 syscall
```
这是在不使用技巧重叠 ELF 元数据以进一步缩小二进制文件的情况下,能达到的绝对极限。
| 组成部分 | 大小 |
|---------------------|------------|
| ELF 头 | 64 字节 |
| 程序头 | 56 字节 |
| 代码 | 5 字节 |
| **总计** | **125 字节**|
在二进制文件能在所有 Linux 系统上运行之前,还需要做最后一项更改。当前程序头将二进制文件映射到地址 `0x00000078`,这要求 Linux 内核在地址 `0x00000000` 处映射一个页面。
```
Elf 文件类型为 EXEC (可执行文件)
入口点 0x78
共有 1 个程序头,起始于偏移量 64
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000078 0x0000000000000078 0x0000000000000078 0x000005 0x000005 RWE 0x1000
```
大多数 Linux 发行版将 sysctl 值 `vm.mmap_min_addr` 设置为非零地址,以减轻利用内核 NULL 解引用的内核漏洞。这意味着当前的二进制文件无法在大多数现代 Linux 发行版上运行。要修复此问题,我们可以更新 Python 修补脚本,将 ELF 文件类型从 `EXEC` 改为 `DYN`。这会告诉 Linux 内核为二进制文件选择一个基地址,而不是直接使用程序段的地址。
```python
from pwnc.minelf import ELF
elf = ELF(open("main", "rb").read())
elf.header.type = elf.Header.Type.DYN
offset = 0
for section in elf.sections:
if section.flags & elf.Section.Flags.ALLOC != 0:
offset = section.offset + section.size
elf.header.section_offset = 0
elf.header.number_of_sections = 0
elf.header.section_name_table_index = 0
elf.raw_elf_bytes = elf.raw_elf_bytes[:offset]
elf.write("main")
```
最终的 ELF 文件:
```
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 的补码,小端序
版本: 1 (当前)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: DYN (共享对象文件)
机器: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x78
程序头起始位置: 64 (文件中的字节偏移)
节头起始位置: 0 (文件中的字节偏移)
标志: 0x0
本头大小: 64 (字节)
程序头大小: 56 (字节)
程序头数量: 1
节头大小: 64 (字节)
节头数量: 0
节头字符串表索引: 0
此文件中没有节。
此文件中没有节组。
程序头:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000078 0x0000000000000078 0x0000000000000078 0x000005 0x000005 RWE 0x1000
此文件中没有动态段。
此文件中没有重定位。
未解码处理器特定的展开信息。
动态符号信息不可用于显示符号。
此文件中未找到版本信息。
```
```
main: 文件格式 elf64-x86-64
PT_LOAD#0 的反汇编:
0000000000000078 <_start>:
78: 6a 3c push 60
7a: 58 pop rax
7b: 0f 05 syscall
```
上一篇文章:调试 GDB 自动补全 (https://ctf.gg/blog/debugging-gdb-autocomplete#post-title)
下一篇文章:WolvCTF 2025: "wasm4" (https://ctf.gg/blog/wolvctf-2025-wasm4#post-title)
相似文章
Zig ELF 链接器改进开发日志
新的 Zig ELF 链接器现在支持外部库和 C 源码的快速增量编译,在 x86_64 Linux 上能够实现毫秒级重建。
Zig 构建速度正在提升
Zig 0.15 相比 0.14 在编译时性能有显著提升,构建脚本编译时间从约 7 秒降至约 1.7 秒,完整构建时间从 41 秒降至 32 秒,且仍使用 LLVM。本文重点介绍了自托管后端和增量编译方面的进展。
构建系统重构
Zig 构建系统已经重构,将配置器和制造器进程分离,支持缓存、发布模式编译,并且'zig build'命令速度提升高达90%。这一变化提高了性能,并允许构建系统在不减速的情况下增加功能。
用 Zig 写一个 C 编译器
一位开发者记录了用 Zig 语言、按照 Nora Sandler 的教程系列构建名为 paella 的 C 编译器的全过程。
最小可行的Zig错误上下文
一篇博文,详细介绍了使用errdefer日志在Zig中添加错误上下文的最小模式,并将其与完整的诊断接收器(diagnostics sinks)和catch块进行比较,讨论了权衡取舍。