同样源码、同样参数,为什么还能编出不一样的字节?
Anubis 这次踩到的坑很小:大约 29 字节。麻烦也很大:它足够让 checksum 对不上,足够让“可验证分发”从一句安全承诺变成一堆维护者要亲手处理的脏活。
Anubis 正在为 WebAssembly proof-of-work 校验做一套实现。目标很朴素:客户端和服务端共用一份 WASM 校验逻辑,避免两边各写一套,最后规则跑偏。
麻烦从兜底开始。用户如果禁用了 WASM,网站要不要直接拒绝访问?作者不想把门关死,于是选择用 binaryen 的 wasm2js,把 WASM 重新编译成 JavaScript。这个方案能提高兼容性,但性能可能更慢。更现实的一点是,禁用 WASM 的环境,往往也会禁用 JavaScript JIT。
一个 JS 兜底,把工具链暗门全带出来了
这不是 Anubis 已经全面上线后的复盘。目前能看到的,更像是相关 PR 和实现过程里暴露出的工程现场。
关键链路可以压缩成这张表:
| 环节 | 选择 | 直接问题 |
|---|---|---|
| 校验逻辑 | 客户端、服务端共用 WASM | 减少两套实现不一致 |
| WASM 被禁用 | 用 wasm2js 转成 JavaScript | 兜底可用,但可能更慢 |
| 工具来源 | 发行版 binaryen / Homebrew binaryen | 版本不同,输出不一致 |
| 固定工具 | vendoring 固定版本 wasm2js | 必须能验证这个二进制怎么来的 |
| 构建过程 | Clang 可能调用 PATH 里的 wasm-opt | 隐式变量影响结果 |
| 最终校验 | 分架构记录 sha256 | 做到架构内确定性,不是跨架构同字节 |
真正的转折在 vendoring。
作者想把固定版本的 wasm2js 放进仓库。理由很简单:发行版里的 binaryen/wasm2js 版本偏旧,开发机上的 Homebrew 版本又可能更新。版本一变,输出就变。兜底代码如果要稳定,工具也得稳定。
但把工具放进仓库,不等于问题结束。反而多了一个问题:别人凭什么相信这个二进制?
答案只能是可复现构建。别人应该能从同样源码构建出同样字节,再用 checksum 对上。
然后 Clang 开始添堵。
它会暗中调用 PATH 里的 wasm-opt。一台机器上可能是 binaryen 108,另一台是 130。旧版 wasm-opt 不支持 WebAssembly Exceptions,构建会失败。作者的处理方式是在链接阶段加 --no-wasm-opt,先关掉这个隐式变量。
更难缠的是 LLVM/Clang 的异常处理路径。这里存在和地址布局相关的排序差异。结果是 x86_64 和 arm64 之间,甚至不同构建之间,会出现约 29 字节差异。
不是语义大改。就是字节不稳。
作者最后用了两步止血:用 setarch 关闭 ASLR;同时为 x86_64 和 arm64 分别记录 sha256。这个边界很重要:目前处理的是架构内确定性,不是证明跨架构能产出完全相同的字节。
受影响的不是普通用户,是维护可验证分发的人
对网站管理员来说,这件事的直接影响很有限。Anubis 做 JS 兜底,是为了让禁用 WASM 的用户不被直接挡在门外。但管理员真正要权衡的是体验和成本:兜底能多接住一部分用户,代价是执行可能更慢,尤其在禁用 JIT 的环境里。
对开源维护者和软件供应链工程师,影响就很具体了。
如果你在发一个需要被验证的二进制,不能只写“源码在这里”。你还要能回答:
- 构建时用了哪个版本的编译器、链接器、优化器?
- Clang 有没有从 PATH 里摸到别的工具?
- 发行版版本和开发机版本是否会产出不同结果?
- ASLR、CPU 架构、异常处理路径会不会影响排序?
- checksum 是跨架构一份,还是分架构接受?
这不是洁癖。它会影响实际动作。
维护者要么 vendoring 固定工具链,要么用容器/Nix/Guix 这类方式收紧构建环境。团队要把 CI 从“能编过”升级到“能重复产出同样字节”。软件供应链工程师要检查隐式依赖,而不是只盯 lockfile。
关注 WebAssembly 和构建系统的开发者,也该把这事当成一个边界样本:WASM 本身常被讲成可移植、可验证、沙箱友好,但从 C/C++ 到 WASM 的链路并不干净。LLVM、binaryen、异常扩展、优化器版本,都会把“可移植”重新拉回现实。
这里也要把话收住。这个案例不能推出“所有编译器都不可复现”。证据只指向一条具体链路:C/C++、WASM、LLVM/Clang、binaryen、WebAssembly Exceptions。问题是工具链非确定性和版本漂移,不是恶意供应链攻击。
但正因为不是攻击,它更容易被低估。
攻击会触发警报。工具链默认行为不会。它只是安静地躲在 PATH、ASLR、架构差异和优化器版本里,直到你需要证明“这个二进制确实来自这份源码”。
可复现构建的硬仗,在那些默认值里
我不太买账的是那种轻飘飘的说法:可复现构建就是把依赖锁住。
锁依赖只是第一层。真正难的是把构建系统里的“默认帮你做点什么”全部写清楚。Clang 帮你找 wasm-opt,发行版帮你提供旧版本,Homebrew 帮你滚到新版本,ASLR 帮你改变地址布局。每一个都合理。合在一起,就让字节不稳。
“差之毫厘,谬以千里。”这里的毫厘,就是那 29 个字节。它不一定改变程序行为,但会改变信任结果。checksum 对不上,后面的解释都很费劲。
历史上很多基础设施问题都不是败在大阴谋,而是败在默认值。铁路轨距、电力制式、浏览器兼容、Linux 发行版打包习惯,都有类似影子。不完全一样,但结构相似:系统越复杂,越依赖没人愿意写进宣传页的细节。
Anubis 这次处理得不漂亮,但还算诚实。
它没有假装跨架构同字节已经解决,而是承认 x86_64 和 arm64 要分开记录 sha256。它也没有把发行版旧版本、Homebrew 新版本写成谁对谁错,而是把版本差异当成构建事实处理。
接下来最该观察的不是“这个功能什么时候全量上线”。更关键的是三件事:
wasm2jsvendoring 后,仓库能否长期提供可验证的构建路径;- CI 是否能稳定覆盖 x86_64 和 arm64,而不是靠维护者手工确认;
- 上游 LLVM/binaryen 对异常处理、排序稳定性和隐式优化调用是否给出更清楚的控制开关。
如果这些没有收紧,可复现构建就会停在“这次能对上”。下次换机器、换发行版、换架构,问题还会回来。
这就是我对这件事的判断:可复现构建不是一句 same input, same output。它更像一张雷区地图。源码只是入口,真正要排的雷在工具链里。
