Anubis 用一年把反爬虫工作量证明搬进 WebAssembly:argon2id、Rust 与一场 LLVM 握手失败

AI 爬虫的算力在指数级膨胀,防爬的一方却要照顾一部 2019 年的中端手机。Anubis 给出的答案是:把工作量证明挑战编译成 WebAssembly,换用对内存敏感的 argon2id 哈希,让同一个二进制同时跑在浏览器和服务器上。这条路作者走了一年,途中遇到职业生涯第一个编译器 bug,最终随 Anubis v1.28.0 以默认关闭的方式发布。

Anubis GitHub 仓库卡片:22k Star 的 AI 爬虫拦截工具

Anubis 是 TecharoHQ 维护的开源反爬虫工具,GitHub 上有约 22,000 个 star,核心思路是在网页前面加一道「证明你是浏览器」的门槛:客户端跑一段 JavaScript 计算,解出一个哈希难题,服务器验证通过才放行。git.kernel.org、GNOME GitLab 等开源基础设施都用它抵挡 AI 爬虫的冲刷。9 月 6 日,作者 Xe Iaso 在官方博客发文,宣布下一个版本将内置 WebAssembly 工作量证明检查,这篇题为《It took a year to ship WebAssembly in Anubis》的文章,把一年的工程折腾完整摊开。

旧方案的死穴:显卡解题与手机发烫

Anubis 原有的挑战是 sha256 工作量证明:浏览器反复计算哈希,直到结果满足难度要求(前导零)。这个机制在 2025 年挡住了大批爬虫,但两个缺陷随之浮出水面。

第一个缺陷是攻防成本的不对称。sha256 是纯 CPU 型哈希,GPU 和专用芯片算它快得离谱。作者在文中写的原话是:「hey Claude vibeslop me a CUDA Anubis solver」这条路正在变得可行——攻击者让 AI 写一个 CUDA 版求解器,用显卡矩阵跑哈希,成本远低于防御方预期。CPU 型难题挡不住算力堆料。

第二个缺陷落在守方自己人身上。手机 CPU 性能弱,同一个难度值在台式机上半秒解完,在旧手机上要烧好几秒,社区抱怨「Anubis 让我手机过热」。想给手机降难度又行不通:难度参数是下发到客户端的,爬虫看到低难度同样可以申请低难度挑战,防守权衡被摊平。

WebAssembly 是同时回应这两个问题的选择。编译成 wasm 的二进制跑得更快,挑战在用户浏览器里完成得越快,Anubis 这个「关卡」存在感越低;而换用的 argon2id 是内存困难函数,求解时需要大量内存带宽,GPU 的单卡优势被拉平,为显卡写求解器不再是抄近道。

同一个二进制,两侧验证

架构上这次改造最有意思的一点:客户端和服务器跑的是同一个 wasm 二进制,不是「同一段逻辑的两种实现」。作者特意强调了这个区别——浏览器里加载 wasm 模块算出解,服务器加载同一个模块验证解,两边行为严格一致。

将代码编译到浏览器的正常路径与 Anubis 实际路径对比的 meme 图

此前的 fast 挑战有两份实现:JavaScript 一份(浏览器端跑),Go 一份(服务器端验证),改任何一处都要人肉同步另一处,代码库里还散落着对挑战机制的隐式假设。单二进制把这类同步问题连根拔掉,也为作者预告的后续实验铺路:给不同客户端下发不同的挑战程序,服务器天然能验证,因为验证器和求解器是同一个东西。

wasm 模块用 Rust 写,编译目标 wasm32-unknown-unknown,不开标准库(no-std)。选 Rust 不是审美偏好,是二进制体积和执行速度——Rust 编出的 wasm 模块最小、跑得最快。对 Anubis 的维护模型还有一层意义:Anubis 核心是 Go 写的,作者给自己定了两条规矩(不许用 CGo、必须能从 MacBook 交叉编译到生产环境),以前改任何一处都要全量重编整个二进制;把挑战模块拆成可在运行时下载替换的插件,意味着威胁行为一变,防御方可以当天换掉挑战逻辑,不用重发整个 Anubis。

手搓 ABI:三个缓冲区,两个入口

理想方案是 WebAssembly Component Model,用 WIT 接口定义语言描述宿主与客户模块之间的调用。作者实际这么试过,但当时 Go 侧生成绑定的工具链不支持从宿主向客户传结构体和字节切片,只能手写 ABI。

最终的设计把模块当成「恰好用 wasm 实现的动态库」:三个共享缓冲区加两个入口函数。数据缓冲区放挑战数据,上限 4096 字节(刻意对齐 4Ki 内存页);结果缓冲区放客户端算出的哈希,约 32 字节;验证缓冲区给服务器回算用,同样约 32 字节。每个缓冲区暴露指针和长度两组函数,客户端入口 anubis_work 循环算哈希直到解出符合难度的 nonce,服务器入口 anubis_verify 用同样的数据回算并比对。模块还可以周期性上报哈希速率,前端拿它画进度条。

作者对这套手搓 ABI 的评价留在文末的「已知问题」清单里:它够用,但没经过真实世界的毒打,后续演化方向是运行时从 OCI 镜像仓库拉取挑战程序,动态轮换。

一年都花在哪了

写第一个能跑的 wasm 挑战只用了几天。剩下的十一个多月,全花在兼容性和构建确定性上。

SIMD 双构建。WebAssembly 的 SIMD 指令扩展能显著加速哈希运算,对手机尤其明显。但 CanIUse 标记 SIMD 为 baseline 的分界线是 Chrome 91,而 Anubis 的兼容底线是 Chrome 75——大量安卓手机、智能电视永远停在旧版 Chrome 上,没有升级路径,Anubis 的用户群恰恰覆盖这些设备。解法是编译两份模块(带 SIMD 和不带),加载时用 wasm-feature-detect 探测浏览器能力再决定加载哪份。

给禁用 wasm 的用户留活路。iOS 锁定模式(Lockdown Mode)和 GrapheneOS 的 Vanadium 浏览器默认策略禁 WebAssembly,这类用户不能被挡在门外。作者不想再维护第二份 JavaScript 实现(单实现正是这次改造的核心目标),于是用了 binaryen 工具链里的 wasm2js:把 wasm 二进制反向编译成 JavaScript。Rust 标准库加哈希函数转出来的 JS 文件体积巨大,跑在解释器上性能有限,但作为兜底路径它能工作。

可复现构建撞上 LLVM bug。wasm2js 和 wasm-opt 需要固定版本编译进仓库,保证所有构建产物的字节一致。CI 里 Ubuntu 打包的 wasm2js 却报错,不认识 Rust 用的 tail call 扩展。作者用二分法逐个版本构建 binaryen,最后定位到 LLVM 的一个 bug:编译器按机器指针顺序遍历异常处理块,每次构建产物漂移约 29 字节。关掉 ASLR(地址空间随机化)后同一台机器上结果恢复一致,这个现象确认了问题在编译器而不在代码。修复随新版 wasi-sdk 发布。这是作者职业生涯第一次撞上真实的编译器 bug,他在文中写,自己一直默认「编译器无 bug,出错的是我的输入」,把嫌疑指向 LLVM 本来就在想象范围之外。

chromesweep:25 个浏览器的自动化农场。旧版 Anubis 发新版本慢,一个原因是作者要手动装几十个浏览器逐个测试。这次他搭了套叫 chromesweep 的系统:在 Kubernetes 集群里批量拉起各个版本的 Chrome,对被测 Anubis 实例发起真实请求。老版本 Chrome 是活的安全炸弹,所以每个浏览器 Pod 套两层壳——NetworkPolicy 只允许访问被测实例和 DNS,外面再包一层 Kata 微虚拟机。测试跑一个 PR 命令就能触发,代价是「办公室会变暖」。就是这套系统抓出了 Chrome 75 编译 wasm 失败的问题。

chromesweep 测试报告:Chrome 16 个版本、Firefox 7 个版本全部通过

Chrome 75 报的错很典型:expected table index 0, found 128。排查发现 Rust 的预编译标准库被编进了 reference types 特性,call_indirect 指令把引用存成表索引,而 MVP 时代的 wasm 引擎读不懂这种编码。Go 的做法是编译时重编标准库,Rust 的 rustup 组件则是预编译好的——CPU 特性选项管不到它。重编标准库太折腾,作者走了另一条路:用之前为可复现构建准备好的 wasm-opt,把编译产物里的非 MVP 特性全部剥掉。标志组合是拿 Claude Opus 和 GLM 5.2 循环 fuzz 出来的,chromesweep 全绿验证通过。

发布节奏与遗留清单

WebAssembly 挑战随 Anubis v1.28.0(代号 Wuk Lamat)发布,默认关闭,管理员在阈值规则或 bot 规则里显式启用;按反馈情况,v1.29.0 计划默认打开。新版同时修掉一个历史遗留:旧 fast 挑战用字符串比较数前导零 nibble(4 位)而不是前导零位,难度加 1 最坏情况等于难度乘 16,缩放曲线完全失真,新挑战直接按位计数。

已知未解问题也列在了文末:wasm2js 路径的进度条还没接上(update_nonce 被stub 掉了);wasm 太快意味着现有难度值可能需要上调;手机请求分类器(IP 信誉加 TLS 指纹)还在原型阶段;挑战程序的运行时轮换要从编译期挪到运行期。

对自建网站的管理员,这套东西的实际影响是:升级 v1.28.0 后可以在配置里试开 algorithm: argon2id 的 wasm 挑战,观察自家流量的通过率和移动端发热变化,再决定是否跟进 v1.29.0 的默认启用。对一个 22k star、被 kernel.org 这类基础设施依赖的工具来说,工作量证明的算力天平重新压向防御方:求解方现在既要算得快,又要提供大内存带宽,两种资源都是显卡集群的短板,这次重构把两条都攥进了 Anubis 手里。

来源:Anubis 官方博客(anubis.techaro.lol/blog/2026/anubis-wasm/)· Hacker News 讨论