TIP本文来自真实踩坑:给一个 Zig 写的 Windows DLL + EXE(伪装成 a.dll 的代理 loader)做发布前的信息清理。最终结论是——Zig 构建产物的”条件 strip”很好做,但 CodeView 辅助字符串里的源码路径,编译期 flag 一个都管不了,只能 post-install 字节覆写。
为什么要在意
逆向分析者拿到一个二进制,第一件事就是:
- 打开 IDA / Ghidra,看导出表、导入表、字符串
- 如果旁边躺着同名
.pdb,直接加载——变量名、函数名、源码路径全送 strings一把梭,抓.rdata里的明文字符串
如果二进制里残留了开发机的绝对路径(比如 C:\src\targetstring\loader\src\proxy.c),等于把项目名、源码结构、开发机目录布局全部白送。蓝队按路径特征一搜,整个仓库就暴露了。
Zig 的 strip 到底删什么
Zig 的 .strip = true(或 -fstrip)做两件事:
- 删除符号表(
.symtab、.debug_*等调试节) - 阻止 PDB / 调试信息生成
但它不删 .rdata 里的字符串数据。这是个关键认知:__FILE__ 宏展开、编译器生成的调试辅助字符串,都是”普通数据”,strip 无从识别它们该删。
我踩坑时做了一个天然对照实验——同一个 ReleaseSafe 构建里:
| 产物 | strip | PDB |
|---|---|---|
-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(安全)
解法是做成构建选项,默认跟随优化模式:
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.cZ:\src\targetstring\loader\src\anti_sandbox.cZ:\src\targetstring\loader\src\xtea.hZ:\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 一个都不管用
我按网上教程一个个试,全部失败:
-fmacro-prefix-map=C:\src\targetstring=.—— 只管__FILE__宏。用zig cc单独编译一个 C 文件,__FILE__确实被映射成.\loader\src\proxy.c。但完整构建的 DLL 里路径还在。-ffile-prefix-map/-fdebug-prefix-map—— LLVM 对 DWARF 生效,但对 CodeView 的路径辅助字符串不覆盖。-g0—— 我天真地想关掉 C 的调试信息。但 zig 编译 C 时在-cflags之后自己重加 debug 参数,-g0被覆盖。-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/binTIP为什么不做成自动的 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 → 0targetstring-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 列表看有没有可疑名字即可。
验证清单(发布前)
# 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结语
- 条件 strip 很容易:
-Dstrip选项 +optimize != .Debug,开发/发布两不误 - strip 只删符号,不删
.rdata字符串——所以字符串级别的泄露要用别的手段 - CodeView 辅助字符串(
(aka ...)那类)里的源码路径,编译期 flag 全灭,-ffile-prefix-map只对 DWARF 和__FILE__有效 - post-install 字节覆写是最可靠的兜底:等长替换、PDB 保留、CI 集成
如果你也做 Windows 上需要对抗逆向分析的原生项目,记得把”无 PDB + 无路径 + 无 .buildid”写进发布核对清单。