做了什么 - 新增 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)。
15 KiB
腾讯天御 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. 数据模型(关键,之前所有混乱都出在这里)
E同时是"寄存器区"和"求值栈":E[0..n-1]是寄存器,E[n..]是栈。- 寄存器是"单元数组":寄存器
i的值是E[i][0],而E[i]本身是个数组(ALLOC_REG i→E[i] = [])。 - "寄存器引用"惯用法:
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],即"对寄存器值的属性引用"。
- 引用 = 两元素数组
[base, key];GET_PROP(59)/SET_PROP(14) 负责解引用。 - 栈操作:
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:
- 定位解释器主循环
X[Y[Z++]](),取出操作码处理器数组变量X; - 解析该数组字面量(JS 感知的括号/字符串/正则切分)得到 70 个槽位(含空洞);
- 把每个 handler 源码规范化(去空白 + 局部变量按首次出现重命名
v1,v2,…, 保留 JS 关键字/内建名与属性名)得到与命名无关的"指纹"; - 用内置的 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,定位"某个值在哪段字节码诞生" | ✅ |
| 归档 | 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])。
下一步(不改路线):
- 用 ④ 的 lilted 产物 读 密钥例程(不再"模拟")→ 用 Python 原生重写(§2.1 最后一步);
- 与
extract_xtea_key(Node 神谕)逐字对齐后,替换掉最后一个 Node 依赖; - 把 lift 的栈模型补全(
SWAP/SET_STACK_LEN/REPLACE_TOP_IMM的表达式还原),消除残留的r<?>。