feat(反VM): 纯 Python 还原 XTEA 算法 + 从 tdc.js 运行时提取每构建密钥
做了什么 - 确认 collect 的加密算法就是教科书 XTEA (32 轮, delta=0x9E3779B9), 并证明 crypto.py 原有的"带加盐"写法只是把两个密钥字折叠: K' = [k0, k1+salt1, k2+salt2, k3] - tdc_worker.js 新增 captureKey 模式: 正则改写 VM 的 ADD/XOR opcode 为带日志版本, 用反向引用自动识别栈变量名 (样本=g/雨云实时=j/1ms=M, 版本无关); 匹配数不为 1 时显式报错, 不静默失败 - 密钥反推: (sum + K[j]) 是一次 ADD, 两操作数必有一个等于 r*DELTA, 另一个就是密钥字; 再对 Top-N 候选做 4! 排列 + 明文内容特征验证 - 新增 recover_payload_template / generate_collect_from_template: 运行时标定一次明文模板, 之后轨迹生成/组包/加密/编码全部在 Python - 新增 tcaptcha/proxy.py 代理提取, client 支持 --proxy-api 遇 12 自动换 IP 为什么 - 服务端只认 collect 的解密结果, 而密钥是每构建随机的; 把算法做成纯 Python 后, 只有"取密钥"依赖 Node, 抗发版能力最强 怎么验证的 - 纯 Python XTEA 加密结果与真实 tdc.js 产物前 48 字节逐字节一致 - 密钥按构建固定的证据: 同一脚本 3 次运行密文首块均为 f6a24e3e4fb5dd2b - 三份脚本 (样本/雨云实时/1ms实时) 均成功复原密钥 - 雨云 aid 端到端: 纯 Python 出包 -> errorCode=0 + 真实 ticket - 回归测试: tcaptcha/tests/test_xtea_key.py 11 项断言全过, 文字点选求解 8/8; 新增 tcaptcha/selftest.py 一键复验 已知未解 - 1ms aid=195672344 在答案正确时仍返回 errorCode=12; 已排除密钥、 遥测引擎、出口 IP (4 个住宅代理)、短时频控、静默窗口
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御点选验证码 OCR 识别引擎集合 (可插拔 + 集成投票)
|
||||
|
||||
设计动机
|
||||
--------
|
||||
单一 OCR 对生僻字(如 "筹")置信度极低: ddddocr 对 "筹" 的 CTC 最大概率仅 0.005,
|
||||
而另一侧 PP-OCRv4 对 "雹" 又会误识为 "电"。两者互补, 因此本模块做 **概率级集成**:
|
||||
对同一个字形, 把每个引擎在每个目标字上的 CTC 最大时间步概率加权求和, 再交给
|
||||
全局指派算法, 可在 3 选 1 的题型上取得远高于单引擎的准确率。
|
||||
|
||||
引擎统一接口
|
||||
------------
|
||||
probabilities(gray: np.ndarray) -> (chars: List[str], probs: np.ndarray) # probs: (T, C)
|
||||
|
||||
gray 约定: 白底黑字的 uint8 灰度图 (字形像素值低)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger("tcaptcha_ocr")
|
||||
|
||||
MODEL_DIR = Path(__file__).resolve().parent / "models"
|
||||
PPOCR_DIR = MODEL_DIR / "ppocr"
|
||||
|
||||
|
||||
class BaseOcrEngine:
|
||||
name = "base"
|
||||
|
||||
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
|
||||
raise NotImplementedError
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class DdddOcrEngine(BaseOcrEngine):
|
||||
"""ddddocr (CTC, 8210 字集) —— 对常用字准确率高, 集成中的稳健票"""
|
||||
|
||||
name = "ddddocr"
|
||||
|
||||
def __init__(self, beta: bool = False, ocr=None):
|
||||
self._ocr = ocr
|
||||
self._beta = beta
|
||||
|
||||
@property
|
||||
def ocr(self):
|
||||
if self._ocr is None:
|
||||
import ddddocr
|
||||
|
||||
self._ocr = ddddocr.DdddOcr(show_ad=False, beta=self._beta)
|
||||
return self._ocr
|
||||
|
||||
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
|
||||
res = self.ocr.classification(gray, probability=True)
|
||||
probs = np.asarray(res["probabilities"]) # (T, 1, C)
|
||||
if probs.ndim == 3:
|
||||
probs = probs[:, 0, :]
|
||||
return list(res["charset"]), probs
|
||||
|
||||
|
||||
class PpOcrEngine(BaseOcrEngine):
|
||||
"""PaddleOCR PP-OCRv4 中文识别模型 (ONNX, 6623 字集) —— 对生僻字覆盖更好"""
|
||||
|
||||
name = "ppocr_v4"
|
||||
|
||||
def __init__(self, model_path: Optional[Path] = None, keys_path: Optional[Path] = None,
|
||||
rec_height: int = 48):
|
||||
import onnxruntime as ort
|
||||
|
||||
self.model_path = Path(model_path or PPOCR_DIR / "ch_PP-OCRv4_rec_infer.onnx")
|
||||
self.keys_path = Path(keys_path or PPOCR_DIR / "ppocr_keys_v1.txt")
|
||||
if not self.model_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"缺少 PP-OCRv4 识别模型: {self.model_path}\n"
|
||||
"下载: https://huggingface.co/SWHL/RapidOCR/resolve/main/PP-OCRv4/ch_PP-OCRv4_rec_infer.onnx"
|
||||
)
|
||||
if not self.keys_path.exists():
|
||||
raise FileNotFoundError(f"缺少 PP-OCR 字典: {self.keys_path}")
|
||||
|
||||
so = ort.SessionOptions()
|
||||
so.intra_op_num_threads = 2
|
||||
so.inter_op_num_threads = 2
|
||||
self.sess = ort.InferenceSession(str(self.model_path), so, providers=["CPUExecutionProvider"])
|
||||
self.input_name = self.sess.get_inputs()[0].name
|
||||
self.rec_height = rec_height
|
||||
|
||||
keys = self.keys_path.read_text(encoding="utf-8").split("\n")
|
||||
while keys and keys[-1] == "":
|
||||
keys.pop()
|
||||
# PaddleOCR 约定: 输出维度 = 1(blank) + 字典 + 1(空格)
|
||||
self.chars = ["blank"] + keys + [" "]
|
||||
|
||||
def _preprocess(self, gray: np.ndarray) -> np.ndarray:
|
||||
g = gray.astype(np.float32) / 255.0
|
||||
ratio = self.rec_height / float(g.shape[0])
|
||||
g = cv2.resize(g, (0, 0), fx=ratio, fy=ratio, interpolation=cv2.INTER_LINEAR)
|
||||
g = (g - 0.5) / 0.5
|
||||
g = g[None, None, :, :]
|
||||
return np.concatenate([g, g, g], axis=1).astype(np.float32)
|
||||
|
||||
def probabilities(self, gray: np.ndarray) -> Tuple[List[str], np.ndarray]:
|
||||
logits = self.sess.run(None, {self.input_name: self._preprocess(gray)})[0] # (1, T, C)
|
||||
return self.chars, np.asarray(logits[0])
|
||||
|
||||
|
||||
class EnsembleOcr:
|
||||
"""概率级加权集成: score(t) = Σ w_i * max_t P_i(t)"""
|
||||
|
||||
def __init__(self, engines: Sequence[BaseOcrEngine], weights: Optional[Sequence[float]] = None):
|
||||
self.engines = list(engines)
|
||||
self.weights = list(weights) if weights else [1.0] * len(self.engines)
|
||||
if len(self.weights) != len(self.engines):
|
||||
raise ValueError("engines 与 weights 数量不一致")
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "EnsembleOcr":
|
||||
engines: List[BaseOcrEngine] = []
|
||||
try:
|
||||
engines.append(PpOcrEngine())
|
||||
except FileNotFoundError as exc:
|
||||
logger.warning("PP-OCRv4 不可用, 退化为单引擎: %s", exc)
|
||||
engines.append(DdddOcrEngine())
|
||||
return cls(engines)
|
||||
|
||||
def per_engine_scores(self, gray: np.ndarray, targets: Sequence[str]) -> List[Dict[str, float]]:
|
||||
per: List[Dict[str, float]] = []
|
||||
for engine in self.engines:
|
||||
try:
|
||||
chars, probs = engine.probabilities(gray)
|
||||
except Exception as exc: # 单个引擎异常不影响整体求解
|
||||
logger.warning("引擎 %s 推理失败: %s", engine.name, exc)
|
||||
per.append({t: 0.0 for t in targets})
|
||||
continue
|
||||
index = {c: i for i, c in enumerate(chars)}
|
||||
row = {}
|
||||
for t in targets:
|
||||
j = index.get(t)
|
||||
row[t] = float(probs[:, j].max()) if j is not None else 0.0
|
||||
per.append(row)
|
||||
return per
|
||||
|
||||
def scores(self, gray: np.ndarray, targets: Sequence[str]) -> Dict[str, float]:
|
||||
per = self.per_engine_scores(gray, targets)
|
||||
out = {}
|
||||
for t in targets:
|
||||
out[t] = float(sum(w * r.get(t, 0.0) for w, r in zip(self.weights, per)))
|
||||
return out
|
||||
|
||||
def close(self):
|
||||
for engine in self.engines:
|
||||
engine.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
ens = EnsembleOcr.default()
|
||||
img = cv2.imread(sys.argv[1], cv2.IMREAD_GRAYSCALE)
|
||||
targets = sys.argv[2:]
|
||||
print(ens.per_engine_scores(img, targets))
|
||||
print("ensemble:", ens.scores(img, targets))
|
||||
Reference in New Issue
Block a user