Files
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

20 KiB
Raw Permalink Blame History

腾讯天御 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 就是一个基于栈的字节码解释器,入口签名(源码原样):

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 语义(源码):

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)。