我们究竟能从一个 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 分段会发现有很多不必要的段:
共有 9 个段头,起始偏移量 0x2068:
...
.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 个段外,其余均已被移除。
但这有些不对劲。该二进制文件不再包含任何可执行代码。这是因为我们必须更改可执行文件的入口点。现在我们的平台是 freestanding 的,入口点是 _start 而不是 main。
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
现在我们的二进制文件包含了一些可执行代码:
查看文本段的大小,它只包含 11 字节的代码。多出的 605 字节从哪里来?使用 readelf 进一步检查 ELF,发现有 4 个程序段。每个程序段占用 56 字节,总共 56 * 4 = 224 字节。
GNU_STACK 完全是可选的,仅作为 Linux 内核的提示。PHDR 同样不必要,两个 LOAD 段可以合并为一个大的 RWX 段。我们无法直接从命令行控制程序段,因此需要写一个链接器脚本。
该脚本创建了一个跨越所有可执行代码和数据的单一 RWX 段,将 4 个段减少为一个。
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 的内容也可以剥离,因为它们没有被任何程序段映射。
我们可以在此处利用编译器布局 ELF 文件的方式。
被标记为 ALLOC 的段是那些由程序段映射并为程序执行所必需的段。按照 ELF 文件的创建方式,段头和非 ALLOC 段都位于文件末尾的一个连续块中。为了剥离多余的元数据,我们可以剪掉最后一个 ALLOC 段之后的所有数据。
from pwnc.minelf import ELF
elf = ELF(open("main", "rb").read())
# ... (修补逻辑)
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
现在我们可以对二进制代码进行一些优化以节省几个字节。反汇编代码显示该函数在程序退出前仍试图返回,末尾还有一个奇怪的额外存根函数。
将函数标记为 noreturn 消除了多余的 ret 指令。
从 syscall1 切换到 syscall0 消除了 xor edi, edi。
_start 已经被标记为 noreturn,那么 xor eax, eax ; ret 是从哪里来的?我们可以暂时重新编译并带上 -fno-strip 并转储二进制文件来找出这些额外指令的来源。
getauxval 在这里做什么???这是一个 freestanding 环境,所以根本不应该使用辅助值。由于该函数未被任何内容引用,添加 -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
这是我们在不使用技巧重叠 ELF 元数据来进一步缩小二进制文件的情况下所能达到的绝对极限。
在二进制文件可以在所有 Linux 系统上运行之前,还需要做出最后一点更改。目前,程序头将二进制文件映射在地址 0x00000078 处,这要求 Linux 内核在地址 0x00000000 处映射一页。
大多数 Linux 发行版将 sysctl 值 vm.mmap_min_addr 设置为非零地址,以减轻利用内核 NULL 解引用的漏洞。这意味着按照目前的状态,该二进制文件将无法在大多数现代 Linux 发行版上运行。为了修复这个问题,我们可以更新 Python 修补脚本,将 ELF 文件类型从 EXEC 更改为 DYN。这将告诉 Linux 内核为二进制文件选择一个基地址,而不是直接使用程序段地址。
elf.header.type = elf.Header.Type.DYN
最终的 ELF 文件已完成。
Golfing Zig ELF Binaries | .;,;.
加入我们
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:
- 供稿,分享自己使用 Zig 的心得
- 改进 ZigCC 组织下的开源项目
- 加入微信群、Telegram 群组
我们究竟能从一个 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 分段会发现有很多不必要的段:
.eh_frame和.eh_frame_hdr是为提供展开信息而生成的,对于二进制文件的运行并非绝对必要。.comment段包含无用的元数据。.tbss是线程本地存储段,由于程序不进行任何线程处理,因此也是不必要的。从
x86_64-linux-gnu切换到x86_64-freestanding-none去除了二进制文件中大部分额外的垃圾,降至 472 字节。现在查看分段发现,除 2 个段外,其余均已被移除。但这有些不对劲。该二进制文件不再包含任何可执行代码。这是因为我们必须更改可执行文件的入口点。现在我们的平台是
freestanding的,入口点是_start而不是main。我们的编译命令没有改变,但二进制文件现在稍微大了一点。
zig build-exe main.zig -target x86_64-freestanding-none -fstrip -OReleaseSmall wc -c main # 616 main现在我们的二进制文件包含了一些可执行代码:
查看文本段的大小,它只包含 11 字节的代码。多出的 605 字节从哪里来?使用
readelf进一步检查 ELF,发现有 4 个程序段。每个程序段占用 56 字节,总共 56 * 4 = 224 字节。GNU_STACK完全是可选的,仅作为 Linux 内核的提示。PHDR同样不必要,两个LOAD段可以合并为一个大的RWX段。我们无法直接从命令行控制程序段,因此需要写一个链接器脚本。该脚本创建了一个跨越所有可执行代码和数据的单一
RWX段,将 4 个段减少为一个。使用链接器脚本重新编译使二进制文件减小到 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的内容也可以剥离,因为它们没有被任何程序段映射。我们可以在此处利用编译器布局 ELF 文件的方式。
被标记为
ALLOC的段是那些由程序段映射并为程序执行所必需的段。按照 ELF 文件的创建方式,段头和非ALLOC段都位于文件末尾的一个连续块中。为了剥离多余的元数据,我们可以剪掉最后一个ALLOC段之后的所有数据。编译并修补后得到了 131 字节的二进制文件。好多了。
zig build-exe main.zig -target x86_64-freestanding-none -fstrip -OReleaseSmall -T linker.ld python3 patch.py wc -c main # 131 main现在我们可以对二进制代码进行一些优化以节省几个字节。反汇编代码显示该函数在程序退出前仍试图返回,末尾还有一个奇怪的额外存根函数。
将函数标记为
noreturn消除了多余的ret指令。从
syscall1切换到syscall0消除了xor edi, edi。_start已经被标记为noreturn,那么xor eax, eax ; ret是从哪里来的?我们可以暂时重新编译并带上-fno-strip并转储二进制文件来找出这些额外指令的来源。getauxval在这里做什么???这是一个freestanding环境,所以根本不应该使用辅助值。由于该函数未被任何内容引用,添加-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这是我们在不使用技巧重叠 ELF 元数据来进一步缩小二进制文件的情况下所能达到的绝对极限。
在二进制文件可以在所有 Linux 系统上运行之前,还需要做出最后一点更改。目前,程序头将二进制文件映射在地址
0x00000078处,这要求 Linux 内核在地址0x00000000处映射一页。大多数 Linux 发行版将
sysctl值vm.mmap_min_addr设置为非零地址,以减轻利用内核 NULL 解引用的漏洞。这意味着按照目前的状态,该二进制文件将无法在大多数现代 Linux 发行版上运行。为了修复这个问题,我们可以更新 Python 修补脚本,将 ELF 文件类型从EXEC更改为DYN。这将告诉 Linux 内核为二进制文件选择一个基地址,而不是直接使用程序段地址。最终的 ELF 文件已完成。
加入我们
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来: