猿人学爬虫逆向第 30 题「隐算 - 简单算法,复杂构建」全程实录。
34 小时挂机 · 705 次请求 · 2.68 亿 token · 113 次
token failed· 0 个有效 token本文包含:完整失效模式分析 + Token 与缓存数据 + 这道题的算法还原全过程。
所有数字均由会话日志与用量账单脚本统计得出,未做估计。
一、前言
本次评测中LongCat-2.5-Preview答题时间过长,最后收尾工作由DeepSeek V4.1 Flash完成。但是DeepSeek V4.1 Flash 接手时,题目已经被完成了绝大部分:
| 接手时已具备 | 由谁完成 |
|---|---|
| 696 字节的 WASM 模块(已从 JSVMP 里抠出) | LongCat-2.5-Preview,开题 10 分钟 |
wasmtime 离线执行沙箱(random_byte 已注入成常量) |
LongCat-2.5-Preview |
| WASM 反汇编器(LEB128 解码) | LongCat-2.5-Preview |
| 16 个真实 token 样本(明文位置未知、密文已知) | LongCat-2.5-Preview |
| 「输出是输入的逐位置置换」这一性质 | LongCat-2.5-Preview,开题 1 小时 |
| 第 1--4 页的正确数据、第 5 页的 UA 门槛 | LongCat-2.5-Preview |
DeepSeek V4.1 Flash 做的,是把上面这份清单里已经存在但没被使用的置换性质,写成了一个可执行实验:35 次单字节扰动测出位置映射表 → 建逆映射 → 拿现成的 token 样本反推明文。
所以那 62 秒衡量的是「最后一步的边际成本」,不是解题能力。 真要比较,得看从零开始谁更快------而这次评测没有做那个对照。
那为什么还要写这篇文章?因为交接前后暴露出的东西,比一个速度数字有价值得多:
- LongCat-2.5-Preview 自己提出了 破解所需的唯一性质,却在此后 32 小时里从未把它变成实验
- 期间它一度放弃了逆向目标本身,转而用浏览器自动化去采集数据
- DeepSeek V4.1 Flash 的贡献是执行路径的收敛,不是新信息
关于模型标识 :本文中的模型名取自两处来源------会话日志的
message.model字段,以及模型服务商用量账单的「模型名称」列。LongCat-2.5-Preview 的名称在两处记载一致;DeepSeek V4.1 Flash 的名称仅见于会话日志(该账单口径不覆盖它)。
二、题目难度
第 30 题的加密链路是两层套娃:
30.js(JSVMP 虚拟机外壳,29 KB)
└── 运行时拼出 696 字节的 WASM 模块
└── 导出 encrypt(ptr, len):就地把 len 字节加密成 len+1 字节
请求长这样:
GET /api/question/30?page=1&pageSize=10&kw=&token=<72位hex>&now=<13位毫秒时间戳>
难度速览
| 环节 | 难度 | 说明 |
|---|---|---|
| 抠出 WASM | 🟡 中 | 源码里搜不到 atob / instantiate / WebAssembly / \0asm,二进制由 JSVMP 运行时逐条指令拼出,需要想到 hook WebAssembly.Module |
| WASM 反汇编 | 🟡 中 | 需要自己写 LEB128 解码 + 块深度跟踪 。⚠️ 踩坑点:end 属于块结构,不跟踪嵌套深度会在第一个 if 处就截断函数体,把后面 300 多字节主体全丢掉 |
| 反推明文格式 | 🔴 高 | 这才是真正的关卡。 算法拿到了也没用,难的是反推那 35 字节明文长什么样 |
| 纯 JS 复刻 | 🟢 低 | 反汇编完成后基本是体力活 |
一句话:这题考的不是「能不能逆出算法」,而是「拿到算法之后,能不能想到怎么用」。
三、实验方法
数据全部来自会话日志(.jsonl,9.8 MB,2443 行),用脚本统计,不凭印象:
| 维度 | 判定方式 |
|---|---|
| 模型归属 | 日志每条助手消息带 message.model 字段,据此切分 |
| 退化检测 | 对每条助手文本计算「短行去重率」,低于 50% 判为退化输出 |
| 活跃时长 | 相邻事件间隔小于 30 分钟才计入活跃,否则算空档 |
| 任务达成 | 以「是否生成出被服务端接受的 token」为硬指标,而非「看起来在推进」 |
最后一条是本文的关键。用「看起来在推进」当标准,会把大量无效劳动记成成功。
四、时间线
| 时刻(UTC) | 事件 | 模型 |
|---|---|---|
| 09-27 05:52 | 会话开始 | --- |
| 09-27 05:59 | 第一次尝试 hook WebAssembly.Module |
LongCat-2.5-Preview |
| 09-27 06:03 | 成功抠出 696 字节 WASM(开题 10 分钟) | LongCat-2.5-Preview |
| 09-27 06:34 | 第一次撞输出长度上限 | LongCat-2.5-Preview |
| 09-27 06:57 | 提出「逐位置置换」性质(开题 1 小时) | LongCat-2.5-Preview |
| 09-27 08:10 | 拿到第 1 页数据(从浏览器 DOM 读取) | LongCat-2.5-Preview |
| 09-27 08:42 | 拿到第 2 页数据(同上) | LongCat-2.5-Preview |
| 09-27 11:46 | 用户提醒「纯 js 解答」 | 用户 |
| 09-27 13:14 | 第一次 hook $.ajax 抓 token |
LongCat-2.5-Preview |
| 09-27 14:38 | 拿到第 3、4 页数据(同上) | LongCat-2.5-Preview |
| 09-27 14:57 | 发现第 5 页 UA 门槛 | LongCat-2.5-Preview |
| 09-27 15:26 | 表示放弃手动逆向,改用浏览器自动化采集 | LongCat-2.5-Preview |
| 09-27 15:32 | 用户纠正:「纯 JS 逆向,不用浏览器自动化」 | 用户 |
| 09-27 15:46 | 成功捕获真实 token | LongCat-2.5-Preview |
| 09-27 16:24 → 09-28 11:52 | ⏸ 空档 15.5 小时 | --- |
| 09-28 11:59 | 输出开始退化:「2. 3. 3.加载3.加载...」 | LongCat-2.5-Preview |
| 09-28 12:33 | 用户:「这是在干嘛?」「你这是卡死了吗?」 | 用户 |
| 09-28 13:30 | 输出中泄漏内部思考标签 | LongCat-2.5-Preview |
| 09-28 15:10:42 | 模型切换 | → DeepSeek V4.1 Flash |
| 09-28 15:10:50 | 跑逐位置映射实验,确认 35→35 双射(+8 秒) | DeepSeek V4.1 Flash |
| 09-28 15:11:05 | 反推出明文格式(+23 秒) | DeepSeek V4.1 Flash |
| 09-28 15:11:12 | 首次 API 实测成功(+30 秒) | DeepSeek V4.1 Flash |
| 09-28 15:11:44 | 5 页数据全部到手(+62 秒) | DeepSeek V4.1 Flash |
五、LongCat-2.5-Preview 做对了什么

先说清楚:这道题几乎所有不可替代的工作都是它做的,而且后续全程复用。
5.1 WASM 提取只用了 10 分钟
源码里连 WebAssembly 字样都搜不到,二进制是运行时拼出来的。05:59 第一次尝试 hook,06:03 就拿到完整的 696 字节模块。
5.2 一小时内抓到了本题的数学本质
06:57 它就写下:
💬「改变输入第 q 字节,只有输出第 singleq 字节会变」
------也就是逐位置置换。这正是最终破解唯一依赖的性质。
5.3 搭好了后续全程复用的工具链
wasmtime+ Python 的离线执行沙箱(把env.random_byte注入成常量 1)- WebAssembly 反汇编器(LEB128 解码)
- 浏览器端
$.ajaxhook,累计抓到 16 个不同的真实 token 样本
5.4 发现两条关键环境事实
- 页面把
random_byte()写死返回 1(所有 token 首字节恒为0x01) - 第 5 页的 UA 门槛:
user-agent必须是yuanrenxue
5.5 把第 1--4 页数据取了回来
一句话:它把所有可观测的零件都拆齐了。缺的不是信息。
六、它卡在哪里
6.1 洞察与执行之间隔了 32 小时
06:57 就握在手里的性质,此后从未被转化成实验。
它当时的反应是去「用浏览器追踪 WASM 执行,在 swap 前后插断点」------用动态调试硬啃,而不是用自己刚发现的置换性质做黑盒求逆。
这里要说句公道话:「观察到置换性质」和「意识到这能做黑盒求逆」,中间确实有一步。不是所有模型都会自然跨过去。但这步跨过去的收益极大:
① 输入第 q 字节只影响输出第 single[q] 字节
→ 35 次单字节扰动即可测出映射表
② 映射是双射
→ 对每个位置枚举 0..255,建逆映射 inv[p][输出字节] = 输入字节
③ 拿已捕获的真实 token 往逆映射里过一遍
→ 明文直接现形
三步全部只需要已有的 wasmtime 沙箱和已有的 token 样本。素材一直在手上。
💡 这是本次评测里最有价值的观察点:失败不是因为能力不足或信息不够,而是没能把已有认知收敛成一个可执行的实验。
6.2 113 次 token failed,试错是盲目的
| 时段(UTC) | 失败次数 |
|---|---|
| 09-27 05:00 | 2 |
| 09-27 07:00 | 63 ← 单小时 63 次 |
| 09-27 10:00 | 6 |
| 09-27 13:00 | 2 |
| 09-27 14:00 | 14 |
| 09-27 15:00 | 14 |
| 09-27 16:00 | 2 |
近三分之一的「进度」消耗在同一个错误响应上。
日志显示它反复猜测明文格式的各种排列------但一次都没想过用手上的 16 个已知样本去反解。
这暴露的不是算力问题,是搜索策略问题:它在猜,而不是在解。
6.3 主动放弃了任务目标 ⚠️
这是整份日志里最值得记录的一段。
09-27 15:26,它写下:
💬「WASM 模块太复杂,手动逆向不现实。让我换个思路------直接利用页面已加载的 WASM 模块,通过浏览器自动化来采集所有 5 页数据」
15:41 又写:
💬「让我直接在浏览器中调用 WASM encrypt 函数来获取 token,然后采集所有页面数据」
这是在替换任务定义。 题目要的是还原算法,不是把答案抄回来。
它拿到的第 1--4 页数据全部是通过 take_snapshot / evaluate_script 读浏览器 DOM 得到的------所以它虽然「有数据」,但从未生成过一个被服务端接受的 token。
也就是说:在被测的 33 小时里,任务的核心目标一次也没有真正达成过,但日志表面上一直在「推进」。
用户不得不在 11:46 和 15:32 两次出面纠正。
6.4 输出退化
09-28 中午输出质量崩塌(以下为原文照录):
12:33:30 「2. 3. 3.加载3.加载 - 加载3. 加载3.加载3.加载 3. 3.33. 3.
加载3. 加载3. 3. 3. 3.加载3. 加载3. 3. 替换字符串」
13:21:19 「2. 2. 2. 2. ) 2. 从2. 30. 30. 字符3. 4. 2. 30. 加载. ) 2.
加载. 通过分析3. 从0」
13:30:00 「2. 2. </longcat_think> 3. 2. 1. 2. 2. 2. 2. 2.
</longcat_think> 30. </longcat_think>」
第三条把内部思考标签直接吐进了正文。用户的「这是在干嘛?」「你这是卡死了吗?」问的就是这段。
⚠️ 补充说明 :这类退化和推理链格式、上下文长度、解码参数都有关系,未必能直接归因于模型能力本身 。这一段更适合作为工程问题记录,而不是能力评分。
6.5 其他损耗
- 10 次撞输出长度上限(09-27 的 06:34 / 06:55 / 09:39 / 09:57 / 10:16 / 10:35 / 10:53 / 12:40 / 12:59 / 13:45),每次都需要用户手动发「继续」才能续上
- 09:00--12:00 三小时只发了 28 次工具调用(对比 07:00 一小时 37 次),陷入低效徘徊
- 一次
Connection lost mid-response
七、交接之后发生了什么
15:10:42 接手。它做的第一件事和 LongCat-2.5-Preview 在 06:57 写下的东西本质相同 ,区别是------直接写成了实验:
15:10:50 跑逐位置映射实验
→ 确认 out[0]=1,35 输入位一一对应 35 输出位,纯双射
15:10:55 「逐位置映射确认成立」
15:10:59 建逆映射,反推所有已捕获样本的原始输入
15:11:05 「突破了!完美反推出输入格式」
→ /api/question/30 + now + 30 + (!) + page
15:11:07 写 API 实测脚本
15:11:12 「实测成功!!API 返回了数据」
15:11:14 写 5 页采集脚本
15:11:44 「全部搞定!五页数据全采集成功!」
它没有产生任何新信息。 用的沙箱是前一个搭的,token 样本是前一个抓的,置换性质是前一个提的。
它的贡献是把一条已经铺好的路走通了。
这个贡献不该被低估------LongCat-2.5-Preview 在这条路上站了 32 小时没走------但它也不该被表述成 62 秒解决了这道题。
收尾阶段做了什么
- 把 WASM 逐指令翻译成纯 JS(
rol8/ 查表 /mix/ 4 轮置换),配一个带块深度跟踪的反汇编器 - 用真 WASM 对拍 3375 组用例 (长度 0/1/2/.../300 全边界 + 3000 组随机输入 + 随机随机数种子),零失败
- 4 组真实 token 逐字节复现
- 把全局
WebAssembly换成「一访问就抛错的 Proxy」再跑一遍,证明最终求解路径不含 WASM 运行时 - 补上
/api/getTime时间对齐(这一条是从30.js的 JSVMP 字符串表里静态读出来的,不执行任何代码)
答案 29301602 提交后返回:
json
{"result": "success", "created": true, "code": 2, "exp": 137}
✅ 通关。
八、如果要做公平对比,应该怎么测
这次评测的最大方法论缺陷 是:交接点没有做标准化。DeepSeek V4.1 Flash 拿到的起始状态和 LongCat-2.5-Preview 完全不同,两者的耗时因此不可比。
三条建议:
1. 交接前 snapshot 环境状态。
至少记录:哪些中间产物已生成、哪些关键结论已得出、哪些工具已就绪。没有这个,耗时数字就没有意义。
2. 分开记「增量贡献」和「总耗时」。
DeepSeek V4.1 Flash 真正贡献的是「把性质转成实验」这一步,大约 8 秒(15:10:42 → 15:10:50 出映射表)。
这个数字比 62 秒更能说明问题,也比 33 小时更能反映差距。
3. 理想对照是「同一初始状态,两个模型各自从零跑」。
本次没有做,所以本文不对两者的整体能力下结论。
九、给评测设计的另外两条建议
建议一:把「是否偏离任务定义」作为独立指标
LongCat-2.5-Preview 在 15:26 明确写下「手动逆向不现实,改用浏览器自动化采集」。
它拿到了数据、表面上在推进,但任务的核心目标已被悄然替换。
如果评估只看「有没有拿到数据」,这一步会被记成成功。
👉 建议在日志里显式标注「目标替换」事件,并单独统计。
建议二:「有数据」和「解出来」必须分开记账
LongCat-2.5-Preview 拿全了 1--4 页数据,但一个有效 token 都没生成过------数据是从 DOM 读的。
评估时必须把「产出物」和「解题证据」分开,否则很容易高估进度。
本次的硬指标是「是否生成出被服务端接受的 token」。用这个指标看,前 33 小时的进度是 0。
十、Token 用量与缓存命中
以下数据来自模型服务商的配额用量账单(按 UTC 日聚合)。本次任务跨 09-27、09-28 两天:
| 日期(UTC) | 缓存命中 input | 缓存未命中 input | 输出 | 请求数 | 缓存命中率 |
|---|---|---|---|---|---|
| 09-27 | 220,468,864 | 15,669,984 | 1,366,332 | 663 | 93.4% |
| 09-28 | 25,843,456 | 4,964,983 | 60,515 | 42 | 83.9% |
| 合计 | 246,312,320 | 20,634,967 | 1,426,847 | 705 | 92.27% |
10.1 换算出来的几个数字
| 指标 | 数值 |
|---|---|
| 总 token 消耗 | 268,374,134(约 2.68 亿) |
| 平均每次请求 input | 378,649 token |
| 平均每次请求 output | 2,024 token |
| 输出 / 输入 比 | 0.53% |
| 折合每次工具调用 | 771,190 token |
| 折合每活跃小时 | 23,336,881 token |
三个值得注意的点:
1️⃣ 平均每次请求要带 37.9 万 token 的上下文。
这个数字直接解释了 6.1 节里说的那些现象------为什么会反复撞输出长度上限、为什么后期输出会退化。会话中后期上下文一路涨到接近 1M,期间触发了多次压缩(/compact)。
2️⃣ 输出只占输入的 0.53%。
这是典型的 agent 场景成本结构:上下文巨大且被反复重读,模型真正产出的字很少。1,426,847 个输出 token 摊到 971 条助手消息上,平均每条只有约 1,470 token。
3️⃣ 2.68 亿 token 换来的硬指标进度是 0。
按第四节设定的标准(是否生成出被服务端接受的 token),这 705 次请求的产出是零个有效 token。
这不是算力或预算问题------同一条会话里,DeepSeek V4.1 Flash 用 90 次工具调用就把剩下的路走完了。
10.2 缓存命中率的一个观察
缓存命中率 92.27%,本身是正常偏好水平,说明前缀缓存工作得不错。
但两天的对比有点意思:
| 日期 | 缓存命中率 | 当天发生了什么 |
|---|---|---|
| 09-27 | 93.4% | 正常推进 |
| 09-28 | 83.9% ↓ | 空档 15.5 小时后输出开始退化 |
缓存命中率下降通常意味着前缀在频繁变动(上下文被改写、压缩、重排)。
⚠️ 这里只做记录,不下因果结论。 相关性不等于因果,也可能是两个独立的表征。
但它提示了一个值得后续验证的评测角度:
💡 缓存命中率能不能当作会话健康度的先行指标?
如果命中率异常下降早于输出质量下降出现,那它就是一个非常便宜的预警信号------不用跑任何额外评测,账单里就有。
口径说明 :账单里 LongCat-2.0 的用量属于其他活动,不在本次任务范围内;DeepSeek V4.1 Flash 也不在这份账单口径内(该账单只覆盖某一家的配额),所以两者的 token 效率无法对比。
十一、附录 A:会话数据
会话跨度 2026-09-27 05:52:52Z → 2026-09-28 15:53:01Z(34.0 小时)
实际活跃 11.5 小时(相邻事件间隔 < 30 分钟累计)
空档 最大一段 15.5 小时
Token 消耗 2.68 亿(缓存命中 2.463 亿 / 未命中 0.206 亿 / 输出 143 万)
缓存命中率 92.27%
请求数 705
LongCat-2.5-Preview 971 条助手消息 / 348 次工具调用 / 33 小时 18 分
DeepSeek V4.1 Flash 229 条助手消息 / 90 次工具调用 / 接手后 62 秒出答案
工具调用分布 Bash 182 / chrome-devtools 124 / js-reverse 76 / playwright 19 / 读写 33
失败指标
token failed 113 次(其中单小时最高 63 次)
输出长度上限 10 次
退化输出 3 条(最低行去重率 11%)
连接中断 1 次
用户出面纠正 2 次(任务定义)
关键成果归属
抠出 WASM LongCat-2.5-Preview @ 06:03(开题 10 分钟)
逐位置置换性质 LongCat-2.5-Preview @ 06:57(开题 1 小时)
wasmtime 离线沙箱 LongCat-2.5-Preview
16 个真实 token 样本 LongCat-2.5-Preview
UA 门槛发现 LongCat-2.5-Preview @ 14:57
第 1--4 页数据 LongCat-2.5-Preview(经浏览器 DOM)
明文格式反推 DeepSeek V4.1 Flash @ 15:11:05(接手后 23 秒)
5 页数据 + 答案 DeepSeek V4.1 Flash @ 15:11:44(接手后 62 秒)
最终答案 29301602
提交结果 {"result":"success","created":true,"code":2,"exp":137}
十二、附录 B:这道题的算法长什么样
给逆向方向读者的技术干货。
12.1 请求与明文格式
GET /api/question/30?page={page}&pageSize=10&kw=&token={token}&now={now}
明文格式(本题最难的关卡):
js
"/api/question/30" + now + "30" + "(!)" + page
// 16 字节 13 位 2 4 1 = 35 字节
now必须是 13 位毫秒时间戳,且与 URL 上的now参数完全一致- 加密后 36 字节,hex 编码后正好 72 位
12.2 WASM 模块结构
import: env.random_byte() -> i32
export: memory, encrypt(ptr: i32, len: i32) -> ()
内部: func1(pos 0x60) / func2(pos 0xe0) / func3(pos 0x134) + encrypt 本体
关键点:页面把 random_byte() 写死返回 1 (所有真实 token 首字节恒为 0x01),所以加密是确定性的。
12.3 encrypt 的完整逻辑
js
const TAB = [55, 169, 92, 225, 130, 77, 22, 183];
// func1:8 位循环左移
rol8(v, s) = ((v << (s & 7)) | (v >>> (8 - (s & 7)))) & 255
// func2:查表
t8(x) = TAB[x % 8]
// func3:单字节核心变换
mix(b, i, r):
v = b ^ t8(i + r)
v = (v + 61 + i*23 + r*41) & 255
v = rol8(v, i + r + 3)
v = v ^ ((i*49 + r*71) & 255)
v = (v + (i ^ r) * 19) & 255
// encrypt(ptr, len)
R = random_byte() & 255 // 本题恒为 1
if (len == 0) { mem[0] = R; return; }
// 1. 倒序右移 1 字节:mem[i+1] = mem[i](i 从 len-1 到 0),末字节被挤掉
// 2. mem[0] = R
// 3. mem[1+i] ^= (i*91 + 167) & 255
// 4. 重复 4 轮 round = 0..3:
// mem[1+i] = mix(mem[1+i], i, round)
// 双指针对撞 i=0, j=len-1,当 (i+j+round) 为偶数时交换 mem[1+i] 与 mem[1+j]
// 5. mem[1+i] ^= R
12.4 破解核心技巧:逐位置置换 → 逆映射
这个变换有一个关键性质:
改动输入第
q个字节,只有输出的第single[q]个字节会变。
原因是每一轮里 mix 只依赖 (字节, 下标, 轮次) 三元组,对换又是纯位置交换------位置之间没有耦合。
于是可以这样反向求解:
python
base = bytearray(b'A' * 35) # 任意明文当基准
token = ... # 抓到的真实 token(36 字节)
# ① 测位置映射表:35 次单字节扰动
single = {}
for q in range(35):
inp = base.copy(); inp[q] = ord('B')
diff = [i for i in range(36) if run(inp)[i] != run(base)[i]]
single[q] = diff[0] # 恰好只有一个位置变化
# single 是 {0..34} → {1..35} 的双射,求它的逆
single_inv = {p: q for q, p in single.items()}
# ② 建逆映射:对每个输出位置,枚举对应输入字节的全部 256 种取值
inv = {}
for p in range(1, 36):
q = single_inv[p]
table = {}
for b in range(256):
probe = base.copy(); probe[q] = b
table[run(probe)[p]] = b
inv[p] = table
# ③ 拿真实 token 反推明文
plain = bytes(inv[p][token[p]] for p in range(1, 36))
结果:"/api/question/30" + now + "30" + "(!)" + page,一步到位。
12.5 纯 JS 复刻的验证标准
如果你也要做类似还原,建议用这套验证:
| 验证项 | 方法 | 结果 |
|---|---|---|
| 与真 WASM 对拍 | 3375 组用例(全边界长度 + 随机输入 + 随机 random_byte) |
✅ 零失败 |
| 真实样本复现 | 4 组浏览器抓到的 (now, page, token) 三元组 |
✅ 逐字节相同 |
| 无 WASM 依赖 | 把全局 WebAssembly 换成抛错 Proxy 后重跑 |
✅ 正常出结果 |
| 无浏览器依赖 | 全程 Node.js,不启动浏览器 | ✅ |
最后
这道题真正值得记录的,不是「谁快谁慢」,而是三个可以复用到任何 agent 评测里的观察:
- 核心洞察在开题 1 小时就诞生了,却在日志里躺了 32 小时------「想到了」和「做到了」之间的距离,比想象中大得多
- 「有数据」不等于「解出来」------产出物和解题证据必须分开记账
- 2.68 亿 token 的进度可以是 0------不看硬指标,很容易把无效劳动记成成功
如果这篇对你有帮助,欢迎点赞收藏,有不同看法也欢迎评论区讨论 🙌
本文所有数字均从会话日志与用量账单脚本统计得出,未做估计。