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:
+397
-194
@@ -1,15 +1,25 @@
|
||||
"""
|
||||
腾讯天御点选验证码工业级纯协议客户端 (Tencent Turing Captcha Protocol Client)
|
||||
实现端到端全自动纯协议闭环:
|
||||
1. GET /cap_union_prehandle 获取初始会话与 PoW 题目
|
||||
2. 毫秒级求解 Proof of Work 哈希碰撞
|
||||
3. 并发拉取动态混淆的 tdc.js 脚本
|
||||
4. POST /cap_union_new_getsig 获取当前轮次题目与图片
|
||||
5. YOLOv8n + ResNet18 模型进行视觉检测、分类与全局非冲突点选排序
|
||||
6. 纯算法还原 (Custom XTEA + 贝塞尔人手微颤) 生成行为轨迹密文 collect 与会话密钥 eks (100% 纯 Python)
|
||||
7. POST /cap_union_new_verify 提交验证,获取最终 ticket 与 randstr
|
||||
腾讯天御点选验证码纯协议客户端 (Tencent Turing Captcha Protocol Client)
|
||||
|
||||
覆盖腾讯天御 `cap_union_new` 协议族的完整纯协议闭环:
|
||||
|
||||
1. GET /cap_union_prehandle 获取会话 sess / PoW 题目 / 题型 / 首道题目
|
||||
2. 本地求解 PoW (MD5 哈希碰撞)
|
||||
3. GET <tdc_path> 拉取当前会话动态混淆的 tdc.js, 提取会话密钥 eks
|
||||
4. GET /cap_union_new_getcapbysig 下载 672x480 背景图 (img_index=1);
|
||||
图标题型额外下载 img_index=0 提示图
|
||||
5. 视觉求解:
|
||||
* 文字点选 (subcapclass 2404) -> tcaptcha.glyph_solver.TextClickSolver
|
||||
* 图标点选 (subcapclass 2408) -> tcaptcha.solver.CaptchaSolver (YOLO+ResNet18)
|
||||
6. 生成行为遥测密文 collect (纯 Python 还原的自定义 XTEA + 贝塞尔轨迹)
|
||||
7. POST /cap_union_new_verify 提交 ans/collect/eks/sess/pow_answer
|
||||
8. 失败 → POST /cap_union_new_getsig 在同一会话内刷新下一道题并重试
|
||||
|
||||
题型识别不依赖 subcapclass 数字 (getsig 不返回该字段), 而是根据
|
||||
instruction + bg_elem_cfg.click_cfg 推断, 详见 classify_challenge()。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -17,8 +27,9 @@ import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -26,12 +37,55 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tcaptcha.crypto import extract_eks, generate_collect
|
||||
from tcaptcha.solver import CaptchaSolver
|
||||
from tcaptcha.crypto import extract_eks, generate_telemetry
|
||||
from tcaptcha.glyph_solver import TextClickSolver, parse_instruction
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("tcaptcha_client")
|
||||
|
||||
DEFAULT_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36")
|
||||
|
||||
# 题型
|
||||
KIND_TEXT_CLICK = "text_click" # 2404 文字点选
|
||||
KIND_ICON_CLICK = "icon_click" # 2408 图标点选 (需要提示图 img_index=0)
|
||||
KIND_SLIDER = "slider" # 2402 滑块拼图 (本客户端未实现)
|
||||
KIND_UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Challenge:
|
||||
"""一道待求解的验证码题目"""
|
||||
|
||||
sess: str
|
||||
instruction: str = ""
|
||||
img_url: str = ""
|
||||
kind: str = KIND_UNKNOWN
|
||||
subcapclass: str = ""
|
||||
click_cfg: Optional[dict] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def targets(self) -> List[str]:
|
||||
return parse_instruction(self.instruction)
|
||||
|
||||
|
||||
def classify_challenge(dyn_show_info: dict, subcapclass: str = "") -> str:
|
||||
"""根据题目配置推断题型 (getsig 不返回 subcapclass, 只能靠结构判断)"""
|
||||
instruction = (dyn_show_info or {}).get("instruction", "") or ""
|
||||
click_cfg = ((dyn_show_info or {}).get("bg_elem_cfg") or {}).get("click_cfg") or {}
|
||||
data_types = click_cfg.get("data_type") or []
|
||||
if "DynAnswerType_POS" in data_types or "DynAnswerType_POINT" in data_types:
|
||||
# 有点选坐标配置: 有目标文字 => 文字点选; 无目标文字 => 图标点选(提示走提示图)
|
||||
return KIND_TEXT_CLICK if parse_instruction(instruction) else KIND_ICON_CLICK
|
||||
if "滑块" in instruction or "拖动" in instruction or subcapclass == "2402":
|
||||
return KIND_SLIDER
|
||||
if subcapclass == "2404":
|
||||
return KIND_TEXT_CLICK
|
||||
if subcapclass == "2408":
|
||||
return KIND_ICON_CLICK
|
||||
return KIND_UNKNOWN
|
||||
|
||||
|
||||
class TencentCaptchaClient:
|
||||
BASE_URL = "https://turing.captcha.qcloud.com"
|
||||
@@ -40,275 +94,424 @@ class TencentCaptchaClient:
|
||||
def __init__(
|
||||
self,
|
||||
aid: str = DEFAULT_AID,
|
||||
solver: Optional[CaptchaSolver] = None,
|
||||
solver=None,
|
||||
text_solver: Optional[TextClickSolver] = None,
|
||||
timeout: float = 15.0,
|
||||
user_agent: Optional[str] = None,
|
||||
entry_url: str = "https://app.rainyun.com/account/reward/earn#",
|
||||
referer: Optional[str] = None,
|
||||
wb: str = "2",
|
||||
js: str = "/tcaptcha-frame.ba387dd1.js",
|
||||
accept_kinds: Sequence[str] = (KIND_TEXT_CLICK, KIND_ICON_CLICK),
|
||||
min_posterior: float = 0.95,
|
||||
pow_timeout: int = 4000000,
|
||||
inter_attempt_delay: float = 0.25,
|
||||
telemetry_mode: str = "python_keyed",
|
||||
proxy: Optional[str] = None,
|
||||
):
|
||||
self.aid = aid
|
||||
"""
|
||||
telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry):
|
||||
"python_keyed" (默认) Node **只**从本次构建的 tdc.js 里提取 XTEA 密钥,
|
||||
collect 的加密与组装全部由纯 Python 完成 (标准 XTEA)
|
||||
"python" 纯 Python + crypto.py 内置固定密钥 (无 Node 依赖, 兼容宽松 aid)
|
||||
"node" 在 Node VM 沙箱里整体执行真实 tdc.js 产出 collect/eks
|
||||
"browser" 在真实 Chrome 里执行真实 tdc.js (CDP 驱动, 环境最真)
|
||||
"auto" / "auto_keyed" 多后端逐级回退
|
||||
|
||||
实测结论 (2026-10-11):
|
||||
* 雨云 aid=2039519451 用 "python" 提交正确答案 → errorCode=0 + 真实 ticket,
|
||||
证明服务端不校验 collect 与 eks 的同源性, 纯 Python 交付形态依然成立。
|
||||
* 1ms.run aid=195672344 答题正确时, "python"/"node"/"browser" 三种遥测
|
||||
在服务端风控阶段一律返回 errorCode=12 —— 与该 aid 的风控策略有关,
|
||||
不是遥测引擎的问题。详见 har/1ms_dianxuan/ANALYSIS.md。
|
||||
"""
|
||||
self.aid = str(aid)
|
||||
self.timeout = timeout
|
||||
self.user_agent = user_agent or (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
|
||||
)
|
||||
self.solver = solver or CaptchaSolver()
|
||||
self.user_agent = user_agent or DEFAULT_UA
|
||||
self.entry_url = entry_url
|
||||
self.referer = referer or entry_url
|
||||
self.wb = str(wb)
|
||||
self.js = js
|
||||
self.accept_kinds = tuple(accept_kinds)
|
||||
self.min_posterior = min_posterior
|
||||
self.pow_timeout = pow_timeout
|
||||
# 抽题/投票之间的最小间隔, 用于规避腾讯 errorCode 12 (f_limit)
|
||||
self.inter_attempt_delay = inter_attempt_delay
|
||||
# 遥测(collect/eks)生成方式: node = 在沙箱里跑真实 tdc.js (推荐); python = 纯算法还原
|
||||
self.telemetry_mode = telemetry_mode
|
||||
# 出口代理: 腾讯天御按 IP 做滚动风控, 触发 errorCode=12 时换 IP 即可恢复
|
||||
self.proxy = proxy
|
||||
|
||||
# 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重)
|
||||
self._icon_solver = solver
|
||||
self._text_solver = text_solver
|
||||
|
||||
self.http_client = httpx.Client(
|
||||
base_url=self.BASE_URL,
|
||||
trust_env=False, # 屏蔽环境代理干扰
|
||||
proxy=self.proxy,
|
||||
headers={
|
||||
"User-Agent": self.user_agent,
|
||||
"Referer": "https://turing.captcha.qcloud.com/",
|
||||
"Referer": self.referer,
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- helpers
|
||||
@property
|
||||
def text_solver(self) -> TextClickSolver:
|
||||
if self._text_solver is None:
|
||||
logger.info("初始化文字点选求解器 (ddddocr + PP-OCRv4 集成)...")
|
||||
self._text_solver = TextClickSolver()
|
||||
return self._text_solver
|
||||
|
||||
@property
|
||||
def icon_solver(self):
|
||||
if self._icon_solver is None:
|
||||
from tcaptcha.solver import CaptchaSolver
|
||||
|
||||
logger.info("初始化图标点选求解器 (YOLOv8 + ResNet18)...")
|
||||
self._icon_solver = CaptchaSolver()
|
||||
return self._icon_solver
|
||||
|
||||
def _solve_pow(self, prefix: str, target_md5: str) -> Tuple[str, int]:
|
||||
"""求解 Proof of Work 哈希碰撞"""
|
||||
t0 = time.time()
|
||||
for i in range(2000000):
|
||||
for i in range(self.pow_timeout):
|
||||
candidate = f"{prefix}{i}"
|
||||
if hashlib.md5(candidate.encode("utf-8")).hexdigest() == target_md5:
|
||||
calc_time = max(1, round((time.time() - t0) * 1000))
|
||||
return candidate, calc_time
|
||||
raise RuntimeError(f"PoW MD5 collision search failed for prefix {prefix}")
|
||||
|
||||
def _generate_telemetry(self, tdc_code: str, click_points: List[Tuple[int, int]]) -> Dict[str, str]:
|
||||
"""纯 Python 算法生成真实行为轨迹与遥测密文 collect 及 eks (100% 纯算免 Node.js)"""
|
||||
eks = extract_eks(tdc_code)
|
||||
collect = generate_collect(click_points, aid=self.aid, user_agent=self.user_agent)
|
||||
return {
|
||||
"collect": collect,
|
||||
"tlg": str(len(collect)),
|
||||
"eks": eks,
|
||||
# ------------------------------------------------------------- 1. prehandle
|
||||
def prehandle(self, tkid: str) -> Tuple[Challenge, dict, str]:
|
||||
"""GET /cap_union_prehandle —— 返回 (题目, comm_captcha_cfg, sid)"""
|
||||
cb_name = f"_aq_{random.randint(100000, 999999)}"
|
||||
ua_b64 = base64.b64encode(self.user_agent.encode()).decode()
|
||||
params = {
|
||||
"aid": self.aid,
|
||||
"protocol": "https",
|
||||
"accver": "1",
|
||||
"showtype": "popup",
|
||||
"ua": ua_b64,
|
||||
"noheader": "1",
|
||||
"fb": "1",
|
||||
"aged": "0",
|
||||
"enableAged": "0",
|
||||
"enableDarkMode": "0",
|
||||
"grayscale": "1",
|
||||
"clientype": "2",
|
||||
"cap_cd": "",
|
||||
"uid": "",
|
||||
"lang": "zh-cn",
|
||||
"entry_url": self.entry_url,
|
||||
"elder_captcha": "0",
|
||||
"js": self.js,
|
||||
"login_appid": "",
|
||||
"wb": self.wb,
|
||||
"tkid": tkid,
|
||||
"subsid": str(random.randint(1, 5)),
|
||||
"callback": cb_name,
|
||||
"sess": "",
|
||||
}
|
||||
resp = self.http_client.get("/cap_union_prehandle", params=params,
|
||||
headers={"Referer": self.referer})
|
||||
match = re.search(rf"{re.escape(cb_name)}\((.*)\)\s*$", resp.text, re.S)
|
||||
if not match:
|
||||
raise RuntimeError(f"prehandle 回调解析失败: {resp.text[:200]}")
|
||||
data = json.loads(match.group(1))
|
||||
dyn = (data.get("data") or {}).get("dyn_show_info") or {}
|
||||
sub = str(data.get("subcapclass", ""))
|
||||
challenge = Challenge(
|
||||
sess=data.get("sess", ""),
|
||||
instruction=dyn.get("instruction", ""),
|
||||
img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""),
|
||||
kind=classify_challenge(dyn, sub),
|
||||
subcapclass=sub,
|
||||
click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"),
|
||||
raw=dyn,
|
||||
)
|
||||
return challenge, (data.get("data") or {}).get("comm_captcha_cfg", {}) or {}, data.get("sid", "")
|
||||
|
||||
def solve(self, max_retries: int = 2) -> Dict:
|
||||
# --------------------------------------------------------------- 2. getsig
|
||||
def getsig(self, sess: str, tkid: str) -> Tuple[Challenge, str]:
|
||||
"""POST /cap_union_new_getsig —— 在同一会话内刷新题目"""
|
||||
resp = self.http_client.post(
|
||||
"/cap_union_new_getsig",
|
||||
data={"sess": sess, "tkid": tkid},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": "https://turing.captcha.gtimg.com",
|
||||
"Referer": "https://turing.captcha.gtimg.com/"},
|
||||
)
|
||||
payload = resp.json()
|
||||
if str(payload.get("ret")) != "0":
|
||||
raise RuntimeError(f"getsig 返回异常: {payload}")
|
||||
dyn = payload.get("data") or {}
|
||||
new_sess = payload.get("sess") or sess
|
||||
challenge = Challenge(
|
||||
sess=new_sess,
|
||||
instruction=dyn.get("instruction", ""),
|
||||
img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""),
|
||||
kind=classify_challenge(dyn),
|
||||
click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"),
|
||||
raw=dyn,
|
||||
)
|
||||
return challenge, new_sess
|
||||
|
||||
# ------------------------------------------------------------ 3. download
|
||||
def fetch_tdc(self, tdc_path: str) -> str:
|
||||
resp = self.http_client.get(tdc_path,
|
||||
headers={"Referer": "https://turing.captcha.gtimg.com/"})
|
||||
return resp.text
|
||||
|
||||
def fetch_image(self, img_url: str, img_index: Optional[int] = None) -> bytes:
|
||||
if img_index is not None:
|
||||
img_url = re.sub(r"img_index=\d+", f"img_index={img_index}", img_url)
|
||||
resp = self.http_client.get(
|
||||
img_url,
|
||||
headers={"Referer": "https://turing.captcha.gtimg.com/",
|
||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
||||
)
|
||||
return resp.content
|
||||
|
||||
# ------------------------------------------------------------------ solve
|
||||
def solve(self, max_retries: int = 3, max_refreshes: int = 6) -> Dict:
|
||||
"""
|
||||
端到端全自动解题并提交验证
|
||||
返回格式:
|
||||
{
|
||||
"success": bool,
|
||||
"ticket": str,
|
||||
"randstr": str,
|
||||
"elapsed_ms": int,
|
||||
"points": [(x1, y1), (x2, y2), (x3, y3)],
|
||||
"raw_response": dict,
|
||||
"error": str
|
||||
}
|
||||
端到端解题并提交验证。
|
||||
|
||||
返回: {success, ticket, randstr, elapsed_ms, points, kind, instruction,
|
||||
raw_response, error}
|
||||
"""
|
||||
start_time = time.time()
|
||||
active_sess = None
|
||||
tkid = None
|
||||
tdc_code = None
|
||||
pow_answer = None
|
||||
pow_calc_time = None
|
||||
last_raw = {}
|
||||
last_error = "Max retries exceeded or verification rejected"
|
||||
tkid = str(random.randint(100000000, 999999999))
|
||||
sess: Optional[str] = None
|
||||
eks: Optional[str] = None
|
||||
tdc_code: Optional[str] = None
|
||||
pow_answer: Optional[str] = None
|
||||
pow_calc_time: Optional[int] = None
|
||||
comm_cfg: Dict = {}
|
||||
challenge: Optional[Challenge] = None
|
||||
last_raw: Dict = {}
|
||||
last_error = "未完成任何一次验证提交"
|
||||
|
||||
for attempt in range(max_retries):
|
||||
attempts = 0
|
||||
refreshes = 0
|
||||
prehandles = 0
|
||||
while attempts < max_retries and refreshes < max_refreshes:
|
||||
try:
|
||||
# 1. 预检请求 (Prehandle) 或在会话内刷新
|
||||
cb_name = f"_aq_{random.randint(100000, 999999)}"
|
||||
if not tkid:
|
||||
tkid = str(random.randint(100000000, 999999999))
|
||||
# ---- 拿题: 首选 prehandle, 之后用 getsig 在同会话内刷新
|
||||
if challenge is None:
|
||||
challenge, comm_cfg, _sid = self.prehandle(tkid)
|
||||
prehandles += 1
|
||||
logger.info("prehandle -> subcapclass=%s kind=%s instruction=%r",
|
||||
challenge.subcapclass, challenge.kind, challenge.instruction)
|
||||
pow_cfg = comm_cfg.get("pow_cfg") or {}
|
||||
if pow_cfg.get("prefix") and pow_cfg.get("md5"):
|
||||
pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"],
|
||||
pow_cfg["md5"])
|
||||
logger.info("PoW 命中 %s (%dms)", pow_answer, pow_calc_time)
|
||||
tdc_code = self.fetch_tdc(comm_cfg.get("tdc_path", ""))
|
||||
eks = extract_eks(tdc_code)
|
||||
if not eks:
|
||||
logger.warning("未从 tdc.js 中提取到 eks, 继续尝试")
|
||||
else:
|
||||
challenge, sess = self.getsig(sess or challenge.sess, tkid)
|
||||
logger.info("getsig -> kind=%s instruction=%r",
|
||||
challenge.kind, challenge.instruction)
|
||||
|
||||
if not active_sess:
|
||||
# 首次进入:执行 Prehandle
|
||||
ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2"
|
||||
params = {
|
||||
"aid": self.aid,
|
||||
"protocol": "https",
|
||||
"accver": "1",
|
||||
"showtype": "popup",
|
||||
"ua": ua_b64,
|
||||
"noheader": "1",
|
||||
"fb": "1",
|
||||
"aged": "0",
|
||||
"enableAged": "0",
|
||||
"enableDarkMode": "0",
|
||||
"grayscale": "1",
|
||||
"clientype": "2",
|
||||
"lang": "zh-cn",
|
||||
"entry_url": "https://app.rainyun.com/account/reward/earn#",
|
||||
"elder_captcha": "0",
|
||||
"js": "/tcaptcha-frame.ba387dd1.js",
|
||||
"wb": "2",
|
||||
"tkid": tkid,
|
||||
"subsid": str(random.randint(1, 5)),
|
||||
"callback": cb_name,
|
||||
}
|
||||
if challenge.kind not in self.accept_kinds:
|
||||
# 题型在会话内是固定的 (getsig 不会换题型), 必须重新 prehandle 抽题
|
||||
logger.info("题型 %s 不在支持范围 %s, 重新 prehandle 抽题 (已抽 %d 次)",
|
||||
challenge.kind, list(self.accept_kinds), prehandles)
|
||||
refreshes += 1
|
||||
challenge = None
|
||||
sess = None
|
||||
eks = None
|
||||
pow_answer = None
|
||||
if self.inter_attempt_delay and refreshes < max_refreshes:
|
||||
time.sleep(self.inter_attempt_delay)
|
||||
continue
|
||||
|
||||
pre_resp = self.http_client.get(
|
||||
"/cap_union_prehandle",
|
||||
params=params,
|
||||
headers={"Referer": "https://app.rainyun.com/"},
|
||||
)
|
||||
match = re.search(rf"{cb_name}\((.*)\)", pre_resp.text)
|
||||
if not match:
|
||||
logger.warning("Failed to parse prehandle callback, retrying...")
|
||||
# ---- 视觉求解
|
||||
points: List[Tuple[int, int]]
|
||||
if challenge.kind == KIND_TEXT_CLICK:
|
||||
bg = self.fetch_image(challenge.img_url, img_index=1)
|
||||
result = self.text_solver.solve_ex(bg, challenge.instruction)
|
||||
logger.info("文字点选求解: %s", result.describe())
|
||||
if not result.ok:
|
||||
logger.warning("文字点选求解失败, 刷新题目")
|
||||
refreshes += 1
|
||||
continue
|
||||
if result.posterior < self.min_posterior:
|
||||
logger.warning("求解置信度过低 (%.3f < %.2f), 不提交, 刷新题目",
|
||||
result.posterior, self.min_posterior)
|
||||
refreshes += 1
|
||||
continue
|
||||
points = result.points
|
||||
else: # KIND_ICON_CLICK
|
||||
bg = self.fetch_image(challenge.img_url, img_index=1)
|
||||
prompt = self.fetch_image(challenge.img_url, img_index=0)
|
||||
if not prompt:
|
||||
logger.warning("图标题型未取到提示图, 刷新题目")
|
||||
refreshes += 1
|
||||
continue
|
||||
points = self.icon_solver.solve(bg, prompt)
|
||||
if len(points) < 3:
|
||||
logger.warning("图标点选求解失败, 刷新题目")
|
||||
refreshes += 1
|
||||
continue
|
||||
|
||||
pre_data = json.loads(match.group(1))
|
||||
initial_sess = pre_data.get("sess")
|
||||
comm_cfg = pre_data.get("data", {}).get("comm_captcha_cfg", {})
|
||||
|
||||
# 2. 计算 PoW
|
||||
pow_cfg = comm_cfg.get("pow_cfg", {})
|
||||
pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"], pow_cfg["md5"])
|
||||
|
||||
# 3. 拉取当前会话专属动态 tdc.js
|
||||
tdc_path = comm_cfg.get("tdc_path", "")
|
||||
tdc_resp = self.http_client.get(
|
||||
tdc_path,
|
||||
headers={"Referer": "https://turing.captcha.gtimg.com/"},
|
||||
)
|
||||
tdc_code = tdc_resp.text
|
||||
current_sess = initial_sess
|
||||
else:
|
||||
# 会话内刷新:直接使用前一轮返回的 sess
|
||||
current_sess = active_sess
|
||||
|
||||
# 4. 获取题目信息与图片 URL (POST /cap_union_new_getsig)
|
||||
sig_resp = self.http_client.post(
|
||||
"/cap_union_new_getsig",
|
||||
data={"sess": current_sess, "tkid": tkid},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": "https://turing.captcha.gtimg.com",
|
||||
"Referer": "https://turing.captcha.gtimg.com/",
|
||||
},
|
||||
)
|
||||
sig_json = sig_resp.json()
|
||||
if str(sig_json.get("ret")) != "0":
|
||||
logger.warning(f"getsig returned non-zero ret: {sig_json}")
|
||||
active_sess = None
|
||||
continue
|
||||
|
||||
dyn_info = sig_json.get("data", {})
|
||||
bg_url = dyn_info.get("bg_elem_cfg", {}).get("img_url", "")
|
||||
prompt_url = bg_url.replace("img_index=1", "img_index=0")
|
||||
|
||||
# 提取图片 URL 中的最新有效 sess
|
||||
sess_match = re.search(r"sess=([^&]+)", bg_url)
|
||||
active_sess = sess_match.group(1) if sess_match else current_sess
|
||||
|
||||
# 5. 下载背景大图与提示图标图
|
||||
bg_bytes = self.http_client.get(
|
||||
bg_url,
|
||||
headers={"Referer": "https://turing.captcha.gtimg.com/"},
|
||||
).content
|
||||
prompt_bytes = self.http_client.get(
|
||||
prompt_url,
|
||||
headers={"Referer": "https://turing.captcha.gtimg.com/"},
|
||||
).content
|
||||
|
||||
# 6. AI 模型高精度求解
|
||||
pts = self.solver.solve(bg_bytes, prompt_bytes)
|
||||
if len(pts) < 3:
|
||||
logger.warning("Solver failed to find 3 valid points, retrying...")
|
||||
continue
|
||||
|
||||
# 7. 格式化答案 ans
|
||||
ans_list = [
|
||||
{"elem_id": 1, "type": "DynAnswerType_POS", "data": f"{pts[0][0]},{pts[0][1]}"},
|
||||
{"elem_id": 2, "type": "DynAnswerType_POS", "data": f"{pts[1][0]},{pts[1][1]}"},
|
||||
{"elem_id": 3, "type": "DynAnswerType_POS", "data": f"{pts[2][0]},{pts[2][1]}"},
|
||||
]
|
||||
# ---- 组包并提交
|
||||
attempts += 1
|
||||
ans_list = [{"elem_id": i + 1, "type": "DynAnswerType_POS",
|
||||
"data": f"{p[0]},{p[1]}"} for i, p in enumerate(points)]
|
||||
ans_str = json.dumps(ans_list, separators=(",", ":"))
|
||||
|
||||
# 8. 纯算法生成真实行为轨迹 collect 与会话密钥 eks (100% 纯 Python)
|
||||
telemetry = self._generate_telemetry(tdc_code, pts)
|
||||
|
||||
# 9. 提交验证 POST /cap_union_new_verify
|
||||
telemetry = generate_telemetry(
|
||||
tdc_code or "", points, aid=self.aid, user_agent=self.user_agent,
|
||||
referrer=self.referer, mode=self.telemetry_mode,
|
||||
)
|
||||
logger.info("遥测密文来源=%s collect_len=%s eks_len=%s",
|
||||
telemetry.get("source"), len(telemetry["collect"]),
|
||||
len(telemetry.get("eks", "")))
|
||||
verify_body = {
|
||||
"ans": ans_str,
|
||||
"collect": telemetry["collect"],
|
||||
"tlg": telemetry["tlg"],
|
||||
"eks": telemetry["eks"],
|
||||
"sess": active_sess,
|
||||
"eks": telemetry.get("eks") or eks or "",
|
||||
"sess": challenge.sess,
|
||||
"tkid": tkid,
|
||||
"pow_answer": pow_answer,
|
||||
"pow_calc_time": str(pow_calc_time),
|
||||
"pow_answer": pow_answer or "",
|
||||
"pow_calc_time": str(pow_calc_time or 1),
|
||||
}
|
||||
|
||||
verify_resp = self.http_client.post(
|
||||
"/cap_union_new_verify",
|
||||
data=verify_body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Origin": "https://turing.captcha.gtimg.com",
|
||||
"Referer": "https://turing.captcha.gtimg.com/",
|
||||
},
|
||||
)
|
||||
|
||||
v_json = verify_resp.json()
|
||||
total_elapsed = int((time.time() - start_time) * 1000)
|
||||
last_raw = v_json
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
|
||||
if str(v_json.get("errorCode")) == "0":
|
||||
logger.info("✅ 验证通过! randstr=%s", v_json.get("randstr"))
|
||||
return {
|
||||
"success": True,
|
||||
"ticket": v_json.get("ticket", ""),
|
||||
"randstr": v_json.get("randstr", ""),
|
||||
"elapsed_ms": total_elapsed,
|
||||
"points": pts,
|
||||
"elapsed_ms": elapsed,
|
||||
"points": points,
|
||||
"kind": challenge.kind,
|
||||
"instruction": challenge.instruction,
|
||||
"raw_response": v_json,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
err_code = str(v_json.get("errorCode"))
|
||||
if err_code == "12":
|
||||
last_error = ("触发腾讯接口频控限制 (errorCode: 12, f_limit), "
|
||||
"建议降低请求频率或更换出口 IP")
|
||||
logger.error(last_error)
|
||||
break
|
||||
if err_code in ("50", "9"):
|
||||
last_error = f"坐标未命中 (errorCode: {err_code})"
|
||||
else:
|
||||
err_code = str(v_json.get("errorCode"))
|
||||
err_msg = v_json.get("errMessage", "")
|
||||
if err_code == "12":
|
||||
last_error = f"触发腾讯接口频控限制 (errorCode: 12, f_limit),建议稍候或更换IP"
|
||||
elif err_code == "50":
|
||||
last_error = f"验证坐标未命中或会话需刷新 (errorCode: 50)"
|
||||
else:
|
||||
last_error = f"验证失败 (errorCode: {err_code}, msg: {err_msg})"
|
||||
last_error = (f"验证失败 (errorCode: {err_code}, "
|
||||
f"msg: {v_json.get('errMessage', '')})")
|
||||
logger.warning(last_error)
|
||||
|
||||
logger.warning(f"Attempt {attempt + 1} verify: {last_error}")
|
||||
last_raw = v_json
|
||||
# 如果接口返回了新的会话 sess,下一轮直接沿用该会话重试刷新
|
||||
new_sess = v_json.get("sess")
|
||||
if new_sess:
|
||||
active_sess = new_sess
|
||||
else:
|
||||
active_sess = None
|
||||
except Exception as e:
|
||||
last_error = f"请求异常: {e}"
|
||||
logger.error(f"Attempt {attempt + 1} encountered exception: {e}")
|
||||
# 服务端返回新 sess 时沿用, 否则回到 prehandle
|
||||
new_sess = v_json.get("sess")
|
||||
if new_sess:
|
||||
challenge, sess = self.getsig(new_sess, tkid)
|
||||
logger.info("verify 后刷新 -> kind=%s instruction=%r",
|
||||
challenge.kind, challenge.instruction)
|
||||
else:
|
||||
challenge = None
|
||||
sess = None
|
||||
eks = None
|
||||
pow_answer = None
|
||||
except Exception as exc: # 网络/解析异常: 丢弃当前会话重来
|
||||
last_error = f"请求异常: {exc}"
|
||||
logger.error("第 %d 次尝试异常: %s", attempts + 1, exc)
|
||||
challenge = None
|
||||
sess = None
|
||||
eks = None
|
||||
pow_answer = None
|
||||
|
||||
total_elapsed = int((time.time() - start_time) * 1000)
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
return {
|
||||
"success": False,
|
||||
"ticket": "",
|
||||
"randstr": "",
|
||||
"elapsed_ms": total_elapsed,
|
||||
"elapsed_ms": elapsed,
|
||||
"points": [],
|
||||
"kind": challenge.kind if challenge else "",
|
||||
"instruction": challenge.instruction if challenge else "",
|
||||
"raw_response": last_raw,
|
||||
"error": last_error,
|
||||
}
|
||||
|
||||
def close(self):
|
||||
self.http_client.close()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Tencent Captcha Pure Protocol Solver Client")
|
||||
parser.add_argument("--aid", default="2039519451", help="Captcha AppID (default: 2039519451 for Rainyun)")
|
||||
parser.add_argument("--aid", default="195672344", help="验证码 AppID (默认毫秒 1ms.run)")
|
||||
parser.add_argument("--entry-url",
|
||||
default="https://1ms.run/console/personal/benefits/check-in")
|
||||
parser.add_argument("--wb", default="1")
|
||||
parser.add_argument("--retries", type=int, default=3)
|
||||
parser.add_argument("--telemetry", default="python",
|
||||
choices=["python", "node", "browser", "auto"])
|
||||
parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080")
|
||||
parser.add_argument("--proxy-api", default=None,
|
||||
help="代理提取 API; 出现 errorCode=12 时自动换 IP 重试")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = TencentCaptchaClient(aid=args.aid)
|
||||
print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid})...")
|
||||
result = client.solve()
|
||||
proxy = args.proxy
|
||||
if not proxy and args.proxy_api:
|
||||
from tcaptcha.proxy import fetch_proxy
|
||||
|
||||
proxy = fetch_proxy(args.proxy_api)
|
||||
print(f"[*] 从代理 API 提取到: {proxy}")
|
||||
|
||||
client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url,
|
||||
referer="https://1ms.run/", wb=args.wb,
|
||||
proxy=proxy, telemetry_mode=args.telemetry)
|
||||
print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid}, proxy={proxy})...")
|
||||
result = client.solve(max_retries=args.retries)
|
||||
|
||||
# errorCode=12 (f_limit) 是 IP 级滚动风控: 换一个出口 IP 再试一次
|
||||
if not result["success"] and "12" in str(result.get("error", "")) and args.proxy_api:
|
||||
print("[*] 触发 f_limit, 换 IP 重试...")
|
||||
client.close()
|
||||
proxy = fetch_proxy(args.proxy_api)
|
||||
print(f"[*] 新代理: {proxy}")
|
||||
client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url,
|
||||
referer="https://1ms.run/", wb=args.wb,
|
||||
proxy=proxy, telemetry_mode=args.telemetry)
|
||||
result = client.solve(max_retries=args.retries)
|
||||
|
||||
if result["success"]:
|
||||
print("\n" + "=" * 50)
|
||||
print(" 🎉 验证码纯协议求解成功!")
|
||||
print("=" * 50)
|
||||
print(f"耗时: {result['elapsed_ms']} ms")
|
||||
print(f"耗时: {result['elapsed_ms']} ms")
|
||||
print(f"题型: {result['kind']} 题目: {result['instruction']!r}")
|
||||
print(f"点击坐标: {result['points']}")
|
||||
print(f"randstr: {result['randstr']}")
|
||||
print(f"ticket: {result['ticket']}")
|
||||
print(f"randstr: {result['randstr']}")
|
||||
print(f"ticket: {result['ticket']}")
|
||||
print("=" * 50)
|
||||
else:
|
||||
print(f"\n❌ 验证失败: {result['error']}")
|
||||
|
||||
Reference in New Issue
Block a user