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

442 lines
17 KiB
Python

# -*- coding: utf-8 -*-
"""
腾讯天御 TDC 混沌 VM (Chaos VM) —— XTEA 密钥的**纯 Python** 静态还原器。
为什么需要它
------------
此前要拿到某一版 tdc.js 的 XTEA 密钥, 只能把 tdc.js 丢进 Node 插桩跑一遍
(ADDU opcode 吐密钥字)。密钥**不是字面量**, 而是 VM 运行时由字节码算出来的,
所以必须"读指令流"而不是"搜字符串"。
本模块完全离线(零 Node、零 JS 运行时), 基于**正确反汇编**(tools/tdc_disasm.py)做
**模式 + 数据流**定位, 复刻密钥构造算法。全部结论都有运行时神谕(tools/tdc_probe.js)
逐条对照过, 见 har/1ms_dianxuan/PROGRESS.md。
算法 (已证: 纯 Python 结果 == Node extract_xtea_key 逐字一致)
------------------------------------------------------------
1. **4 个密钥字**: 每个字由一个专门的闭包算出:
word_i = Σ_{k=0..3} ( (ord(src_i[k]) + off_i) & 0xFF ) << (8*k)
源串 `src_i` 是紧跟在该闭包 `CREATE_CLOSURE` 之后的 4 个 `PUSH_STR_CHAR`
字面量; 偏移 `off_i` 是打包点的立即数; 存入下标 `i` 由闭包尾部的
`PUSH_IMM_ARRAY / PUSH_IMM [i] / ... / SET_PROP` 给出。
2. **哪些闭包是真密钥**: 密钥数组会被 **XTEA 闭包捕获**。
XTEA 轮里出现 `delta = 0x9E3779B9`; 含 delta 的那个闭包体所属的
`CREATE_CLOSURE` 即 XTEA 闭包, 其 `caps=[r7=r<K>]` 的 `<K>` 就是密钥数组所在的
父帧寄存器。**只有 caps 右侧等于该寄存器的密钥字闭包**才是真密钥
(同一份脚本往往还并行算了第二个"诱饵"数组, 用不同寄存器, 必须排除)。
3. **轮加盐**: XTEA 轮内 `key[(sum>>>11)&3]` 查表之后会**条件性**加上一个立即数
(salt): `GET_PROP / PUSH_IMM <salt> / ADD / ADD / XOR`。加给哪个下标由守卫条件
`idx == ((sum>>>11)&3)` / `idx == (sum & 3)` 里的 `idx` 常量决定。
有效密钥 = base 字按盐的下标位置相加。
所有定位都是**模式 + 立即数**, 不写死任何 pc (每份构建都会重排)。
"""
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: # 允许在任意 cwd 下 import tools.*
sys.path.insert(0, _ROOT)
from tools.tdc_disasm import extract_bytecode, disassemble_cfg # noqa: E402
from tcaptcha.tdc_opcodes import derive_opcode_table # noqa: E402
M32 = 0xFFFFFFFF
XTEA_DELTA = 0x9E3779B9 # 2654435769, XTEA 黄金比常数
KEY_LEN = 4
# 走"下标表达式"时要跳过的算子 (它们构成 `(sum & 3)` / `(sum>>>11)&3`)
_SKIP_OPS = {
"BITWISE_AND", "SHR_UNSIGNED", "LOAD_REG_IMM", "MAKE_REG_PROP_REF",
"GET_PROP", "SWAP", "STORE_REG", "ADD", "DUP", "LOAD_REG_INDIRECT",
"PUSH_IMM_ARRAY", "POP",
}
# 这些算子紧跟在"掩码 PUSH_IMM 3"之前, 说明那个 3 是掩码而不是下标
_MASK_NEIGHBOURS = {"BITWISE_AND", "LOAD_REG_IMM", "SHR_UNSIGNED"}
class _Dis:
"""对反汇编结果做少量索引封装, 便于按 pc 前后游走。"""
def __init__(self, js_text: str) -> None:
self.z = extract_bytecode(js_text)
# opcode 编号每份构建都重排 -> 必须先按构建把语义表推导出来
names, arity, dynamic, diag = derive_opcode_table(js_text)
self.names, self.arity, self.dynamic, self.diag = names, arity, dynamic, diag
self.insts = disassemble_cfg(self.z, names=names, arity=arity, dynamic=dynamic)
self.by_pc: Dict[int, dict] = {i["pc"]: i for i in self.insts}
self.pcs: List[int] = sorted(self.by_pc)
self._idx: Dict[int, int] = {pc: n for n, pc in enumerate(self.pcs)}
def op(self, pc: Optional[int]) -> Optional[str]:
i = self.by_pc.get(pc) if pc is not None else None
return i["name"] if i else None
def ops(self, pc: Optional[int]) -> List[int]:
i = self.by_pc.get(pc) if pc is not None else None
return list(i["operands"]) if i else []
def note(self, pc: int) -> str:
return " ".join(self.by_pc[pc].get("note") or [])
def nxt(self, pc: Optional[int]) -> Optional[int]:
n = self._idx.get(pc) if pc is not None else None
return self.pcs[n + 1] if n is not None and n + 1 < len(self.pcs) else None
def prev(self, pc: Optional[int]) -> Optional[int]:
n = self._idx.get(pc) if pc is not None else None
return self.pcs[n - 1] if n is not None and n > 0 else None
def in_range(self, lo: int, hi: int) -> List[int]:
import bisect
a = bisect.bisect_left(self.pcs, lo)
b = bisect.bisect_right(self.pcs, hi)
return self.pcs[a:b]
# --------------------------------------------------------------------------- #
# 1. 闭包清单 / 密钥字打包点
# --------------------------------------------------------------------------- #
def _closures(d: _Dis) -> List[dict]:
"""所有 CREATE_CLOSURE -> {pc, target, caps:{dst:src}, str_after}。"""
out: List[dict] = []
for i in d.insts:
if i["name"] != "CREATE_CLOSURE":
continue
pc = i["pc"]
note = " ".join(i["note"] or [])
m = re.search(r"target=@(\d+)", note)
if not m:
continue
caps: Dict[int, int] = {}
caps_part = re.search(r"caps=\[([^\]]*)\]", note)
if caps_part:
for dst, src in re.findall(r"r(\d+)=r(\d+)", caps_part.group(1)):
caps[int(dst)] = int(src)
out.append({"pc": pc, "target": int(m.group(1)), "caps": caps,
"str_after": _string_after(d, pc)})
return out
def _string_after(d: _Dis, pc: int) -> Optional[str]:
"""CREATE_CLOSURE 紧随其后的 `PUSH_EMPTY_STR + PUSH_STR_CHAR*` 字面量。"""
p = d.nxt(pc)
if p is None or d.op(p) != "PUSH_EMPTY_STR":
return None
chars: List[str] = []
q = d.nxt(p)
while q is not None and d.op(q) == "PUSH_STR_CHAR":
chars.append(chr(d.ops(q)[0]))
q = d.nxt(q)
return "".join(chars)
def _pack_sites(d: _Dis, lo: int, hi: int) -> List[dict]:
"""
在 [lo,hi] 里找"密钥字打包点":
... CALL_METHOD[1] / PUSH_IMM c / SUB (off = -c)
或 ... CALL_METHOD[1] / PUSH_IMM 0 / PUSH_IMM c / SUB / SUB (off = +c)
/ PUSH_IMM 8 / LOAD_REG_IMM / MUL / SHL / BITWISE_OR
返回 [{or_pc, off}]。
"""
res: List[dict] = []
for pc in d.in_range(lo, hi):
if d.op(pc) != "PUSH_IMM" or d.ops(pc) != [8]:
continue
a, b, c, e = d.nxt(pc), None, None, None
b = d.nxt(a)
c = d.nxt(b)
e = d.nxt(c)
if not (d.op(a) == "LOAD_REG_IMM" and d.op(b) == "MUL"
and d.op(c) == "SHL" and d.op(e) == "BITWISE_OR"):
continue
off = _decode_offset(d, pc)
if off is not None:
res.append({"or_pc": e, "imm8_pc": pc, "off": off})
return res
def _decode_offset(d: _Dis, imm8_pc: int) -> Optional[int]:
"""解出紧跟 CALL_METHOD 之后的偏移立即数 (含两种编码)。"""
p1 = d.prev(imm8_pc) # 最后一个 SUB
if d.op(p1) != "SUB":
return None
p2 = d.prev(p1)
if d.op(p2) == "PUSH_IMM": # X - c => off = -c
return -d.ops(p2)[0]
if d.op(p2) == "SUB": # X - (0 - c) => off = +c
p3 = d.prev(p2)
p4 = d.prev(p3)
if d.op(p3) == "PUSH_IMM" and d.op(p4) == "PUSH_IMM" and d.ops(p4) == [0]:
return d.ops(p3)[0]
return None
def _store_index(d: _Dis, lo: int, hi: int) -> Optional[int]:
"""闭包尾部的 `PUSH_IMM_ARRAY / PUSH_IMM [i] / MAKE_REG_PROP_REF / LOAD_REG_IMM / SET_PROP`。"""
hits: List[int] = []
for pc in d.in_range(lo, hi):
if d.op(pc) != "PUSH_IMM_ARRAY":
continue
a, b, c, e = d.nxt(pc), None, None, None
b = d.nxt(a)
c = d.nxt(b)
e = d.nxt(c)
if (d.op(a) == "PUSH_IMM" and d.op(b) == "MAKE_REG_PROP_REF"
and d.op(c) == "LOAD_REG_IMM" and d.op(e) == "SET_PROP"):
hits.append(d.ops(a)[0])
return hits[-1] if hits else None
# --------------------------------------------------------------------------- #
# 2. XTEA 闭包定位 + 轮加盐
# --------------------------------------------------------------------------- #
def _find_xtea_closure(d: _Dis, closures: List[dict]) -> Optional[dict]:
"""含 XTEA 常数(delta)的那个闭包体所属的 CREATE_CLOSURE。"""
delta_pcs = [pc for pc in d.pcs
if d.op(pc) == "PUSH_IMM" and d.ops(pc)[0] in (XTEA_DELTA, XTEA_DELTA * 32)]
# 取"最内层"(区间最小)的闭包体 —— 外层 init 函数的体也会横跨 delta,
# 但它不捕获密钥数组。
best: Optional[dict] = None
best_end: Optional[int] = None
for cl in closures:
end = _body_end(d, cl["pc"])
if end is None or end < cl["target"]:
continue
if any(cl["target"] <= pc <= end for pc in delta_pcs):
if best is None or best_end is None or (end - cl["target"]) < (best_end - best["target"]):
best, best_end = cl, end
return best
def _largest_reg(regs: Dict[int, int]) -> Optional[int]:
return max(regs.values()) if regs else None
def _caps_single(caps: Dict[int, int]) -> Optional[Tuple[int, int]]:
"""字/XTEA 闭包只捕获一个数组: 返回 (dst, src)。"""
if len(caps) != 1:
return None
(dst, src), = caps.items()
return dst, src
def _defining_closure(d: _Dis, closures: List[dict], pc: int) -> Optional[dict]:
"""含给定 pc 的**最内层**闭包(即创建 pc 处那条 CREATE_CLOSURE 的那个函数)。"""
best: Optional[dict] = None
best_span: Optional[int] = None
for c in closures:
e = _body_end(d, c["pc"])
if e is None or e < c["target"]:
continue
if c["target"] <= pc <= e:
span = e - c["target"]
if best_span is None or span < best_span:
best, best_span = c, span
return best
def _body_end(d: _Dis, cl_pc: int) -> Optional[int]:
"""
闭包体结束 pc (该体的 RET_TRUE)。约定: 闭包被创建后要么紧跟其体尾的 RET_TRUE,
要么中间夹一条 `PUSH_IMM_ARRAY`(闭包被塞进数组时)。
"""
e = d.prev(cl_pc)
if e is not None and d.op(e) == "PUSH_IMM_ARRAY":
e = d.prev(e)
return e if (e is not None and d.op(e) == "RET_TRUE") else None
def _guard_index(d: _Dis, eq_pc: int) -> Optional[int]:
"""
守卫 `idx == ((sum & 3) | ((sum>>>11)&3))` 里的常量 idx。
从 EQ_LOOSE 反向游走, 跳过下标表达式自身的算子, 第一个落在 0..3 的立即数即 idx。
掩码 3 靠"它的后继是 BITWISE_AND / LOAD_REG_IMM / SHR_UNSIGNED"识别并跳过。
"""
pc = d.prev(eq_pc)
last: Optional[str] = None
for _ in range(24):
if pc is None:
return None
nm, ops = d.op(pc), d.ops(pc)
if nm in ("PUSH_IMM", "REPLACE_TOP_IMM") and ops:
v = ops[0]
if 0 <= v <= 3:
if not (nm == "PUSH_IMM" and v == 3 and last in _MASK_NEIGHBOURS):
return v
elif nm not in _SKIP_OPS:
# 出现意料之外的算子: 继续向前找, 但记录上下文
pass
last = nm
pc = d.prev(pc)
return None
def _salts(d: _Dis, lo: int, hi: int) -> List[Tuple[int, int]]:
"""
XTEA 轮内的条件加盐: `GET_PROP / PUSH_IMM <salt> / ADD / ADD / XOR`。
返回 [(idx, salt)] (按 (idx,salt) 去重)。
"""
salt_pcs: List[int] = []
for pc in d.in_range(lo, hi):
if d.op(pc) != "PUSH_IMM":
continue
if d.op(d.prev(pc)) != "GET_PROP" or d.op(d.nxt(pc)) != "ADD":
continue
salt_pcs.append(pc)
if not salt_pcs:
return []
# 收集所有 (JUMP_IF_TRUE, EQ_LOOSE) 守卫; block(T) 到 T 之后第一条无条件 JUMP
guards: List[Tuple[int, int, Optional[int]]] = [] # (target, jump_pc, idx)
for pc in d.in_range(lo, hi):
if d.op(pc) != "JUMP_IF_TRUE":
continue
tgt_ops = d.ops(pc)
if not tgt_ops:
continue
tgt = tgt_ops[0]
eq = d.prev(pc)
if d.op(eq) != "EQ_LOOSE":
continue
idx = _guard_index(d, eq)
if idx is not None:
guards.append((tgt, pc, idx))
def block_end(tgt: int) -> int:
p = tgt
while p is not None and p <= hi:
if d.op(p) == "JUMP":
return p
p = d.nxt(p)
return hi
out: List[Tuple[int, int]] = []
for spc in salt_pcs:
salt = d.ops(spc)[0]
best: Optional[Tuple[int, int]] = None # (target, idx)
for tgt, _j, idx in guards:
if tgt <= spc < block_end(tgt):
if best is None or tgt > best[0]:
best = (tgt, idx)
if best is not None:
out.append((best[1], salt))
# 去重保序
seen = set()
uniq: List[Tuple[int, int]] = []
for x in out:
if x not in seen:
seen.add(x)
uniq.append(x)
return uniq
# --------------------------------------------------------------------------- #
# 3. 主入口
# --------------------------------------------------------------------------- #
def word_from_chars(src: str, off: int) -> int:
"""word = Σ ((ord(c) + off) & 0xFF) << (8*k) (小端打包)"""
w = 0
for k, ch in enumerate(src):
w |= ((ord(ch) + off) & 0xFF) << (8 * k)
return w & M32
def extract_xtea_key_pure(js_text: str, verbose: bool = False):
"""
纯 Python 从 tdc.js 里还原本次构建的 XTEA 有效密钥 (4 个 32 位字)。
返回 dict:
base : 4 个 base 字 (未折盐)
salts : [(idx, salt), ...]
key : 有效密钥
words : [(idx, word, src, off), ...] (便于诊断)
"""
d = _Dis(js_text)
closures = _closures(d)
xtea = _find_xtea_closure(d, closures)
if xtea is None:
raise ValueError("未找到 XTEA 闭包 (字节码里没有 delta 常数?)")
xs = _caps_single(xtea["caps"])
if xs is None:
raise ValueError("XTEA 闭包没有唯一捕获的数组, 无法确定密钥数组")
xtea_src = xs[1] # XTEA 所在帧里, 密钥数组所在的寄存器
# 密钥数组是"被 XTEA 闭包捕获"的那一个。字闭包与 XTEA 闭包往往不在同一帧:
# 父帧 G: r<R> = 密钥数组
# ├─ 字闭包 W: caps=[r7 = 本帧 r<Wsrc>] (Wsrc ∈ {R, 诱饵寄存器})
# └─ XTEA 闭包 : caps=[r7 = 本帧 r<xtea_src>]
# 所以要把 W 的捕获穿一层: B = 定义 W 的那个闭包(B 帧 -> G 帧), 检查
# B.caps[Wsrc] == xtea_src -> 该字闭包写的就是真密钥数组。
words: Dict[int, Tuple[int, str, int]] = {}
picked: List[str] = []
for cl in closures:
if cl["str_after"] is None:
continue
cs = _caps_single(cl["caps"])
if cs is None:
continue
w_src = cs[1] # 该字闭包捕获的、定义帧里的寄存器
definer = _defining_closure(d, closures, cl["pc"])
if definer is not None and definer["caps"]:
real = definer["caps"].get(w_src) == xtea_src
else: # 同帧: 直接比
real = (w_src == xtea_src)
if not real:
continue
end = _body_end(d, cl["pc"])
if end is None or end < cl["target"]:
continue
packs = _pack_sites(d, cl["target"], end)
if not packs:
continue
idx = _store_index(d, cl["target"], end)
if idx is None or not (0 <= idx < KEY_LEN):
continue
src, off = cl["str_after"], packs[-1]["off"]
words[idx] = (word_from_chars(src, off), src, off)
picked.append("%s(idx=%d,off=%+d)" % (src, idx, off))
if len(words) != KEY_LEN:
raise ValueError(
"密钥字不完整: 只定位到 %s (期望 0..3); XTEA 密钥帧寄存器 r%s"
% (sorted(words), xtea_src))
base = [words[i][0] for i in range(KEY_LEN)]
salts = _salts(d, xtea["target"], d.prev(xtea["pc"]) or xtea["target"])
key = list(base)
for idx, salt in salts:
key[idx] = (key[idx] + salt) & M32
report = {
"base": base,
"salts": salts,
"key": key,
"words": [(i, words[i][0], words[i][1], words[i][2]) for i in range(KEY_LEN)],
"xtea_closure_pc": xtea["pc"],
"key_reg": xtea_src,
}
if verbose:
print("[tdc_key_pure] XTEA closure @%d key array = r%s" % (xtea["pc"], xtea_src))
for i, w, s, o in report["words"]:
print(" word[%d] <- %-6r off=%+d = %d" % (i, s, o, w))
for idx, salt in salts:
print(" salt +%d -> key[%d]" % (salt, idx))
print(" base = %s" % base)
print(" key = %s" % key)
return report
def extract_xtea_key(js_text: str) -> List[int]:
"""便捷入口: 只取有效密钥 (与 crypto.extract_xtea_key 同口径)。"""
return list(extract_xtea_key_pure(js_text)["key"])