做了什么 - 新增 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)。
321 lines
13 KiB
Python
321 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
腾讯天御 TDC 混沌 VM (Chaos VM) —— **按构建推导操作码表**。
|
|
|
|
为什么需要它
|
|
------------
|
|
`tools/tdc_disasm.py` 里固化的 opcode 表取自 **core_js 那一份构建**。
|
|
实测(5 份构建交叉验证): **opcode 编号每份构建都会整体重排** ——
|
|
同一份源码编译出的字节码, pc0 处的 `SET_STACK_LEN` 在 core_js 是 53,
|
|
在 root 样本是 19, 在 1ms 实时样本分别是 18 / 10 / 60。
|
|
所以写死 opcode 表 = 换一份脚本就全错。
|
|
|
|
本模块的做法:
|
|
1. 从 JS 源码里定位解释器主循环 `X[Y[Z++]]()` -> 取出**操作码处理器数组变量** `X`;
|
|
2. 解析该数组字面量(JS 感知的括号/字符串/正则切分)得到 70 个槽位(含空洞);
|
|
3. 把每个 handler 的源码**规范化**(去空白 + 局部变量按首次出现重命名为 v1,v2,...,
|
|
保留 JS 关键字/内建名与属性名)得到与变量命名无关的"指纹";
|
|
4. 用内置的 58 条参考指纹(取自 core_js = `tools/tdc_disasm.OP_NAMES` 那套语义)
|
|
反查每个槽位的语义名 —— 数组下标即该构建的 opcode。
|
|
5. 操作数个数按语义名从参考表取 (与"handler 体内 `x[y++]` 读取次数"实测一致)。
|
|
|
|
这样, 只要服务端还是这台 VM(handler 语义相同), 无论 opcode 怎么重排都能对上。
|
|
```
|
|
经过: 5/5 本构建全部 58/58 handler 精确匹配 (har/1ms_dianxuan/PROGRESS.md)。
|
|
```
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
if _ROOT not in sys.path:
|
|
sys.path.insert(0, _ROOT)
|
|
|
|
from tools.tdc_disasm import ARITY as _CORE_ARITY, DYNAMIC_ARITY as _CORE_DYNAMIC, OP_NAMES as _CORE_NAMES # noqa: E402
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 参考指纹: core_js 构建的 58 个 handler 规范化源码 -> 语义名 (与 OP_NAMES 一致)
|
|
# --------------------------------------------------------------------------- #
|
|
_REF_BLOB = r"""PUSH_STR_CHAR function(){v1[v1.length-1]+=String.fromCharCode(v2[v3++])}
|
|
IS_EXCEPTION function(){return!!v1}
|
|
DUP function(){v1.push(v1[v1.length-1])}
|
|
XOR function(){v1[v1.length-2]=v1[v1.length-2]^v1.pop()}
|
|
EQ_STRICT function(){v1[v1.length-2]=v1[v1.length-2]===v1.pop()}
|
|
MOD function(){v1[v1.length-2]=v1[v1.length-2]%v1.pop()}
|
|
CALL_FUNC function(){varv1=v2[v3++],v4=v1?v5.slice(-v1):[];v5.length-=v1,v5.push(v5.pop().apply(v6,v4))}
|
|
GET_GLOBAL_PROP function(){v1[v1.length-1]=v2[v1[v1.length-1]]}
|
|
MAKE_PROP_REF function(){varv1=v2.pop(),v3=v2.pop();v2.push([v3[0][v3[1]],v1])}
|
|
TYPEOF function(){v1.push(typeofv1.pop())}
|
|
DELETE function(){varv1=v2.pop();v2.push(deletev1[0][v1[1]])}
|
|
LOGICAL_NOT function(){v1.push(!v1.pop())}
|
|
EQ_LOOSE function(){v1[v1.length-2]=v1[v1.length-2]==v1.pop()}
|
|
SET_PROP function(){varv1=v2[v2.length-2];v1[0][v1[1]]=v2[v2.length-1]}
|
|
GREATER_THAN function(){v1[v1.length-2]=v1[v1.length-2]>v1.pop()}
|
|
SWAP function(){varv1=v2[v3++],v4=v5[v5.length-2-v1];v5[v5.length-2-v1]=v5.pop(),v5.push(v4)}
|
|
IN function(){v1[v1.length-2]=v1[v1.length-2]inv1.pop()}
|
|
RET_TRUE function(){return!0}
|
|
GREATER_EQ function(){v1[v1.length-2]=v1[v1.length-2]>=v1.pop()}
|
|
PUSH_IMM function(){v1.push(v2[v3++])}
|
|
MUL function(){v1[v1.length-2]=v1[v1.length-2]*v1.pop()}
|
|
LOAD_REG_INDIRECT function(){v1.push(v1[v1.pop()[0]][0])}
|
|
ITER_NEXT function(){v1[v1.length-1].length?v1.push(v1[v1.length-1].shift(),!0):v1.push(undefined,!1)}
|
|
BIND_THIS function(){v1.push([v2,v1.pop()])}
|
|
PUSH_NULL function(){v1.push(null)}
|
|
BITWISE_AND function(){v1[v1.length-2]=v1[v1.length-2]&v1.pop()}
|
|
PUSH_IMM_ARRAY function(){v1.push([v2[v3++]])}
|
|
MAKE_REG_PROP_REF function(){varv1=v2.pop();v2.push([v2[v2.pop()][0],v1])}
|
|
SUB function(){v1[v1.length-2]=v1[v1.length-2]-v1.pop()}
|
|
CALL_METHOD function(){varv1=v2[v3++],v4=v1?v5.slice(-v1):[],v1=(v5.length-=v1,v5.pop());v5.push(v1[0][v1[1]].apply(v1[0],v4))}
|
|
SHR function(){v1[v1.length-2]=v1[v1.length-2]>>v1.pop()}
|
|
SHL function(){v1[v1.length-2]=v1[v1.length-2]<<v1.pop()}
|
|
JUMP_IF_TRUE function(){varv1=v2[v3++];v4[v4.length-1]&&(v3=v1)}
|
|
FOR_IN_KEYS function(){varv1,v2=[];for(v1inv3.pop())v2.push(v1);v3.push(v2)}
|
|
ADD function(){v1[v1.length-2]=v1[v1.length-2]+v1.pop()}
|
|
NEW_METHOD function(){varv1=v2[v3++],v4=v1?v5.slice(-v1):[],v1=(v5.length-=v1,v4.unshift(null),v5.pop());v5.push(v6(v1[0][v1[1]],v4))}
|
|
PUSH_UNDEFINED function(){v1.push(undefined)}
|
|
PUSH_FALSE function(){v1.push(!1)}
|
|
STORE_REG function(){v1[v1[v1.length-2][0]][0]=v1[v1.length-1]}
|
|
ALLOC_REG function(){varv1=v2[v3++];v4[v1]=v4[v1]===undefined?[]:v4[v1]}
|
|
POP_TRY function(){v1.pop()}
|
|
THROW function(){throwv1[v1.length-1]}
|
|
POP function(){v1.pop()}
|
|
DIV function(){v1[v1.length-2]=v1[v1.length-2]/v1.pop()}
|
|
SET_STACK_LEN function(){v1.length=v2[v3++]}
|
|
REVERSE_TOP2 function(){v1.push([v1.pop(),v1.pop()].reverse())}
|
|
PUSH_EMPTY_STR function(){v1.push("")}
|
|
PUSH_TRY function(){v1.push([v2[v3++],v4.length,v2[v3++]])}
|
|
CREATE_CLOSURE function(){for(varv1=v2[v3++],v4=[],v5=v2[v3++],v6=v2[v3++],v7=[],v8=0;v8<v5;v8++)v4[v2[v3++]]=v9[v2[v3++]];for(v8=0;v8<v6;v8++)v7[v8]=v2[v3++];v9.push(functionv10(){varv5=v4.slice(0);v5[0]=[this],v5[1]=[arguments],v5[2]=[v10];for(varv6=0;v6<v7.length&&v6<arguments.length;v6++)0<v7[v6]&&(v5[v7[v6]]=[arguments[v6]]);returnv11(v1,v2,v12,v5,v13,v14,v15,v16)})}
|
|
GET_PROP function(){varv1=v2.pop();v2.push(v1[0][v1[1]])}
|
|
JUMP function(){v1=v2[v1++]}
|
|
NEW_FUNC function(){varv1=v2[v3++],v4=v1?v5.slice(-v1):[];v5.length-=v1,v4.unshift(null),v5.push(v6(v5.pop(),v4))}
|
|
BITWISE_OR function(){v1[v1.length-2]=v1[v1.length-2]|v1.pop()}
|
|
REPLACE_TOP_IMM function(){v1[v1.length-1]=v2[v3++]}
|
|
SHR_UNSIGNED function(){v1[v1.length-2]=v1[v1.length-2]>>>v1.pop()}
|
|
PUSH_TRUE function(){v1.push(!0)}
|
|
LOAD_REG_IMM function(){v1.push(v1[v2[v3++]][0])}
|
|
CLEAR_EXCEPTION function(){v1=null}"""
|
|
|
|
|
|
def _reference() -> Dict[str, str]:
|
|
out: Dict[str, str] = {}
|
|
for line in _REF_BLOB.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
name, _, fp = line.partition("\t")
|
|
out[fp] = name
|
|
return out
|
|
|
|
|
|
_REF_FP2NAME = _reference()
|
|
# 语义名 -> 操作数个数 / 是否动态长度
|
|
_NAME_ARITY: Dict[str, int] = {}
|
|
for _op, _nm in _CORE_NAMES.items():
|
|
_NAME_ARITY[_nm] = _CORE_ARITY.get(_op, 0)
|
|
_NAME_DYNAMIC = {_CORE_NAMES[o] for o in _CORE_DYNAMIC}
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# JS 感知的工具
|
|
# --------------------------------------------------------------------------- #
|
|
_JS_KEEP = set("""
|
|
function var return new if else for while do typeof instanceof in of delete void this
|
|
null true false undefined break continue switch case default throw try catch finally
|
|
Object String Number Boolean Array Function RegExp Date Math JSON Error TypeError
|
|
Symbol parseInt parseFloat isNaN encodeURIComponent decodeURIComponent
|
|
Uint8Array Uint8ClampedArray Infinity NaN arguments
|
|
""".split())
|
|
_ID_RE = re.compile(r"[A-Za-z_$][\w$]*")
|
|
_PREV_REGEX_OK = set("(,=[{;:!&|?+-*%~^<>")
|
|
_OPERAND_READ_RE = re.compile(r"[A-Za-z_$][\w$]*\s*\[\s*[A-Za-z_$][\w$]*\s*\+\+\s*\]")
|
|
|
|
|
|
def _skip_js_string(js: str, i: int) -> int:
|
|
q = js[i]
|
|
i += 1
|
|
while i < len(js):
|
|
c = js[i]
|
|
if c == "\\":
|
|
i += 2
|
|
continue
|
|
if c == q:
|
|
return i + 1
|
|
i += 1
|
|
return i
|
|
|
|
|
|
def _skip_js_regex(js: str, i: int) -> int:
|
|
i += 1
|
|
while i < len(js):
|
|
c = js[i]
|
|
if c == "\\":
|
|
i += 2
|
|
continue
|
|
if c == "[":
|
|
i += 1
|
|
while i < len(js) and js[i] != "]":
|
|
if js[i] == "\\":
|
|
i += 1
|
|
i += 1
|
|
i += 1
|
|
continue
|
|
if c == "/":
|
|
i += 1
|
|
while i < len(js) and js[i].isalpha():
|
|
i += 1
|
|
return i
|
|
i += 1
|
|
return i
|
|
|
|
|
|
def split_js_array(js: str, lb: int) -> Tuple[List[str], int]:
|
|
"""把从 `lb`(指向 '[')起的数组字面量切成顶层元素字符串。"""
|
|
assert js[lb] == "["
|
|
i, depth, start, out = lb + 1, 0, lb + 1, []
|
|
while i < len(js):
|
|
c = js[i]
|
|
if c in "\"'`":
|
|
i = _skip_js_string(js, i)
|
|
continue
|
|
if c == "/":
|
|
j = i - 1
|
|
while j >= 0 and js[j].isspace():
|
|
j -= 1
|
|
if j < 0 or js[j] in _PREV_REGEX_OK:
|
|
i = _skip_js_regex(js, i)
|
|
continue
|
|
i += 1
|
|
continue
|
|
if c in "([{":
|
|
depth += 1
|
|
i += 1
|
|
continue
|
|
if c in ")]}":
|
|
if c == "]":
|
|
if depth == 0:
|
|
out.append(js[start:i])
|
|
return out, i
|
|
depth -= 1
|
|
else:
|
|
depth -= 1
|
|
i += 1
|
|
continue
|
|
if c == "," and depth == 0:
|
|
out.append(js[start:i])
|
|
start = i + 1
|
|
i += 1
|
|
continue
|
|
i += 1
|
|
raise ValueError("数组字面量未闭合")
|
|
|
|
|
|
def handler_array(js: str) -> List[str]:
|
|
"""
|
|
定位解释器主循环 `ARR[BC[PC++]]()`, 返回 `ARR` 的字面量元素(每个 = 一个 handler 源码)。
|
|
含空洞的槽位返回空串。
|
|
"""
|
|
m = re.search(r"([A-Za-z_$][\w$]*)\s*\[\s*[A-Za-z_$][\w$]*\s*\[\s*[A-Za-z_$][\w$]*\s*\+\+\s*\]\s*\]\s*\(\s*\)", js)
|
|
if not m:
|
|
raise ValueError("未找到 VM 解释器主循环 (X[Y[Z++]]())")
|
|
arr = m.group(1)
|
|
best: Optional[List[str]] = None
|
|
for mm in re.finditer(re.escape(arr) + r"\s*=\s*\[", js):
|
|
lb = js.index("[", mm.start())
|
|
try:
|
|
els, _ = split_js_array(js, lb)
|
|
except Exception:
|
|
continue
|
|
ne = sum(1 for e in els if e.strip())
|
|
if best is None or ne > sum(1 for e in best if e.strip()):
|
|
best = els
|
|
if best is None:
|
|
raise ValueError("未找到操作码处理器数组字面量 %s=[...]" % arr)
|
|
return best
|
|
|
|
|
|
def canonicalize(src: str) -> str:
|
|
"""把 handler 源码规范化成与变量命名无关的指纹。"""
|
|
out: List[str] = []
|
|
mp: Dict[str, str] = {}
|
|
n = 0
|
|
i = 0
|
|
prev = ""
|
|
while i < len(src):
|
|
c = src[i]
|
|
if c in "\"'`":
|
|
j = _skip_js_string(src, i)
|
|
out.append(src[i:j])
|
|
i, prev = j, "'"
|
|
continue
|
|
m = _ID_RE.match(src, i)
|
|
if m:
|
|
tok = m.group(0)
|
|
if prev == "." or tok in _JS_KEEP:
|
|
out.append(tok)
|
|
else:
|
|
if tok not in mp:
|
|
n += 1
|
|
mp[tok] = "v%d" % n
|
|
out.append(mp[tok])
|
|
i, prev = m.end(), "i"
|
|
continue
|
|
if not c.isspace():
|
|
out.append(c)
|
|
prev = c
|
|
i += 1
|
|
return "".join(out)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 对外: 按构建推导操作码表
|
|
# --------------------------------------------------------------------------- #
|
|
def derive_opcode_table(js: str) -> Tuple[Dict[int, str], Dict[int, int], set, Dict[str, str]]:
|
|
"""
|
|
返回 (names, arity, dynamic, diag):
|
|
names : {opcode: 语义名}
|
|
arity : {opcode: 固定操作数个数} (动态长度的不在其中)
|
|
dynamic : {opcode} 需要读操作数才能定长
|
|
diag : 诊断信息
|
|
"""
|
|
els = handler_array(js)
|
|
names: Dict[int, str] = {}
|
|
arity: Dict[int, int] = {}
|
|
dynamic: set = set()
|
|
unknown: List[int] = []
|
|
slots: List[Tuple[int, str]] = []
|
|
for op, src in enumerate(els):
|
|
if not src.strip():
|
|
continue
|
|
fp = canonicalize(src)
|
|
nm = _REF_FP2NAME.get(fp)
|
|
if nm is None:
|
|
unknown.append(op)
|
|
continue
|
|
names[op] = nm
|
|
if nm in _NAME_DYNAMIC:
|
|
dynamic.add(op)
|
|
else:
|
|
arity[op] = _NAME_ARITY.get(nm, 0)
|
|
slots.append((op, nm))
|
|
diag = {
|
|
"handler_count": len(names),
|
|
"unknown_slots": unknown,
|
|
"table": {op: nm for op, nm in slots},
|
|
}
|
|
if unknown:
|
|
raise ValueError("有 handler 指纹未能识别(服务端改了 VM 语义?): %s" % unknown[:10])
|
|
if len(names) < 40:
|
|
raise ValueError("推导出的 opcode 表过小(%d), 疑似解析失败" % len(names))
|
|
return names, arity, dynamic, diag
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_js = open(sys.argv[1] if len(sys.argv) > 1 else "tcaptcha/core_js/tdc_sample.js",
|
|
encoding="utf-8", errors="replace").read()
|
|
_names, _arity, _dyn, _diag = derive_opcode_table(_js)
|
|
print("handlers: %d dynamic: %s" % (_diag["handler_count"], sorted(_dyn)))
|
|
for _op in sorted(_names):
|
|
print(" %3d %-18s arity=%s" % (_op, _names[_op], "dyn" if _op in _dyn else _arity.get(_op, 0)))
|