Files
hello_js_reverse_skill/tcaptcha/VM_SPEC.md
T
yml2213 d2b3889132 feat(VM): 运行时反证通过 —— 静态导出的 cd 槽位序与真机 require 序逐位一致
做法: 真机 iframe 里包 Function.prototype.apply, 模块工厂调用形状实测为
4 参 [object,object,object,function](require 是最后一个), 按工厂实例分组记账;
再用新增 tools/cdp_script_source.js(Debugger.scriptParsed + getScriptSource)
取运行时真正执行的那份 tdc.js(不能用重新 fetch —— 同 URL 每次返回不同构建)。

结果: 静态 == 运行时 =
[8,9,10,13,14,15,16,17,18,19,20,21,23,24,25,30,31,32,34,36,37,1,38,39,40,41,42,43,44,47,48,49,50,51,56,57,58]
37 项逐位相同 -> 槽位序=require序 这条链闭合; 之前语义对不上是标注启发式的问题。

附带: window.TDC(含 getData) 只在验证码 iframe, 可直接调用取证; cdp_eval 新增 --init-script。
账本新增已知 #35。
2026-10-12 00:24:13 +08:00

360 lines
20 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<?>`。
---
## 9. 程序结构层:模块系统 / 入口 / collect 装配(2026-10-11 补,工具 `tools/tdc_program.py`)
§5/§6 曾把缺口写成「不是 VM 的问题,而是模块系统 + 宿主调用序列」。本节把它补成确定规格。
### 9.1 顶层就是「模块注册表装配」
```
@0000: SET_STACK_LEN 3; ALLOC_REG 2; JUMP @1518
@1518: r3 = CREATE_CLOSURE(target=@7, params=[p0->r3]) // VM 外层包装
...
@1538: PUSH_IMM [0]; REVERSE_TOP2; JUMP -> @2085
@2085: CREATE_CLOSURE(target=@1544, params=[p0->r3,p1->r4,p2->r5]); SET_PROP // registry[0] = 工厂@1544
@2096: PUSH_IMM [1]; REVERSE_TOP2; JUMP -> @2914
@2914: CREATE_CLOSURE(target=@2102, ...); SET_PROP // registry[1] = 工厂@2102
... // 共 62 个
```
即源码级的 `registry[id] = function (module, exports, require) { ... }`(CommonJS)。
**模块工厂的形参固定为 (r3=module, r4=exports, r5=require)**;模块体内 `r5(N)` 就是 `require(N)`。
> `tools/tdc_program.py --modules` 直接导出这张表。
### 9.2 代码布局的坑(把工具改对了)
- 函数体是**不连续**的:父函数的初始化序列与子函数体交错排布,父函数用 `JUMP` 跳过子函数体。
所以「按 pc 大小猜归属」全错(实测把父函数续段误判给夹在中间的兄弟函数)。
正确做法:**边界感知走法** —— 从入口走控制流,遇到**另一个函数入口**就停下。
- 反汇编器曾漏掉终结符:`RET_TRUE`/`THROW` 之后**没有 fall-through**,否则控制流会穿过函数结尾
线性走下去,把后续所有代码都算进当前函数。已修(按语义名定位,不写死 opcode 数值)。
### 9.3 collect 的装配链(`{"cd":[...],"sd":{...}}` 是怎么出来的)
1. **清单模块**:某个模块返回一个数组,构造方式是接连的
```
DUP?; PUSH_IMM_ARRAY [6]; "length" ... ; LOAD_REG_IMM [5]; PUSH_IMM [N]; CALL_FUNC [1]; SET_PROP
```
语义即 `arr[arr.length] = require(N)`。**这串 require 的顺序就是 `cd` 的槽位顺序。**
(core_js 那份:37 个 require + 1 个字面量槽;线上构建实测是**另一串** id。)
2. **分发模块**:一个闭包把清单按 `.on` / `.reset` / `.get` 分进三个数组
(`caps=[r5=r9,r6=r12,r7=r13,r8=r14]`,即 出=r12/r13/r14)。
3. **装配模块**:`r3 = '{"cd":['`,然后
```
for (r7 = 0; r7 < r15.length; r7++) { r8 = r15[r7]; v = r8.get(); ... 追加 r3 ... }
r3 += '],'; ... r4["cd"] = r3
```
即**按数组顺序**调每个采集器 `.get()` 并序列化 → `cd` 槽位序 = 清单数组顺序。
### 9.4 为什么这件事决定 errorCode=12
- 槽位序**按构建随机化**(5 份同日样本 5/5 互不相同,见 `har/1ms_dianxuan/PROGRESS.md` #31)。
- 仓库里的 `models/success_collect_template.b64` 是**某一次**的顺序
(其指纹与 `签到成功-1.har` 那一次逐槽一致,#32)。
- 拿旧顺序发的 collect,会被服务端按**本次构建**的槽位表解释成「自相矛盾的环境」(例如
`cd[1]` 该是 `[360,360]` 却是 `"MacIntel"`)→ 风控判 12。
- 这正是「换 IP / 换遥测后端 / 换传输指纹 / 换 cookie / 长静默全都无效」的原因:
它们**都不动槽位序**。
> 因此纯算路线必须**按本次构建**导出槽位表(`tools/tdc_layout.py`),
> 并用**本次 prehandle 的 sid** 去取那份 tdc.js(真机 URL 还多带 `appid=<aid>`)。
### 9.5 运行时反证(2026-10-11,已通过)
静态导出不能自证,所以做了对拍:在真机 iframe 里包一层 `Function.prototype.apply`,
模块工厂的调用形状实测是 **4 参 `[object, object, object, function]`**(require 是最后一个),
按工厂实例分组记账;再用 `tools/cdp_script_source.js`(`Debugger.scriptParsed` +
`getScriptSource`)取到**运行时真正执行的那份 tdc.js**。
> ⚠️ 不能用"重新 fetch 一次 tdc.js"来对比 —— 同一个 URL **每次返回不同构建**(§9.6/#29),
> 必须用 Debugger 取**已解析的源码**。
结果:静态导出的 37 个 id 与运行时逐位相同
```
[8,9,10,13,14,15,16,17,18,19,20,21,23,24,25,30,31,32,34,36,37,1,38,39,40,41,42,43,44,47,48,49,50,51,56,57,58]
```
→ **槽位序 = 清单模块 require 序** 这条链闭合。脚本:`scripts/experiment_require_order.py`。
### 9.6 附带发现
- `window.TDC`(含 `getData`/`getInfo`/`setData`/`clearTc`)**只存在于验证码 iframe**,可以直接
`TDC.getData(true)` 触发取证(不必真的答一次题),返回值就是 collect 明文。
- iframe 里还有 `__TENCENT_CHAOS_STACK` 全局。
- `Page.reload` 对 OOPIF iframe 无效("Command can only be executed on top-level targets");
想在脚本执行前挂钩子要用 `Page.addScriptToEvaluateOnNewDocument`(已加到 `tools/cdp_eval.js`
的 `--init-script`)。