Files
hello_js_reverse_skill/tcaptcha/VM_SPEC.md
T
yml2213 2e2bbc876a feat(密钥): 纯 Python 静态提取 XTEA 密钥 —— 去掉最后一个 Node 依赖 (6/6 构建与神谕逐字一致)
做了什么
- 新增 tcaptcha/tdc_opcodes.py: 按构建从解释器源码推导 opcode 表。
  实测 opcode 编号**每份构建整体重排**(同一源码 pc0 的 SET_STACK_LEN 在 6 份构建里
  = 53/19/32/57/55/1, JUMP = 60/17/42/58/21/2); 旧固化表只对 core_js 有效 ——
  用它反汇编其它构建会在 pc0 撞上 RET_TRUE, 得到"空反汇编"的假象。
  做法: 定位主循环 X[Y[Z++]]() 的 handler 数组 -> JS 感知切分 -> handler 源码规范化
  (去空白 + 局部变量按首次出现重命名) 取指纹 -> 与内置 58 条 core_js 参考指纹匹配;
  数组下标即该构建的 opcode, argc 由语义名取 (与 handler 体内 x[y++] 次数实测一致)。
- 新增 tcaptcha/tdc_key.py: 纯 Python XTEA 密钥静态还原器 (零 Node / 零 pc 硬编码)。
  反汇编 -> 定位含 delta 的 XTEA 闭包 -> 用"两层捕获链"选出真密钥数组:
  字闭包捕获的 B 帧寄存器, 经"定义 B 的那个闭包"的 caps 映射到 G 帧后须 == XTEA 闭包
  捕获的寄存器; 借此排除脚本里并行计算的第二套诱饵字 -> 读 4 个 (源串, 偏移, 下标)
  -> 折入轮加盐。
- tools/tdc_disasm.py: disassemble_cfg/_decode_one 支持注入按构建推导的 opcode 表
  (names/arity/dynamic), 默认仍回落 core_js 表; 控制流 opcode 改为按语义名定位。
- tools/tdc_key_pure.py: CLI (-v 打印推导过程 / --opcodes 打印 opcode 表 / --json)。
- tcaptcha/crypto.py: extract_xtea_key 改为"纯 Python 主路径 + Node 插桩兜底"。
- 新增 tcaptcha/tests/test_tdc_key.py 并接入 selftest;
  新增入库 fixture tcaptcha/tests/fixtures/tdc_fresh_2026-10-11.js (现场新抓的 tdc.js)。

为什么
- AGENTS.md §2.4 分层交付 / §1.1: 去掉最后一个 Node 依赖, 提高并发与抗发版能力;
- §5.7 负结果也是资产: 显式撤回"opcode 数值跨构建固定"与"密钥只能靠 Node 插桩拿"两条旧结论。

怎么验证
- 6 份构建纯 Python 结果 == Node 插桩 extract_xtea_key, **逐字一致**:
  core_js / root / 1ms tdc.js / tdc_live / tdc_latest / 2026-10-11 现场新抓 tdc_fresh。
- opcode 表 6/6 构建 58/58 handler 精确匹配, 0 未识别。
- .venv/bin/python -m tcaptcha.selftest 全绿 (含既有逐字节对拍 test_xtea_key)。
- 把 node_bin 指向不存在的程序时 generate_telemetry 仍产出 collect (证明已零 Node)。
2026-10-11 22:49:23 +08:00

267 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 腾讯天御 Chaos VM 规范(纯粹从源码/反汇编fact整理)
> 目的:把 TDC 的 JSVMP(`__TENCENT_CHAOS_VM`)从"读不懂"变成"有确定规格",供纯 Python 复现与静态提取使用。
> 事实来源:`tcaptcha/core_js/tdc_sample.js` 的 VM 源码(AST 导出)+ `tools/tdc_disasm.py` 的正确反汇编。
> 工具:`tools/tdc_disasm.py`(提取+CFG 反汇编)、`tools/tdc_vm_run.py`(纯 Python 解释器,WIP)。
---
## 1. 架构总览
VM 就是一个**基于栈的字节码解释器**,入口签名(源码原样):
```js
function __TENCENT_CHAOS_VM(H, z, c, E, I, D, U, C) {
var g, A = !E,
B = (H = +H, z = z || [0], E = E || [[this], [{}]], I = I || {}, []),
w = null;
function Q() { return function (A, B, w) { return new (Function.bind.apply(A, B))(); }.apply(null, arguments); }
/* ... 定义 58 个 handler,装配成 G 数组 ... */
for (var G = [ /* handlers */ ], M = !1; !M; ) M = G[z[H++]]();
}
```
| 形参 | 含义 |
| --- | --- |
| `H` | 程序计数器 PC(字节码下标) |
| `z` | 字节码整数数组(45636 个) |
| `c` | **宿主全局对象**(页面里就是 `window`)—— `.apply(c, ...)` 的默认 this |
| `E` | **寄存器区 + 求值栈**(同一个数组!)默认 `[[this],[{}]]` |
| `I D U C` | 其它透传参数(本样本用不到) |
| `B` | try 栈(异常帧) |
| `w` | 异常标志(非空即"有异常") |
主循环:`M = G[z[H++]]()` —— 取 `z[H++]` 当 opcode 下标调用 handler;**handler 返回真值时循环结束**(对应 `RET_TRUE`=19、`IS_EXCEPTION`=1 在异常时)。
---
## 2. 数据模型(关键,之前所有混乱都出在这里)
1. **`E` 同时是"寄存器区"和"求值栈"**:`E[0..n-1]` 是寄存器,`E[n..]` 是栈。
2. **寄存器是"单元数组"**:寄存器 `i` 的**值**是 `E[i][0]`,而 `E[i]` 本身是个数组(`ALLOC_REG i` → `E[i] = []`)。
3. **"寄存器引用"惯用法**:
- `PUSH_IMM_ARRAY i`(op 31)压入字面数组 `[i]`(这里 `i` 是**寄存器号**,不是数组元素);
- `MAKE_REG_PROP_REF`(op 32)`A=E.pop(); E.push([E[E.pop()][0], A])` → 得到 `[E[i][0], key]`,即"**对寄存器值的属性引用**"。
4. **引用 = 两元素数组 `[base, key]`**;`GET_PROP`(59)/`SET_PROP`(14) 负责解引用。
5. 栈操作:`DUP`(2)、`SWAP n`(17,`n` 是相对栈顶的偏移)、`POP`(51)、`REVERSE_TOP2`(54)、`SET_STACK_LEN n`(53,**直接截断/扩张 E 的长度**)、`REPLACE_TOP_IMM v`(64)。
> ⚠️ 因为 `E` 一物两用、寄存器有"单元数组"这层壳、且引用是普通二元数组,**任何"按寄存器号做正则匹配"的做法都会失效** —— 必须做真正的数据流/执行。
---
## 3. 完整 opcode 表(70 槽 / 58 handler / 12 空洞)
`argc` = handler 体内 `z[H++]` 次数(= 固定操作数字节数);动态长度的那个是 `CREATE_CLOSURE`(见 §4)。
> ⚠️ **本节表格是"core_js 那一份构建"的编号,不能当跨构建常量用(重要修正,见 §3.1)。**
> 下面的 `opcode` 列只对 `tcaptcha/core_js/tdc_sample.js` 成立。
> **空洞 opcode**:`7, 16, 21, 24, 27, 40, 43, 44, 50, 58, 63, 67` —— 它们**永远不会**出现在可执行字节码里;
> 一旦在反汇编里看到空洞 opcode,说明**解码失步**(旧线性反汇编器就是这么坏的)。
### 3.1 opcode 编号**每份构建都会整体重排**(实测,5+1 份构建)
> **推翻早期结论**:早期把上面的 opcode 数值当成 VM 常量("`RET_TRUE`=19、`SET_STACK_LEN`=53…")。
> 实测是错的:**同一份源码编译出的字节码,pc0 处的 `SET_STACK_LEN` 在 6 份构建里分别是
> 53 / 19 / 32 / 57 / 55 / 1**,`JUMP` 分别是 60 / 17 / 42 / 58 / 21 / 2。
> 也就是说 **opcode → 语义 的映射表每份脚本都重新洗牌**;写死数值 = 换一份脚本全错
> (这也是为什么早先用 core 表去反汇编其它构建会得到"`RET_TRUE` 在 pc0 ⇒ 反汇编为空"的假象)。
**正确做法**:不在字节码上死磕,而是**从该构建自己的解释器源码里推导 opcode 表** ——
见 `tcaptcha/tdc_opcodes.py`:
1. 定位解释器主循环 `X[Y[Z++]]()`,取出**操作码处理器数组变量** `X`;
2. 解析该数组字面量(JS 感知的括号/字符串/正则切分)得到 70 个槽位(含空洞);
3. 把每个 handler 源码**规范化**(去空白 + 局部变量按首次出现重命名 `v1,v2,…`,
保留 JS 关键字/内建名与属性名)得到与命名无关的"指纹";
4. 用内置的 58 条参考指纹(取自 core_js,即本表)反查每个槽位的语义名 ——
**数组下标即该构建的 opcode**;`argc` 直接按语义名取(与 handler 体内 `x[y++]` 次数实测一致)。
> 交叉验证:6/6 份构建全部 **58/58 handler 精确匹配**、0 未识别。
> 因此 `tools/tdc_disasm.py` / `tools/tdc_lift.py` 现在都支持**注入按构建推导的 opcode 表**
> (`disassemble_cfg(z, names=…, arity=…, dynamic=…)`);不传才回落到本表(core_js)。
| opcode | argc | handler 源码(core_js 的 VM 内 G 数组第 i 项) |
| --- | --- | --- |
| 0 | 1 | `function () { E[E.length - 1] += String.fromCharCode(z[H++]); }` |
| 1 | 0 | `function () { return !!w; }` |
| 2 | 0 | `function () { E.push(E[E.length - 1]); }` |
| 3 | 0 | `function () { E[E.length - 2] = E[E.length - 2] ^ E.pop(); }` |
| 4 | 0 | `function () { E[E.length - 2] = E[E.length - 2] === E.pop(); }` |
| 5 | 0 | `function () { E[E.length - 2] = E[E.length - 2] % E.pop(); }` |
| 6 | 1 | `function () { var A = z[H++], B = A ? E.slice(-A) : []; E.length -= A, E.push(E.pop().apply(c, B)); }` |
| 7 | - | _(空洞: 无 handler)_ |
| 8 | 0 | `function () { E[E.length - 1] = c[E[E.length - 1]]; }` |
| 9 | 0 | `function () { var A = E.pop(), B = E.pop(); E.push([B[0][B[1]], A]); }` |
| 10 | 0 | `function () { E.push(typeof E.pop()); }` |
| 11 | 0 | `function () { var A = E.pop(); E.push(delete A[0][A[1]]); }` |
| 12 | 0 | `function () { E.push(!E.pop()); }` |
| 13 | 0 | `function () { E[E.length - 2] = E[E.length - 2] == E.pop(); }` |
| 14 | 0 | `function () { var A = E[E.length - 2]; A[0][A[1]] = E[E.length - 1]; }` |
| 15 | 0 | `function () { E[E.length - 2] = E[E.length - 2] > E.pop(); }` |
| 16 | - | _(空洞: 无 handler)_ |
| 17 | 1 | `function () { var A = z[H++], B = E[E.length - 2 - A]; E[E.length - 2 - A] = E.pop(), E.push(B); }` |
| 18 | 0 | `function () { E[E.length - 2] = E[E.length - 2] in E.pop(); }` |
| 19 | 0 | `function () { return !0; }` |
| 20 | 0 | `function () { E[E.length - 2] = E[E.length - 2] >= E.pop(); }` |
| 21 | - | _(空洞: 无 handler)_ |
| 22 | 1 | `function () { E.push(z[H++]); }` |
| 23 | 0 | `function () { E[E.length - 2] = E[E.length - 2] * E.pop(); }` |
| 24 | - | _(空洞: 无 handler)_ |
| 25 | 0 | `function () { E.push(E[E.pop()[0]][0]); }` |
| 26 | 0 | `function () { E[E.length - 1].length ? E.push(E[E.length - 1].shift(), !0) : E.push(undefined, !1); }` |
| 27 | - | _(空洞: 无 handler)_ |
| 28 | 0 | `function () { E.push([c, E.pop()]); }` |
| 29 | 0 | `function () { E.push(null); }` |
| 30 | 0 | `function () { E[E.length - 2] = E[E.length - 2] & E.pop(); }` |
| 31 | 1 | `function () { E.push([z[H++]]); }` |
| 32 | 0 | `function () { var A = E.pop(); E.push([E[E.pop()][0], A]); }` |
| 33 | 0 | `function () { E[E.length - 2] = E[E.length - 2] - E.pop(); }` |
| 34 | 1 | `function () { var A = z[H++], B = A ? E.slice(-A) : [], A = (E.length -= A, E.pop()); E.push(A[0][A[1]].apply(A[0], B)); }` |
| 35 | 0 | `function () { E[E.length - 2] = E[E.length - 2] >> E.pop(); }` |
| 36 | 0 | `function () { E[E.length - 2] = E[E.length - 2] << E.pop(); }` |
| 37 | 1 | `function () { var A = z[H++]; E[E.length - 1] && (H = A); }` |
| 38 | 0 | `function () { var A, B = []; for (A in E.pop()) B.push(A); E.push(B); }` |
| 39 | 0 | `function () { E[E.length - 2] = E[E.length - 2] + E.pop(); }` |
| 40 | - | _(空洞: 无 handler)_ |
| 41 | 1 | `function () { var A = z[H++], B = A ? E.slice(-A) : [], A = (E.length -= A, B.unshift(null), E.pop()); E.push(Q(A[0][A[1]], B)); }` |
| 42 | 0 | `function () { E.push(undefined); }` |
| 43 | - | _(空洞: 无 handler)_ |
| 44 | - | _(空洞: 无 handler)_ |
| 45 | 0 | `function () { E.push(!1); }` |
| 46 | 0 | `function () { E[E[E.length - 2][0]][0] = E[E.length - 1]; }` |
| 47 | 1 | `function () { var A = z[H++]; E[A] = E[A] === undefined ? [] : E[A]; }` |
| 48 | 0 | `function () { B.pop(); }` |
| 49 | 0 | `function () { throw E[E.length - 1]; }` |
| 50 | - | _(空洞: 无 handler)_ |
| 51 | 0 | `function () { E.pop(); }` |
| 52 | 0 | `function () { E[E.length - 2] = E[E.length - 2] / E.pop(); }` |
| 53 | 1 | `function () { E.length = z[H++]; }` |
| 54 | 0 | `function () { E.push([E.pop(), E.pop()].reverse()); }` |
| 55 | 0 | `function () { E.push(""); }` |
| 56 | 2 | `function () { B.push([z[H++], E.length, z[H++]]); }` |
| 57 | 6 | `function () { for (var w = z[H++], Q = [], A = z[H++], B = z[H++], G = [], M = 0; M < A; M++) Q[z[H++]] = E[z[H++]]; for (M = 0; M < B; M++) G[M] = z[H++]; E.push(function g() { var A = Q.slice(0); A[0] = [this], A[1] = [arguments …` |
| 58 | - | _(空洞: 无 handler)_ |
| 59 | 0 | `function () { var A = E.pop(); E.push(A[0][A[1]]); }` |
| 60 | 1 | `function () { H = z[H++]; }` |
| 61 | 1 | `function () { var A = z[H++], B = A ? E.slice(-A) : []; E.length -= A, B.unshift(null), E.push(Q(E.pop(), B)); }` |
| 62 | 0 | `function () { E[E.length - 2] = E[E.length - 2] \| E.pop(); }` |
| 63 | - | _(空洞: 无 handler)_ |
| 64 | 1 | `function () { E[E.length - 1] = z[H++]; }` |
| 65 | 0 | `function () { E[E.length - 2] = E[E.length - 2] >>> E.pop(); }` |
| 66 | 0 | `function () { E.push(!0); }` |
| 67 | - | _(空洞: 无 handler)_ |
| 68 | 1 | `function () { E.push(E[z[H++]][0]); }` |
| 69 | 0 | `function () { w = null; }` |
---
## 4. 闭包与调用约定
### 4.1 `CREATE_CLOSURE`(57) —— 操作数布局(动态)
```
@pc: 57 target capN parN [reg,reg]*capN [reg]*parN
```
handler 语义(源码):
```js
for (var w = z[H++], Q = [], A = z[H++], B = z[H++], G = [], M = 0; M < A; M++) Q[z[H++]] = E[z[H++]];
for (M = 0; M < B; M++) G[M] = z[H++];
E.push(function g() {
var A = Q.slice(0);
A[0] = [this], A[1] = [arguments], A[2] = [g];
for (var B = 0; B < G.length && B < arguments.length; B++) 0 < G[B] && (A[G[B]] = [arguments[B]]);
return __TENCENT_CHAOS_VM(w, z, c, A, I, D, U, C);
});
```
- `Q[capRegIdx] = E[parentRegIdx]` —— **捕获是按"寄存器号 → 寄存器号"成对出现的**(`caps=[r7=r8]` 表示新闭包的 r7 ← 父闭包 r8,**共享同一单元对象**);
- `G` 是形参寄存器号表;
- **调用闭包 = 以 `target` 为新 PC、带着"捕获拷贝 + this/arguments/g"重新进入 VM**。
> 🔑 由此得出一条反汇编铁律:**`CREATE_CLOSURE` 的 `target` 是"被调用时才执行"的代码,必须作为反汇编遍历根**,否则大半个程序都算不到(这是旧工具的第二处致命缺陷)。
### 4.2 调用类 opcode
| op | 名字 | this 绑定 | 说明 |
| --- | --- | --- | --- |
| 6 | CALL_FUNC n | `c`(全局) | `E.push(E.pop().apply(c, B))` |
| 34 | CALL_METHOD n | 引用基对象 | `A[0][A[1]].apply(A[0], B)` |
| 61 | NEW_FUNC n | — | 用 `Q(...)` 做 `new` |
| 41 | NEW_METHOD n | — | 同上,参数前插 `null` |
| 28 | BIND_THIS | — | `E.push([c, E.pop()])` 造"以 c 为基"的引用 |
### 4.3 其它结构
- **异常**:`PUSH_TRY`(56) 压 `[catchPC, E.length, catchVarReg]` 到 `B`;`POP_TRY`(48) 弹;`IS_EXCEPTION`(1) 检查 `w`;`CLEAR_EXCEPTION`(69) `w=null`;`THROW`(49)。
- **属性**:`GET_GLOBAL_PROP`(8) `c[E[-1]]`;`MAKE_PROP_REF`(9) 通用引用;`FOR_IN_KEYS`(38)。
- **字符串**:`PUSH_EMPTY_STR`(55) 压 `""`,随后一串 `PUSH_STR_CHAR`(0) 逐字符追加 —— 所以**字面串在字节码里是一串 <256 的立即数**(折叠即可还原,`tdc_disasm.py` 已做)。
---
## 5. 引导流程(bootstrap,实测)
```
@00000: SET_STACK_LEN 3
@00002: ALLOC_REG 2
@00004: JUMP @1518
...
@01518: CREATE_CLOSURE target=@7 caps=[] params=[p0->r3] <- 模块工厂(之一)
@01523: PUSH_EMPTY_STR "Array" ... GET_GLOBAL_PROP / NEW_FUNC ...
...
(很快 RET_TRUE 返回)
```
**我们的 Python 解释器跑 739 步后 `RET_TRUE` 返回,且寄存器里没有可用的模块闭包** —— 说明:
> **tdc.js 的顶层只"注册/装配"模块;真正做 XTEA、生成密钥的代码,是在宿主调用进模块时才执行的**(浏览器里最终由 `TDC.getData()` 触发;`tools/tdc_probe.js` 也是这么驱动的)。
---
## 6. 宿主接口与"如何驱动"(纯 Python 路线的真正缺口)
- JS 侧调用是 `__TENCENT_CHAOS_VM(0, <zCall 结果>, window)` → **`c = window`**。
- VM 本身不碰环境;**环境访问全部通过 `c`**(`GET_GLOBAL_PROP` / `CALL_METHOD` 的基对象)。
- 因此"能跑出 collect"靠的是:**页面加载 → 模块系统装配 → 宿主调用模块入口 → 模块内部用 `c` 读环境**。
- 所以纯 Python 静态路线要补的不是 VM,而是**模块系统 + 宿主调用序列**。
---
## 7. 工具链映射(对齐 AGENTS.md §3.2 五件套)
> 目标(§4.2 验收):能秒答「**这个寄存器/字段谁写的**」「**这段代码在哪**」「**有多少子程序**」。
| # | 环节 | 工具 | 输入 → 输出 | 状态 |
| --- | --- | --- | --- | --- |
| ① | 清点 inventory | `tcaptcha/VM_SPEC.md` §3 + `tools/tdc_disasm.py` | tdc.js → opcode 表(70槽/58 handler/arity) + 字节码 | ✅ |
| ② | 提取 disasm | `tools/tdc_disasm.py` | tdc.js → 可达性 CFG 反汇编(0 非法 opcode) | ✅ |
| ③ | 运行时追踪 trace | `tools/tdc_trace.js` | 跑一次 → 真值 PC 序列(用于逐条对拍) | ✅ |
| ④ | **执行序渲染 lift** | `tools/tdc_lift.py` | 字节码 → **可读伪代码**(表达式栈还原 + 控制流 label/goto + **闭包递归展开**) | ✅ |
| ⑤ | 查询 CLI query | `tools/tdc_query.py` | `who rN` / `at @pc` / `funcs` / `grep RE` | ✅ |
| 辅助 | 神谕定位 | `tools/tdc_keytrace.js` | 驱动真 VM,定位"某个值在哪段字节码诞生" | ✅ |
| 归档 | ~~复刻 JS 运行时~~ | `tools/tdc_vm_run.py` | 用 Python 重写 JS 语义去"跑"VM | 🗄 归档为储备(**打地鼠陷阱**,§2.3 铁律2) |
**lift 实例(XTEA 轮,直接可读)**:
```
@09285:
r? = (r? + ((((r6 << 4) ^ (r6 >>> 5)) + r6) ^ (r2 + (r7[(3 & r2)] + 855311))));
r2 = (r2 + 2654435769); // sum += DELTA
if (([6] == ((0 >>> 11) & 3))) goto @9398;
if ((3 == ((r2 >>> 11) & 3))) goto @9361;
```
(教科书 XTEA:`v += (((v<<4)^(v>>>5)) + v) ^ (sum + K[(sum>>>11)&3])`;`855311` 即该构建的轮加盐。)
---
## 8. 下一步路线(自上而下)
**已完成的 L3 解构**:①字节码提取 ②正确反汇编 ③全部 opcode 语义 ④提升器(闭包递归展开)
⑤查询 CLI ⑥密钥形态与流向定位(`caps=[r7=r8]`、XTEA 轮 `reg7[(sum>>>11)&3]`)。
**下一步(不改路线)**:
1. 用 ④ 的 lilted 产物 **读** 密钥例程(不再"模拟")→ 用 Python 原生重写(§2.1 最后一步);
2. 与 `extract_xtea_key`(Node 神谕)逐字对齐后,替换掉最后一个 Node 依赖;
3. 把 lift 的栈模型补全(`SWAP/SET_STACK_LEN/REPLACE_TOP_IMM` 的表达式还原),消除残留的 `r<?>`。