1106 字
6 分钟
Information Leakage in Zig Build Artifacts: Conditional Strip & Source Path Sanitization
TIP

This 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:

  1. Open IDA / Ghidra, inspect the export table, import table, strings
  2. If a .pdb with the same name is lying around, load it — variable names, function names, source paths all handed over
  3. Run strings to 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:

ArtifactstripPDB
-client.exe.strip = trueNo .pdb
a.dll (proxy loader)Forgot to configure stripHas 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:

build.zig
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:

ScenarioCommandPDB
Development / RE debuggingzig build (Debug by default)✅ Generated
Production releasezig build -Doptimize=ReleaseSafe❌ Not generated
Force overridezig build -Dstrip=true/falseFollows flag
WARNING

Watch out: “non-primary” artifacts like a.dll are 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 every installArtifact.

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 hits
targetstring-client.exe: 9 hits

strings revealed:

Z:\src\targetstring\loader\src\proxy.c
Z:\src\targetstring\loader\src\anti_sandbox.c
Z:\src\targetstring\loader\src\xtea.h
Z:\src\targetstring\zig-pkg\N-V-__8AAJud...\c\dec\decode.c

Accompanied 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:

  1. -fmacro-prefix-map=C:\src\targetstring=. — only affects the __FILE__ macro. Compiling a single C file with zig cc does remap __FILE__ to .\loader\src\proxy.c. But paths remain in the full DLL build.
  2. -ffile-prefix-map / -fdebug-prefix-map — LLVM applies these to DWARF, but not to CodeView auxiliary path strings.
  3. -g0 — I naively tried to disable C debug info. But zig re-adds debug flags after -cflags, overwriting -g0.
  4. -fdebug-compilation-dir=. — zig explicitly sets the compilation directory to the project root; my override in -cflags got 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.c

The 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 prefix
REPLACEMENT = 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 True

Key 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/bin
TIP

Why not make this an automatic build step? Zig has no “post-install” hook — install and any post-processing step create a dependency cycle (install → strip → install). The standard approach is CI integration (same pattern as the existing strip_buildid.py), with a manual zig build strip-paths available locally.

Verification results:

$ python scan_paths.py # Custom scan script
a.dll path-hits=17 → 0
targetstring-client.exe path-hits=9 → 0

Another 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
done

When auditing these “metadata sections”, just use objdump -h or pefile to iterate the section list and check for suspicious names.

Pre-Release Checklist#

Terminal window
# 1. No PDB shipped with binaries
ls zig-out/bin/*.pdb # Should be empty (Release build)
# 2. No absolute source paths remaining
rg -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_XXX

Conclusion#

  1. Conditional strip is easy: -Dstrip option + optimize != .Debug, works for both dev and release
  2. Strip only removes symbols, not .rdata strings — string-level leakage requires other measures
  3. CodeView auxiliary strings (the (aka ...) kind) containing source paths are unaffected by all compile-time flags; -ffile-prefix-map only works for DWARF and __FILE__
  4. 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.

Information Leakage in Zig Build Artifacts: Conditional Strip & Source Path Sanitization
https://tski.uk/blog/en/zig-strip-source-paths/
作者
Tokisaki Galaxy
发布于
2026-08-13
许可协议
CC BY