Zig ELF 二进制文件代码高尔夫 (2025)

Lobsters Hottest 新闻

摘要

深入技术探讨如何缩小 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 链接器改进开发日志

Hacker News Top

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

Zig 构建速度正在提升

Mitchell Hashimoto

Zig 0.15 相比 0.14 在编译时性能有显著提升,构建脚本编译时间从约 7 秒降至约 1.7 秒,完整构建时间从 41 秒降至 32 秒,且仍使用 LLVM。本文重点介绍了自托管后端和增量编译方面的进展。

构建系统重构

Lobsters Hottest

Zig 构建系统已经重构,将配置器和制造器进程分离,支持缓存、发布模式编译,并且'zig build'命令速度提升高达90%。这一变化提高了性能,并允许构建系统在不减速的情况下增加功能。

用 Zig 写一个 C 编译器

Hacker News Top

一位开发者记录了用 Zig 语言、按照 Nora Sandler 的教程系列构建名为 paella 的 C 编译器的全过程。

最小可行的Zig错误上下文

matklad

一篇博文,详细介绍了使用errdefer日志在Zig中添加错误上下文的最小模式,并将其与完整的诊断接收器(diagnostics sinks)和catch块进行比较,讨论了权衡取舍。