# -*- coding: utf-8 -*- """ 腾讯天御 TDC 混沌 VM (Chaos VM) —— **按构建推导操作码表**。 为什么需要它 ------------ `telemetry/disasm.py` 里固化的 opcode 表取自**内置样本 tcaptcha/tdc_sample.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 条参考指纹(取自内置样本 = `telemetry.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 tcaptcha.telemetry.disasm import ARITY as _CORE_ARITY, DYNAMIC_ARITY as _CORE_DYNAMIC, OP_NAMES as _CORE_NAMES # noqa: E402 # --------------------------------------------------------------------------- # # 参考指纹: 内置样本构建的 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()} 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/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)))