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)。
This commit is contained in:
yml2213
2026-10-11 22:49:23 +08:00
parent 9286f77661
commit 2e2bbc876a
11 changed files with 1058 additions and 30 deletions
+49 -16
View File
@@ -19,6 +19,11 @@
opcode 表来自对 VM 源码 AST 的推导(`G = [ ... ]` 中第 i 个 handler 即 opcode i,
操作数个数 = handler 体内 `z[H++]` 的出现次数),已固化在下方。
> ⚠️ **固化的表只对 `tcaptcha/core_js/tdc_sample.js` 有效。** 实测 **opcode 编号每份构建都整体重排**
> (同一源码 pc0 的 `SET_STACK_LEN` 在 6 份构建里是 53/19/32/57/55/1)。
> 处理其它构建请先用 `tcaptcha/tdc_opcodes.py: derive_opcode_table(js)` 推导该构建的表,再
> `disassemble_cfg(z, names=…, arity=…, dynamic=…)` 注入。详见 VM_SPEC.md §3.1。
用法:
.venv/bin/python tools/tdc_disasm.py [tdc.js] # 默认 tcaptcha/core_js/tdc_sample.js
"""
@@ -178,21 +183,32 @@ def extract_bytecode(js_text: str) -> List[int]:
# --------------------------------------------------------------------------- #
# 2. 可达性驱动的 CFG 反汇编 (只在可执行地址解码)
# --------------------------------------------------------------------------- #
def _decode_one(z: List[int], pc: int) -> Tuple[Optional[int], List[int], Optional[List[str]], int]:
def _decode_one(z: List[int], pc: int, names: Optional[Dict[int, str]] = None,
arity: Optional[Dict[int, int]] = None,
dynamic: Optional[set] = None) -> Tuple[Optional[int], List[int], Optional[List[str]], int]:
"""解码 pc 处一条指令 -> (opcode, operands, note, next_fallthrough_pc)。
返回值 (None,...) 表示该地址无法构成合法指令。
names/arity/dynamic 允许注入**按构建推导**的操作码表 (见 tcaptcha/tdc_opcodes.py);
不传则用本模块固化的 core_js 表。
"""
if names is None:
names = OP_NAMES
if arity is None:
arity = ARITY
if dynamic is None:
dynamic = DYNAMIC_ARITY
if pc >= len(z):
return None, [], None, pc
op = z[pc]
if op > 69 or op not in OP_NAMES: # 空洞 opcode / 越界 -> 非法
if op > 69 or op not in names: # 空洞 opcode / 越界 -> 非法
return None, [], None, pc + 1
pos = pc + 1
operands: List[int] = []
note: Optional[List[str]] = None
if op in DYNAMIC_ARITY:
if op in dynamic:
# 57: target, capCount, paramCount, [reg, reg]*capCount, [reg]*paramCount
if pos + 3 > len(z):
return None, [], None, pos
@@ -207,47 +223,64 @@ def _decode_one(z: List[int], pc: int) -> Tuple[Optional[int], List[int], Option
params = [f"p{i}->r{operands[2*cap+i]}" for i in range(param)]
note = [f"target=@{target}", f"caps=[{','.join(caps)}]", f"params=[{','.join(params)}]"]
else:
n = ARITY.get(op, 0)
n = arity.get(op, 0)
if pos + n > len(z):
return None, [], None, pos
operands = z[pos:pos + n]
pos += n
if op in (OP_JUMP, OP_JUMP_IF_TRUE) and operands:
if names[op] in ("JUMP", "JUMP_IF_TRUE") and operands:
note = [f"target=@{operands[0]}"]
elif op == OP_PUSH_TRY and len(operands) == 2:
elif names[op] == "PUSH_TRY" and len(operands) == 2:
note = [f"catchTarget=@{operands[0]}", f"catchVarReg=r{operands[1]}"]
return op, operands, note, pos
def disassemble_cfg(z: List[int], entry: int = 0, follow_closures: bool = True) -> List[dict]:
"""从 entry 出发按可达性反汇编; 返回按 pc 排序的指令列表。"""
def disassemble_cfg(z: List[int], entry: int = 0, follow_closures: bool = True,
names: Optional[Dict[int, str]] = None,
arity: Optional[Dict[int, int]] = None,
dynamic: Optional[set] = None) -> List[dict]:
"""从 entry 出发按可达性反汇编; 返回按 pc 排序的指令列表。
names/arity/dynamic 可注入按构建推导的操作码表 (默认 = 本模块固化的 core_js 表)。
"""
if names is None:
names = OP_NAMES
if arity is None:
arity = ARITY
if dynamic is None:
dynamic = DYNAMIC_ARITY
# 控制流相关 opcode 按"语义名"定位, 不写死数值 (opcode 每份构建都重排)
op_jump = next((o for o, n in names.items() if n == "JUMP"), OP_JUMP)
op_jump_if = next((o for o, n in names.items() if n == "JUMP_IF_TRUE"), OP_JUMP_IF_TRUE)
op_push_try = next((o for o, n in names.items() if n == "PUSH_TRY"), OP_PUSH_TRY)
op_closure = next((o for o, n in names.items() if n == "CREATE_CLOSURE"), OP_CREATE_CLOSURE)
visited: Dict[int, dict] = {}
work = [entry]
while work:
pc = work.pop()
if pc in visited or pc >= len(z) or pc < 0:
continue
op, operands, note, nxt = _decode_one(z, pc)
op, operands, note, nxt = _decode_one(z, pc, names, arity, dynamic)
if op is None:
# 非法地址 (落在数据区): 记录但不继续
visited[pc] = {"pc": pc, "op": None, "name": "DATA/INVALID", "operands": [], "note": None}
continue
visited[pc] = {"pc": pc, "op": op, "name": OP_NAMES[op], "operands": operands, "note": note}
if op == OP_JUMP:
visited[pc] = {"pc": pc, "op": op, "name": names[op], "operands": operands, "note": note}
if op == op_jump:
if operands:
work.append(operands[0])
continue # 无条件跳转: 无 fall-through
if op == OP_JUMP_IF_TRUE:
if op == op_jump_if:
if operands:
work.append(operands[0])
work.append(nxt) # 条件跳转: 有 fall-through
continue
if op == OP_PUSH_TRY and operands:
if op == op_push_try and operands:
work.append(operands[0]) # catch 目标可达
if op == OP_CREATE_CLOSURE and follow_closures:
# 57 的操作数 [0] 是闭包体入口: 该段代码在闭包被调用时才执行, 但它是**代码**,
if op == op_closure and follow_closures:
# 操作数 [0] 是闭包体入口: 该段代码在闭包被调用时才执行, 但它是**代码**,
# 必须作为遍历根加入, 否则大半个程序都算不到。
# (提升单个函数时应置 follow_closures=False, 避免子函数体溢进父函数)
work.append(z[pc + 1])
work.append(nxt)
return [visited[k] for k in sorted(visited)]
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
腾讯天御 TDC 混沌 VM —— **纯 Python** XTEA 密钥提取 CLI (零 Node / 零 JS 运行时)。
用法:
.venv/bin/python tools/tdc_key_pure.py [tdc.js] # 默认 core_js 样本
.venv/bin/python tools/tdc_key_pure.py -v har/.../tdc.js # 打印推导过程
.venv/bin/python tools/tdc_key_pure.py --opcodes tdc.js # 顺带打印按构建推导的 opcode 表
实现见 `tcaptcha/tdc_key.py` (定位算法) 与 `tcaptcha/tdc_opcodes.py` (按构建推导操作码表)。
"""
from __future__ import annotations
import argparse
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser(description="纯 Python 从 tdc.js 提取 XTEA 密钥")
ap.add_argument("path", nargs="?", default="tcaptcha/core_js/tdc_sample.js")
ap.add_argument("-v", "--verbose", action="store_true", help="打印密钥构造过程")
ap.add_argument("--opcodes", action="store_true", help="打印按构建推导的 opcode 表")
ap.add_argument("--json", action="store_true", help="以 JSON 输出")
args = ap.parse_args()
js = open(args.path, encoding="utf-8", errors="replace").read()
if args.opcodes:
from tcaptcha.tdc_opcodes import derive_opcode_table
names, arity, dynamic, diag = derive_opcode_table(js)
print("handlers=%d dynamic=%s" % (diag["handler_count"], sorted(dynamic)))
for op in sorted(names):
print(" %3d %-18s arity=%s" % (op, names[op], "dyn" if op in dynamic else arity.get(op, 0)))
print()
report = extract_xtea_key_pure(js, verbose=args.verbose)
if args.json:
print(json.dumps(report, ensure_ascii=False))
else:
print("base = %s" % report["base"])
print("salts = %s" % report["salts"])
print("key = %s" % report["key"])
return 0
if __name__ == "__main__":
raise SystemExit(main())