1634 字
8 分钟
Zig 构建产物的信息泄露:条件 strip 与源码路径清除
TIP

本文来自真实踩坑:给一个 Zig 写的 Windows DLL + EXE(伪装成 a.dll 的代理 loader)做发布前的信息清理。最终结论是——Zig 构建产物的”条件 strip”很好做,但 CodeView 辅助字符串里的源码路径,编译期 flag 一个都管不了,只能 post-install 字节覆写。

为什么要在意#

逆向分析者拿到一个二进制,第一件事就是:

  1. 打开 IDA / Ghidra,看导出表、导入表、字符串
  2. 如果旁边躺着同名 .pdb,直接加载——变量名、函数名、源码路径全送
  3. strings 一把梭,抓 .rdata 里的明文字符串

如果二进制里残留了开发机的绝对路径(比如 C:\src\targetstring\loader\src\proxy.c),等于把项目名、源码结构、开发机目录布局全部白送。蓝队按路径特征一搜,整个仓库就暴露了。

Zig 的 strip 到底删什么#

Zig 的 .strip = true(或 -fstrip)做两件事:

  • 删除符号表.symtab.debug_* 等调试节)
  • 阻止 PDB / 调试信息生成

但它不删 .rdata 里的字符串数据。这是个关键认知:__FILE__ 宏展开、编译器生成的调试辅助字符串,都是”普通数据”,strip 无从识别它们该删。

我踩坑时做了一个天然对照实验——同一个 ReleaseSafe 构建里:

产物stripPDB
-client.exe.strip = true .pdb
a.dll(代理 loader)忘了配 strip有 3MB a.pdb

strip=true -> 不生成 PDB;没配 strip -> PDB 一定生成

而 IDA 会自动加载同目录同名 PDB——dest/src/len 这种参数名直接暴露,等于把源码的”目录”白送。

条件 strip(-Dstrip)#

一刀切 strip 有个矛盾:

  • 开发 / AI 调试:要 PDB(IDA 加载后反编译质量天差地别)
  • 生产发布:不要 PDB(安全)

解法是做成构建选项,默认跟随优化模式:

build.zig
const strip_opt = b.option(
bool, "strip",
"strip debug info / suppress PDB (default: false in Debug, true in Release)",
) orelse (optimize != .Debug);

然后应用到所有正式产物:

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, /* ... */ }),
});

用法:

场景命令PDB
开发 / 逆向调试zig build(Debug 默认)✅ 生成
生产发布zig build -Doptimize=ReleaseSafe❌ 不生成
强制覆盖zig build -Dstrip=true/false跟随
WARNING

注意a.dll 这种”非主产物”特别容易漏配 strip。我检查时发现 exe 和静态库都配了,唯独动态库没配——而它恰恰是 IDA 最常分析的目标。建议给每个 installArtifact 的产物都过一遍。

大坑:源码路径被写进 .rdata#

修完 strip,我以为完事了。用 rg -a "targetstring" 一扫:

a.dll: 17 处
targetstring-client.exe: 9 处

strings 看内容:

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

而且伴生着一堆 'DWORD' (aka 'unsigned long')'const uint64_t' (aka ...)类型字符串——这是 clang CodeView 调试信息的辅助字符串,存在 .rdata(不是 .debug 节!)。所以 strip 也删不掉它们(.rdata 是普通数据)。

四连败:编译期 flag 一个都不管用#

我按网上教程一个个试,全部失败:

  1. -fmacro-prefix-map=C:\src\targetstring=. —— 只管 __FILE__ 宏。用 zig cc 单独编译一个 C 文件,__FILE__ 确实被映射成 .\loader\src\proxy.c。但完整构建的 DLL 里路径还在。
  2. -ffile-prefix-map / -fdebug-prefix-map —— LLVM 对 DWARF 生效,但对 CodeView 的路径辅助字符串不覆盖
  3. -g0 —— 我天真地想关掉 C 的调试信息。但 zig 编译 C 时在 -cflags 之后自己重加 debug 参数-g0 被覆盖。
  4. -fdebug-compilation-dir=. —— zig 显式设置了编译目录为项目根,我在 -cflags 里覆盖也被挤掉。

我还用非法 flag 探针确认了 zig 确实转发 -cflags(加 -ftargetstring-invalid-probe 会被 clang 拒绝)——所以 flag 到了,但管不到 CodeView 字符串

根因#

用 pefile 定位路径所在位置:

off=0x617600 rva=0x619000 sec=.rdata :: Z:\...\loader\src\proxy.c

前缀是 \xcc\xcc\xcc\xcc(MSVC 的 int3 填充),后面是 (aka ...) 类型字符串——这是 clang 为 CodeView 生成的辅助字符串表,zig 把它放进 .rdata。这些路径是 zig 编译管线硬编码的源文件路径-cflags 只影响代码生成层,插不进去。

解法:post-install 字节覆写#

既然编译期管不了,那就构建完成后直接改字节。写了个小脚本:

MARKER = b"Z:\\src\\targetstring" # 实际换成你项目的绝对路径前缀
REPLACEMENT = b"." * len(MARKER) # 等长替换,不破坏文件结构
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

关键点:

  • 等长替换Z:\src\targetstring(长度固定)换成等长 .,不改变文件布局
  • PDB 不动:调试器从 PDB 解析源码路径,所以 PDB 保留真实路径,rg 二进制时 PDB 里的不算(那是符号文件,不随发布分发)

接入方式:

// build.zig:手动步骤(避免 install 依赖循环)
{
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):每次 Windows 构建后自动跑
- name: Sanitize source paths from binaries
if: contains(matrix.target, 'windows')
run: |
python3 tools/strip_source_paths.py zig-out/bin
TIP

为什么不做成自动的 build step?因为 Zig 没有”install 之后”的 hook——install 和任何后处理步骤互相依赖会依赖循环install → strip → install)。标准做法就是 CI 集成(和已有的 strip_buildid.py 一个模式),本地给一个手动 zig build strip-paths

验证结果:

$ python scan_paths.py # 自定义扫描脚本
a.dll path-hits=17 → 0
targetstring-client.exe path-hits=9 → 0

另一个坑:.buildid#

GitHub 构建的产物会带 .buildid 段,里面可能有仓库/提交信息。项目里已有 tools/strip_buildid.py 在 CI 里剥离:

- name: Strip .buildid section
run: |
for exe in zig-out/bin/*.exe; do
python3 tools/strip_buildid.py "$exe" || exit 1
done

检查这类”元数据段”时,objdump -h / pefile 遍历 section 列表看有没有可疑名字即可。

验证清单(发布前)#

Terminal window
# 1. 无 PDB 随二进制分发
ls zig-out/bin/*.pdb # 应为空(Release 构建)
# 2. 无源码绝对路径残留
rg -a "targetstring" zig-out/bin/*.dll zig-out/bin/*.exe # 应为 0
# 3. 无 .buildid 段
# (CI 已自动处理)
# 4. IDA 确认:打开二进制,确认无符号、无路径、函数叫 sub_XXX

结语#

  1. 条件 strip 很容易:-Dstrip 选项 + optimize != .Debug,开发/发布两不误
  2. strip 只删符号,不删 .rdata 字符串——所以字符串级别的泄露要用别的手段
  3. CodeView 辅助字符串(aka ...) 那类)里的源码路径,编译期 flag 全灭-ffile-prefix-map 只对 DWARF 和 __FILE__ 有效
  4. post-install 字节覆写是最可靠的兜底:等长替换、PDB 保留、CI 集成

如果你也做 Windows 上需要对抗逆向分析的原生项目,记得把”无 PDB + 无路径 + 无 .buildid”写进发布核对清单。

Zig 构建产物的信息泄露:条件 strip 与源码路径清除
https://tski.uk/blog/zig-strip-source-paths/
作者
Tokisaki Galaxy
发布于
2026-08-13
许可协议
CC BY