Files
yml2213 344eea2194 refactor(结构): 分层重组 + 清除全部废弃路线
做了什么:
- 包改为分层: tcaptcha/{protocol,telemetry,solvers,ocr,adapters,models,tests}
  * protocol/  client.py  transport.py  proxy.py
  * telemetry/ crypto.py  values.py  key.py  opcodes.py
               disasm.py  program.py  layout.py  modules.py
  * solvers/   text_click.py  icon_click.py  icon_click_rotate.py  slider.py
  * ocr/       engines.py  slicer.py
  * adapters/  one_ms.py
- tools/ 里原本是**包运行时依赖**的 tdc_{disasm,program,layout,modules}.py 并入 telemetry/
  (放在 tools/ 属于分层倒置),tools/ 只留 CLI: tdc_key_pure.py / tdc_profile.py / cdp_*.js
- crypto.py 529 -> 340 行: 切除「成功样本模板回填」与「Node 插桩兜底」两条老路;
  generate_telemetry 只剩 python_values 一种模式,传别的会直接报错并说明原因
- 求解器改名分清: solver.py -> solvers/icon_click.py(client 实际在用的 YOLO+ResNet18 ONNX),
  icon_click_solver.py -> solvers/icon_click_rotate.py(仅测试在用,留作对照)
- pyproject: 去掉 ultralytics/onnx(训练栈已删),补上真正在用的 onnxruntime

删除了什么:
- 雨云整条线 + dist/rainyun_qinglong(104M 旧快照)
- 模板回填: models/success_collect_template.b64、tdc_worker.js、analyze_collect_layout.py
- 「补环境」时代浏览器 JS: chrome_fetch/manual_captcha/probe_captcha/solve_in_browser/tdc_browser
- 旧 VM 探索工具 10 个(tdc_collectgen/tdc_slotmap/tdc_lift/tdc_vm_run/tdc_query/tdc_probe 等)
  + 8 个基于它们的旧 verify 脚本
- 训练/标注/评估: train_classifier/train_detector/prepare_yolo/web_labeler/collector/
  siamese/evaluate/benchmark_verify
- tcaptcha/core_js/(60 个分析产物)、TOOLCHAIN_REPORT.md(整篇在描述已删工具)
- 3 个已无任何引用的 PyTorch 权重:icon_classifier.pt 43M / yolo_detector.pt 5.9M /
  siamese_matcher.pt 1.6M —— 线上只跑 onnx,tcaptcha/ 117M -> 66M

为什么:
- 项目后面要专门承载「滑块 / 文字点选 / 图标点选」三种求解器;扁平包 + 运行时依赖混在
  tools/ + 两条已废弃的 collect 生成路线,会让后续扩展持续踩坑

怎么验证:
- .venv/bin/python -m tcaptcha.selftest                    全绿
- scripts/verify/verify_offline.py                         逐槽全等 + 分段几何一致
- scripts/verify/verify_values.py                          4 份真机明文 152/152 逐槽全等
- scripts/verify/send_test.py --mode python_values         1ms 签到页文字点选 errorCode=0
- 回滚点: tag backup/pre-refactor-20261012
2026-10-12 12:34:57 +08:00

321 lines
13 KiB
Python

# -*- 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()}
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/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)))