Golfing Zig ELF binaries (2025)

jstrieb1 pts0 comments

Golfing Zig ELF Binaries | .;,;.} --> Toggle menu Toggle theme

Golfing Zig ELF Binaries<br>u unvariant

January 29, 2025<br>11 min read

pwn

index

How much can we feasibly strip from a zig binary? Starting from a normal zig program that does absolutely nothing:

main.zig1

pub fn main() void {}

Terminal windowzig build-exe main.zig -target x86_64-linux-gnu

du -hk main

# 2180 main

2180K for a binary that does nothing. Given that the smallest possible executable ELF file is around 80 bytes, 2180K is quite a bit of bloat. What happens when we strip out debug info?

Terminal windowzig build-exe main.zig -target x86_64-linux-gnu -fstrip

du -hk main

# 192 main

Saved 1988K just by stripping out debugging information. However 192K is still quite far from our 80 byte goal. We are still compiling in Debug mode, so let’s switch to ReleaseSmall (equivalent to -Os for gcc/clang as far as I can tell).

Terminal windowzig build-exe main.zig -target x86_64-linux-gnu -fstrip -OReleaseSmall

du -hk main

# 12 main

Now we’re at 12K! Saved 180K just by switching from Debug to ReleaseSmall. Next step is to enable function and data sections to allow the linker to strip away unreferenced functions or data.

Terminal windowzig build-exe main.zig -target x86_64-linux-gnu -fstrip -OReleaseSmall -ffunction-sections -fdata-sections --gc-sections

du -hk main

# 12 main

…and that did nothing. I guess ReleaseSmall already handles this optimization.<br>Taking a peek at the ELF sections shows quite a few unnecessary sections:

Terminal windowThere are 9 section headers, starting at offset 0x2068:

Section Headers:

[Nr] Name Type Address Offset

Size EntSize Flags Link Info Align

[ 0] 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 and .eh_frame_hdr are generated to provide unwinding information, and is not strictly necessary for the the binary to run. The .comment section holds useless metadata. .tbss is a section for thread local storage, which is also unnecessary since the program does not do any threading.

Terminal windowzig 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

Switching from x86_64-linux-gnu to x86_64-freestanding-none cuts most of the extra cruft from the binary, down to 472 bytes. Looking at the sections now reveals that all but 2 sections have been removed:

Terminal windowThere are 3 section headers, starting at offset 0x118:

Section Headers:

[Nr] Name Type Address Offset

Size EntSize Flags Link Info Align

[ 0] 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:

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)

But something isn’t quite right. The binary no longer contains any executable code. This is because we have to change our executable’s entrypoint. Now that our platform is freestanding, the entrypoint is _start instead of main.

const syscall1 = @import("std").os.linux.syscall1;

export fn _start() void {

_ = syscall1(.exit, 0);

Our compile command hasn’t changed and the binary size is now slightly larger.

Terminal windowzig build-exe main.zig -target x86_64-freestanding-none -fstrip -OReleaseSmall

wc -c main

# 616 main

Except now our binary has some executable code this time:

Terminal windowThere are 4 section headers, starting at offset 0x168:

Section Headers:

[Nr] Name Type Address Offset

Size EntSize Flags Link Info Align

[ 0] NULL 0000000000000000 00000000

0000000000000000 0000000000000000...

main terminal x86_64 sections section binary

Related Articles