安全研究

1kToken的算力不到0.1美国币: 把PoW验证码Cap拆到协议层

2026年10月8日·28 分钟阅读
Hali

Hali

www.userhali.com

1kToken的算力不到0.1美国币: 把PoW验证码Cap拆到协议层

"用 AI Agent 爬网站"从噱头变成运维议题之后,有个很诱人的选项:PoW(工作量证明)验证码。不管你是谁,先交点 CPU 算力再说。Cap(Apache-2.0)就是其中之一,AdGuard、Bunny.net 这类站点都在用(出处见第六节)。

我把它拉到本机拆开跑了一遍,想搞清楚一件事:它宣传里"挡 AI Agent"的那层,边界到底在哪。

跑完一圈下来,直观结论就两条:PoW 给请求标了价,但做不了门禁;至于里面最像"反自动化"的检测层,查的其实是你 POST 上去的 JSON 长得像不像正常浏览器。


〇、实验环境与数据范围

  • 测试对象:Cap standalone v3(服务端),本轮实验锁定 commit af624b9。仓库里还有 widget 等其它形态,本文不涉及。
  • 运行环境:本地自建实例,ARM64 容器、Node 22。
  • 测试规模:共 18 组预设,包含 PoW 14 组(hashwx 5 + sha256-pow 6 + rsw 3)、instrumentation 2 组、限流 2 组;另有 4 类探针向量单独对比。完整矩阵与原始数据都在 cap-lab。
  • 判定标准:下文提到的"通过",均指 redeem 返回 200 且 siteverify 返回 success: true。

术语约定

词含义
challenge服务端下发的一次求解任务(一个 JWT + 参数)
token解完后由 redeem 签发的验证凭据,形如 siteKey:id:secret,TTL 2 小时,且只能校验一次(见第二节)。下文"每 token"指签发一枚 token
c / s / dclassic 协议的挑战个数 / salt 长度(hex 字符数)/ 难度(十六进制位数,1 位 = 4 bit)
hashwxformat 2 的一种协议:WASM 里的哈希前缀搜索
rswformat 2 的另一种:基于大整数模运算的时间锁(反复平方求模幂)

一、先把靶场搭起来

服务端是 Bun + Elysia,存储全走 Redis/valkey。跑起来不难,不过踩了两个环境坑:

  1. Alpine/musl 环境必须用 musl 版 bun,glibc 版直接起不来,gcompat 也救不了。

  2. 别用 bun install 装依赖。它的 isolated install 在部分容器环境里会把缓存文件名改成 ...index.mjs0001.00020001 这种形式,直接弄丢了扩展名;而 elysia 的 package.json 没有 "type": "module",那个 .mjs 于是被当成 CommonJS 解析,启动直接报:

    SyntaxError: Export named 'Elysia' not found in module '.../dist/.l2s..l2s.index.mjs0001...'
    

    试过 --backend=copyfile、--linker=hoisted、删了重装都没用。最后改用 npm install 装依赖、只拿 bun 运行,一切正常。


二、协议:两代格式,三种工作量方案

format 1(classic) 的 challenge 是个 HS256 JWT,载荷带 c/s/d。这里有个关键细节:salt 和 target 不是服务端单独下发的,客户端直接从 token 就能推导出来:

tokenFnv = fnv1a(token)
saltSeed = fnv1aResume(tokenFnv, String(i+1))
target   = prngFromHash(fnv1aResume(saltSeed, "d"), d)
salt     = prngFromHash(saltSeed, s)
找 n 使 sha256(salt + n) 的十六进制前 d 位 == target

换算下来,d 是十六进制位数,每个挑战期望尝试 $16^d$ 次,单 token 约 $c \times 16^d$ 次 SHA-256。d=1 是 16 次;d=4 是 65,536 次;d=6 就是 16,777,216 次。

format 2 把挑战列表明文下发,标准答案用 AES-256-GCM 加密在 JWT 里。支持三种方案:

协议求解方式单 token 代价
sha256-pow同上,前缀匹配$c \times 16^d$ 次 SHA-256
hashwxWASM 哈希搜索,目标 U64_MAX/d= hashwxDifficulty 次。注意这是总量:服务端拆成 4 个挑战,每个 总量/4,命中概率 $1/d$
rsw$y = x^{(2^t)} \bmod N$$t$ 次模平方

服务端验证链:核对 JWT 签名 / scope / exp -> 解密标准答案逐项比对 -> 一次性消费 challenge -> 签发 token。

  • challenge 一次性消费的实现是 SET blocklist:<jwt签名> 1 NX EX <ttl>。Redis 的 SET ... NX 是单条原子命令,并发重放里只有一个能拿到;它发生在答案比对之后,所以重复提交同一份 (token, solutions) 第二次会在这里被拒(already_redeemed)。
  • token 形如 siteKey:id:secret,id 与 secret 都是随机十六进制(8 / 15 字节),并不是 HMAC;服务端只保存 token 的过期时间。
  • 验证 token 也是一次性的:siteverify 用的是 Redis GETDEL token:<response>,校验成功即删除。实测同一枚 token 第二次校验会返回 404 Token not found。站点如果要重复校验同一凭据,得自己在首次成功后缓存状态。
  • challenge TTL 15 分钟,token TTL 2 小时。

三、18 组预设实测

预设结果期望尝试/token单次耗时
hashwx 50k(难度下限)通过5 万0.77 s
hashwx 250k通过25 万6.3 s
hashwx 1M(默认)通过100 万15.6 s
hashwx 2M通过200 万28.4 s
hashwx 5M(难度上限)通过500 万85.1 s
sha256-pow c=80 d=1通过1,2800.30 s
sha256-pow c=300 d=2通过76,8000.72 s
sha256-pow c=1 d=4通过65,5360.17 s
sha256-pow c=80 d=4通过524 万22.0 s
sha256-pow c=20 d=5通过2,097 万63.5 s
sha256-pow c=2 d=6通过3,355 万60.1 s
rsw t=10k / 75k / 300k通过1万 / 7.5万 / 30万次模平方0.23 / 0.90 / 3.3 s
instrumentation(PoW 50k 预设)被拦5 万(已解完)卡在 missing_instr
instrumentation + blockAutomatedBrowsers被拦5 万(已解完)同上
限流 5 次/10 s第 5 次起 429——
限流 200 次/5 s10/10 通过5 万1.1 s

不同预设下单枚 token 的算力成本(对数坐标)

​图 1:不同预设下单枚 token 的哈希次数(对数坐标,跨四个数量级;注:图中标出的是修复计数器前的早期单次采样值,理论期望值以正上方表格为准)。低于 $10^5$ 的低难度档位,按第九节的云 CPU 单价折算,每千枚 token 的算力成本不到 1 美分,基本等于白送。

补充几个测试口径细节:

  1. 上表是单次抽样,不是均值。hashwx 耗时列在 1M(15.6 s)与 2M(28.4 s)之间呈约 1.8 倍关系,而难度正好 2 倍。单次抽样的抖动主要落在"每 token 实际哈希数"上,耗时列相当稳定。
  2. sha256-pow 的哈希数我单独复核过。同一预设连采 3 次:c=80 d=1 得 1,162 / 1,286 / 1,189(期望 1,280),c=300 d=2 得 76,189 / 73,124 / 79,160(期望 76,800),比值 0.91–1.03;c=80 d=4 单次 402–437 万(期望 524 万,单样本几何分布波动本就很大)。早期版本这张表里的哈希数偏高约 2–3 倍,是我当时的计数器写出了 bug,放在第七节复盘。
  3. 吞吐基线。以上数据都是优化前的基线(4 个 worker 线程、每挑战一 worker、解释模式内核)。整机有效吞吐(总哈希 / 墙钟,含尾部空闲)在大难度档约 5.8–6.7 万次/秒,内核单线程实测 3.1 万次/秒(见第四节)。小难度档四个 worker 都满载,有效吞吐会更高。

四、客户端侧还能再快 7–8 倍

测完基线后,我想试试客户端求解到底还能压榨出多少性能。起初我以为瓶颈在线程调度,试了四种改法,结果有三种是负收益:

改法实测
把 hashwx_make 提到块外(每 65536 nonce 一次)40,689 vs 44,734 hashes/s,慢 10%(make 几乎免费)
共享队列调度(解释内核下)19.0 vs 15.5 s/token,慢 20%
常驻 worker 池(复用 wasm 实例)无可测差异
i64 参数改传 Number 省 BigInt 分配引擎不允许

真正的答案藏在 wasm 的导出表里。去翻 hashwx.wasm 才发现,除了我最初调的 hashwx_exec,它还导出了 hashwx_exec_begin / hashwx_exec_final / hashwx_module / hashwx_registers / hashwx_memory。原来官方前端 widget 走的就是这条动态编译内部 wasm 的路子,我之前一直拿最慢的解释模式导出函数在跑。

const ctx = hashwx_alloc(1);            // 1 = 编译模式,0 = 解释模式,两个 ctx 不能混用
const inner = /* 编译 hashwx_module(ctx) 返回的内嵌 wasm */;
// 每 nonce: exec_begin(ctx, nonce) -> inner(regs, mem) -> exec_final(ctx)

单线程对比(每个命中都用解释路径复核过):

compiled   173,951 hashes/s
interpreted 30,899 hashes/s      ->  5.63x

踩坑提醒:那段 11,316 字节的内嵌 wasm 把 seed 烘焙进了生成的代码里。如果复用同一个 inner 去扫不同 block,只有 block 0 正确,block 1+ 全错,而且会"提前命中"假解(我第一版因此测出了一个假的 6.59x)。正确做法是每次 hashwx_make 后重新编译,每 65536 nonce 一次,开销约 2.5%。

换上编译模式,再叠加共享队列(nonce 空间无界,所有 worker 抢同一个挑战,谁先撞上谁赢),默认预设的端到端成绩直接起飞:

耗时/token每分钟每小时
优化前15.5–17.8 s3.37202
优化后2.13 s28.21,692

求解器优化前后对比

图 2:左为单线程 PoW 内核吞吐(同一算法、相同哈希值,只是省掉每次调用的开销),右为默认预设的端到端成绩。

算力总功其实并没有变少,四个挑战仍然各要期望命中 250k 次,压缩的只是墙钟时间:最容易撞上的那个很快结束,所有线程立刻合围去啃最难的那个。客户端单靠优化实现就能拿到 7–8 倍的提速,这也意味着防守方单纯靠拉高难度来涨价,在写过针对性优化的对手面前会被直接打个一折。


五、唯一硬生效的拦截:限流

限流是这一整套里唯一对纯脚本硬生效的拦截。核心逻辑很短:

const key = `rl:${scope}:${ip}:${windowMs}:${window}`;
const count = await db.incr(key);
if (count > max) -> 429

看代码有三个细节需要注意:

  1. scope 不是 site key,而是模块级自增计数器,每个限流器调用点对应一个固定值。也就是说同一 IP 下所有站点密钥共用一个配额桶,一把吵闹的 key 会拖垮同 IP 的其它 key(当然,换 key 也不能重置限流)。
  2. 按请求计数,不按 token。/challenge 与 /redeem 挂在同一个插件下、共用同一个 scope,所以拿一枚 token 要花掉两次配额。实测 max=4 时恰好只拿得到 2 枚 token,第 3 枚开始 429,有效速率是 max/2。
  3. 管理接口不参与限流。建 key、改配置这些路由上没有挂限流器,也不消耗上面的桶。

六、"挡 AI Agent"那一层,检的是上报数据

Cap 的 instrumentation 机制是这样的:服务端每次生成一段随机 JS 程序(位运算 + 操作 DOM),deflate 压缩后下发;客户端在 sandbox iframe 里执行,回传 4 个变量的值;服务端核对这 4 个值,再对一并上报的浏览器探针向量跑一遍检测。

拆开服务端源码后,能看到两个很关键的实现细节:

第一,标准答案是服务端自己算好的。 generateInstrumentation() 用 domSumMock() 在服务端就把那段 DOM 计算的结果算了一遍,并没有真的去观察你的 DOM。所以"这段计算难以在浏览器外模拟"的前提在代码层面并不成立。

第二,探针向量完全由客户端组装。 ua、webdriver、plugins、fontWidths、outerWH 等每个字段,都是客户端脚本自己读完环境后拼成 JSON POST 上来的,服务端只对这份 JSON 做特征判断。我把四类向量喂进了它自己的 detectAutomation():

向量结果被哪几项拦
真实桌面浏览器通过—
默认无头 Chrome被拦webdriver_true、headless_token
加固后的无头(换 UA、删 webdriver)被拦webdriver_stripped、window_exceeds_screen
纯手写、完全不启动浏览器9 项全过—

四类探针向量在 Cap 自家检测下的结果

图 3:跑 Cap 自己的 detectAutomation(),9 项检查与 4 类向量的交叉对比。X 表示被服务端拦下。

这里你可能会好奇:不启动浏览器,怎么拿到那 4 个变量的值?其实不需要起完整浏览器,我是用 Node 的 node:vm 手搓了一个迷你沙箱环境,把 Window、Document、Navigator、HTMLElement、EventTarget 这些类、Symbol.toStringTag 以及一个够用的 DOM 壳子补齐,直接把服务端那段脚本跑起来拿结果。唯一要注意的是原型链细节:这些类必须是 vm 内定义的真实 class,否则脚本里 instanceof 和 Object.prototype.toString 那几道自检直接就过不去。

另外,blockChecks 那一层共有 14 项,每次随机抽 8 项执行,这也解释了为什么有些半成品脚本测起来时好时坏。

平心而论,它里面有两处设计得挺细:navigator 检查刻意区分了"自有属性"与"原型 getter";webdriver_stripped 和 window_exceeds_screen 能抓出"改了 UA 但其它环境数据没跟上"的补丁痕迹。拿现成的 Selenium / Puppeteer / Playwright 硬撞确实会被拦住,简单打一层补丁也会被拦。

但说到底,它校验的是上报的数据签名,而不是执行环境本身。这一层给攻击者制造的真正麻烦反倒不在检测上,而在于客户端必须执行服务端下发的任意 JS:要么直接跑(得承担沙箱逃逸与远程代码执行风险),要么逆向复刻整套随机生成算法(变量名、操作序列、salt 每次都变),这会变成持续的维护负担。

翻看 Cap 的官方文档(docs/public/agent.md),作者自己的措辞其实很克制:「不是绝对保证——打过补丁的 stealth 浏览器连 Cloudflare Turnstile 都能破——但能明显抬高门槛,挡住绝大多数现成的自动化工具」。这比外面宣传的"挡 AI Agent"诚实得多;开头提到 AdGuard / Bunny.net 在用,也出自同一份文档。


七、花时间最多的两个坑,都是我自己的 bug

坑一:我一度以为服务端有状态污染 bug。

当时所有 hashwx 预设都被判 Invalid solution,我去服务端插桩,发现同一个 verifyHashwxSolution 对完全相同的数据先返回 true、紧接着在校验器里返回 false。排查半天才发现:Node 在我这个容器沙箱里调 os.cpus() 返回的是空数组(虽然 nproc 报 8,但 Node 拿不到 CPU 信息)。结果分片数退化成 2,而我写完 round-robin 分片后直接调了 .flat():

chunks = [[c0, c2], [c1, c3]]  ->  flat = [c0, c2, c1, c3]

第 2 个挑战的解被硬生生塞进了第 3 个位置。因为单 worker 和"一挑战一 worker"两种写法都不走这段逻辑,所以前面几次测试全绿,把我结结实实晃点了一道。

坑二:哈希计数器错了整整一版。

早期表格里 c=80 d=1 测出来是 3,551 次,而理论期望只有 1,280,差了 2.8 倍。我一开始还强行用"尾延迟"给自己圆场,但算了一下 80 个几何分布求和的偏差约 $15\sigma$,几何分布只会围绕期望对称波动,根本做不出这种系统性偏高(d=2 是 2.9 倍,d=4 是 2.2 倍,到了 d=5、d=6 反而低于期望)。

中间我还猜过双哈希、批粒度、重试计入三个方向,全都对不上,因为真正需要解释的是为什么"小难度系统性偏高、大难度反而正常"。最后回头把计数器推倒重写、改成直接对每个挑战统计尝试次数,三档比值立刻回到了 0.91–1.03。折腾大半天,小丑竟是我自己。


八、如果我是防守方

  1. 把 PoW 当成算力标价,别当成准入门禁。在架构文档里明确写清楚"这套机制防的是随手抓取的低成本爬虫",别让它去扛"防 AI"的叙事。
  2. 先想好检测失败时的降级策略。代理、隐私插件、非主流浏览器都很容易触发探针误报,一刀切拦截正常用户的代价,往往比放过一两个高级 bot 高得多。
  3. 限流按来源与业务分桶,别全局混用。现在的实现有两个隐患:一是 NAT 踩踏,校园网、企业出口、公共 Wi-Fi 下所有人共享出口 IP,一个站点的恶意请求会让整个局域网用户在所有接入该实例的站点上一起吃 429;二是把管理端与业务端混在一个桶里,平时跑个运维脚本都可能挤掉正常用户的额度。
  4. 把精力花在客户端没法自己组装的信号上。比如服务端行为序列、IP/会话关联、请求时序分析、Token Binding 或设备证明(WebAuthn / TPM attestation)。当然这些也不是银弹(JA3/JA4 照样能被复刻,设备证明有兼容性成本),但叠加起来的效果远比单靠客户端上报一份 JSON 靠谱。

九、算笔经济账:到底值多少钱

把第四节跑出来的实测数据换算成云主机成本(数量级估算):

  • 默认档(hashwx 1M)每枚 token 期望 100 万次哈希;编译模式内核单线程约 17.4 万次/秒(173,951 hashes/s),算下来每枚 token 大约消耗 5.75 核·秒。
  • 按常见云主机 $$0.05$ / 核·小时(即 $$0.05 \div 3600 \approx $0.0000139$ / 核·秒)折算,单枚 token 成本约 $$0.00008$(十万分之八美元)。
  • 换算成验证码行业通用的「每千次调用(per 1k tokens)」口径:
    • 优化后求解器:约 $$0.08$ / 千枚 token(折合 $$80$ / 百万枚)。
    • 未优化朴素求解器(慢 7–8 倍):约 $$0.60$ / 千枚 token(折合 $$600$ / 百万枚)。

对比一下这组数字就能看出客户端优化的杀伤力:在未优化前,默认档每千枚 $$0.60$(百万枚 $$600$)的算力成本,其实已经摸到了第三方打码平台的报价区间(通常 $$0.50\text{–}$2.00$ / 千次),对低价值的批量抓取确实能起到劝退作用;但一旦攻击者做完第四节的编译内核优化,成本直接跌到 $$0.08$ / 千枚,相比 hCaptcha / reCAPTCHA 官方千分之几美元的单价($$1\text{–}$2$ / 千次)直接打了个零头。

至于图 1 里低于 $10^5$ 次哈希的浅绿档位,优化后每千枚成本不到 1 美分(百万枚约 $$8$,最低档 c=80 d=1 百万枚仅 $$0.10$),筛掉的就只是那些连一点 CPU 都不愿付的轻量级脚本了。


十、复现与仓库

靶场脚本、协议拆解笔记、18 组预设的原始数据、求解器基准和检测层分析都已经整理到了 GitHub:

https://github.com/haliChina/cap-lab

# 起一个自己的 Cap 实例之后
export ADMIN_KEY=<你的实例管理密钥>
python3 tools/mkkey.py '{"name":"t","protocol":"hashwx","hashwxDifficulty":50000}'
node tools/matrix.mjs A                                  # 预设矩阵
node tools/throughput.mjs '{"protocol":"hashwx"}' 10 1    # 吞吐压测
sh tools/bypass.sh strict                                # 一条命令跑完整条链路
node tools/feedback/server.mjs                           # 一个按钮的反馈系统

仓库里的工具链没有预置任何第三方目标地址(probe-remote.mjs 必须显式传 baseUrl),仅用于本地靶场或你拥有明确授权的实例。本文与 Cap 官方无隶属关系。


实验环境:ARM64 容器、Node 22,Cap v3 standalone(commit af624b9)。所有数字来自本地实测,不同机器会有出入,但量级关系与趋势不变。


致谢:

MiniMax M3.1 Flash(Max)撰写初稿
Muse Spark 1.2审查
Doubao Seed 2.1 Pro(High)审查
GLM 5.3(Max)审查
GPT 6.1 Sol审查
Gemini 4 Argon(High)撰写终稿

这个故事告诉我们:
别用MiniMax写文章。