TIPThis post comes from a real-world pitfall: sanitizing a Zig-written Windows DLL + EXE (a proxy loader disguised as a.dll) before release. The final takeaway — Zig’s “conditional strip” is easy to implement, but source paths inside CodeView auxiliary strings can’t be controlled by any compile-time flag and require post-install byte patching.
Why It Matters
The first thing a reverse analyst does when they get a binary:
- Open IDA / Ghidra, inspect the export table, import table, strings
- If a
.pdbwith the same name is lying around, load it — variable names, function names, source paths all handed over - Run
stringsto grab plaintext strings from.rdata
If the binary still contains absolute paths from the development machine (e.g. C:\src\targetstring\loader\src\proxy.c), you’ve given away the project name, source structure, and dev machine directory layout for free. A blue-team search by path signature exposes the entire repository.
What Zig’s Strip Actually Removes
Zig’s .strip = true (or -fstrip) does two things:
- Removes the symbol table (
.symtab,.debug_*and other debug sections) - Prevents PDB / debug info generation
But it does not remove string data from .rdata. This is a critical insight: __FILE__ macro expansions and compiler-generated debug auxiliary strings are “ordinary data” — strip has no way to identify them as something to delete.
I accidentally ran a perfect controlled experiment within the same ReleaseSafe build:
| Artifact | strip | PDB |
|---|---|---|
-client.exe | .strip = true | No .pdb |
a.dll (proxy loader) | Forgot to configure strip | Has 3MB a.pdb |
strip=true → no PDB generated; strip not configured → PDB is always generated.
And IDA auto-loads same-directory same-name PDB files — parameter names like dest/src/len are directly exposed, essentially giving away the source code’s “directory”.
Conditional Strip (-Dstrip)
A blanket strip creates a contradiction:
- Development / AI debugging: need PDB (IDA decompilation quality is night and day with symbols)
- Production release: no PDB (security)
The solution is a build option that defaults based on the optimization mode:
const strip_opt = b.option( bool, "strip", "strip debug info / suppress PDB (default: false in Debug, true in Release)",) orelse (optimize != .Debug);Then apply it to all production artifacts:
const client_exe = b.addExecutable(.{ .root_module = b.createModule(.{ .strip = strip_opt, // ... }),});const client_static_lib = b.addLibrary(.{ .root_module = b.createModule(.{ .strip = strip_opt, /* ... */ }),});const a_dll = b.addLibrary(.{ .root_module = b.createModule(.{ .strip = strip_opt, /* ... */ }),});Usage:
| Scenario | Command | PDB |
|---|---|---|
| Development / RE debugging | zig build (Debug by default) | ✅ Generated |
| Production release | zig build -Doptimize=ReleaseSafe | ❌ Not generated |
| Force override | zig build -Dstrip=true/false | Follows flag |
WARNINGWatch out: “non-primary” artifacts like
a.dllare especially easy to miss when configuring strip. I found that the exe and static lib were both configured, but the dynamic lib wasn’t — and it’s precisely the most common IDA analysis target. Double-check everyinstallArtifact.
The Big Gotcha: Source Paths Written into .rdata
After fixing strip, I thought I was done. A quick rg -a "targetstring" scan:
a.dll: 17 hitstargetstring-client.exe: 9 hitsstrings revealed:
Z:\src\targetstring\loader\src\proxy.cZ:\src\targetstring\loader\src\anti_sandbox.cZ:\src\targetstring\loader\src\xtea.hZ:\src\targetstring\zig-pkg\N-V-__8AAJud...\c\dec\decode.cAccompanied by a bunch of 'DWORD' (aka 'unsigned long'), 'const uint64_t' (aka ...) type strings — these are clang CodeView debug auxiliary strings, stored in .rdata (not the .debug section!). So strip can’t remove them either (.rdata is ordinary data).
Four Failures: No Compile-Time Flag Works
I tried every tutorial online, all failed:
-fmacro-prefix-map=C:\src\targetstring=.— only affects the__FILE__macro. Compiling a single C file withzig ccdoes remap__FILE__to.\loader\src\proxy.c. But paths remain in the full DLL build.-ffile-prefix-map/-fdebug-prefix-map— LLVM applies these to DWARF, but not to CodeView auxiliary path strings.-g0— I naively tried to disable C debug info. But zig re-adds debug flags after-cflags, overwriting-g0.-fdebug-compilation-dir=.— zig explicitly sets the compilation directory to the project root; my override in-cflagsgot overridden.
I also confirmed with an invalid flag probe that zig does forward -cflags (adding -ftargetstring-invalid-probe gets rejected by clang) — so the flags arrive, but they don’t govern CodeView strings.
Root Cause
Locating the paths with pefile:
off=0x617600 rva=0x619000 sec=.rdata :: Z:\...\loader\src\proxy.cThe prefix is \xcc\xcc\xcc\xcc (MSVC’s int3 padding), followed by (aka ...) type strings — this is the auxiliary string table clang generates for CodeView, placed in .rdata by zig. These paths are hardcoded source file paths in zig’s compilation pipeline; -cflags only affects the code generation layer and can’t intercept them.
Solution: Post-Install Byte Patching
Since compile-time can’t handle it, just patch the bytes after the build. Here’s a small script:
MARKER = b"Z:\\src\\targetstring" # Replace with your project's absolute path prefixREPLACEMENT = b"." * len(MARKER) # Same-length replacement to preserve file structure
def sanitize(path: str) -> bool: with open(path, "rb") as f: data = f.read() if MARKER not in data: return False with open(path, "wb") as f: f.write(data.replace(MARKER, REPLACEMENT)) return TrueKey points:
- Same-length replacement:
Z:\src\targetstring(fixed length) replaced with the same number of.characters, preserving file layout - PDB untouched: debuggers parse source paths from PDB, so PDB keeps the real paths — when scanning binaries with
rg, PDB hits don’t count (it’s a symbol file, not distributed with releases)
Integration:
// build.zig: manual step (avoids install dependency cycles){ const strip_cmd = b.addSystemCommand(&.{ "python", "tools/strip_source_paths.py", b.getInstallPath(.bin, ""), }); strip_cmd.step.dependOn(b.getInstallStep()); const strip_step = b.step("strip-paths", "sanitize absolute source paths in zig-out/bin"); strip_step.dependOn(&strip_cmd.step);}# CI (.github/workflows/build.yml): runs automatically after every Windows build- name: Sanitize source paths from binaries if: contains(matrix.target, 'windows') run: | python3 tools/strip_source_paths.py zig-out/binTIPWhy not make this an automatic build step? Zig has no “post-install” hook —
installand any post-processing step create a dependency cycle (install → strip → install). The standard approach is CI integration (same pattern as the existingstrip_buildid.py), with a manualzig build strip-pathsavailable locally.
Verification results:
$ python scan_paths.py # Custom scan scripta.dll path-hits=17 → 0targetstring-client.exe path-hits=9 → 0Another Gotcha: The .buildid Section
GitHub Actions build artifacts include a .buildid section that may contain repository/commit info. The project already has tools/strip_buildid.py to remove it in CI:
- name: Strip .buildid section run: | for exe in zig-out/bin/*.exe; do python3 tools/strip_buildid.py "$exe" || exit 1 doneWhen auditing these “metadata sections”, just use objdump -h or pefile to iterate the section list and check for suspicious names.
Pre-Release Checklist
# 1. No PDB shipped with binariesls zig-out/bin/*.pdb # Should be empty (Release build)
# 2. No absolute source paths remainingrg -a "targetstring" zig-out/bin/*.dll zig-out/bin/*.exe # Should be 0
# 3. No .buildid section# (Handled automatically by CI)
# 4. IDA verification: open the binary, confirm no symbols, no paths, functions named sub_XXXConclusion
- Conditional strip is easy:
-Dstripoption +optimize != .Debug, works for both dev and release - Strip only removes symbols, not
.rdatastrings — string-level leakage requires other measures - CodeView auxiliary strings (the
(aka ...)kind) containing source paths are unaffected by all compile-time flags;-ffile-prefix-maponly works for DWARF and__FILE__ - Post-install byte patching is the most reliable fallback: same-length replacement, PDB preserved, CI-integrated
If you’re building native Windows projects that need to resist reverse engineering, add “no PDB + no paths + no .buildid” to your release checklist.