做了什么:
- 包改为分层: 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
321 lines
13 KiB
Python
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)))
|