Files
hello_js_reverse_skill/tcaptcha/tdc_opcodes.py
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

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