攻克腾讯 TCaptcha 滑块验证码:纯 HTTP 协议逆向实战

攻克腾讯 TCaptcha 滑块验证码:纯 HTTP 协议逆向实战

本文记录了一次完整的验证码逆向工程实践,从协议分析、图像处理、算法设计到 JavaScript VM 执行,最终实现了对腾讯 TCaptcha 滑块验证码的全自动化破解,通过率达到 100%。

一、技术挑战概述

腾讯 TCaptcha 是国内主流的滑块验证码方案,被广泛应用于各大互联网平台的风控系统中。本项目以粉笔教育的登录流程为研究对象,核心目标是在不依赖 Selenium 或 Playwright 等浏览器自动化工具的前提下,通过纯 HTTP 协议模拟实现验证码的自动化破解。这要求我们不仅要实现亚像素级别的拼图块位置计算,还需要绕过设备指纹、行为轨迹等多维度的检测机制,最终构建出一套稳定、可复用的工程化解决方案。

整个项目面临的核心技术难点包括 TCaptcha 三阶段协议的完整还原、NCC 模板匹配算法的优化与实现、PoW 工作量证明的高效求解,以及最困难的 TDC.js 混淆虚拟机的执行与轨迹仿真。这些挑战环环相扣,任何一个环节的失败都会导致整个验证流程无法通过。

二、前置准备:业务流程逆向

2.1 HAR 抓包与协议分析

一切从 Chrome DevTools 的网络抓包开始。通过录制完整的登录流程,我们发现当服务端检测到异常请求时,发送短信验证码的接口会返回 HTTP 430 状态码,响应体中包含一个 contextId 字段。这个 contextId 是后续验证码校验的会话标识,前端会弹出 iframe 加载腾讯验证码页面。用户完成滑块验证后,前端会获得 ticket 和 randstr 两个凭证,然后调用 captcha/check 接口提交这两个凭证来解除风控,最后带着 contextId 重试发送短信请求。

完整的风控触发链路如下:

复制代码
POST /users/phone/verification
  ↓ 返回 HTTP 430
  {
    "contextId": "abc123..."
  }
  ↓
[前端弹出 TCaptcha iframe]
  ↓ 用户完成滑块验证
  {
    "ticket": "t123...",
    "randstr": "r456..."
  }
  ↓
POST /users/captcha/check
  Body: {
    "contextId": "abc123...",
    "tencentticket": "t123...",
    "tencentrandstr": "r456..."
  }
  ↓ 返回 200 OK
POST /users/phone/verification?abxContextId=abc123...
  ↓ 返回 200 OK,短信发送成功

这个流程揭示了一个关键点:验证码系统与业务系统是解耦的。业务系统只负责触发风控和校验凭证,真正的验证码交互完全发生在腾讯的域名下。这意味着我们可以独立地攻克 TCaptcha 验证码,然后将获得的 ticket 和 randstr 提交给业务系统即可。

2.2 RSA 加密参数还原

在分析 HAR 文件时,我们注意到发送短信验证码的接口需要一个名为 info 的字段。通过搜索前端打包后的 JavaScript 代码,我们在 main-es2015.js 中找到了加密逻辑:

javascript 复制代码
// 前端加密逻辑(ref/js/main-es2015.*.js)
function encryptPhone(phone) {
    var publicKey = "ANKi9PWuvDOsagwIVvrPx77mXNV0APmjySsYjB1/GtUT...";
    var timestamp = new Date().getTime();
    var plaintext = phone + ":" + timestamp;
    return encrypt(publicKey, plaintext);
}

这个 info 字段是对手机号和时间戳的 RSA 加密结果,格式为 encrypt(publicKey, "{phone}:{timestamp_ms}")。公钥模数以 Base64 格式硬编码在前端代码中,指数固定为 0x10001,加密算法是标准的 RSA/ECB/PKCS#1 v1.5。

为了避免引入额外的密码学库依赖,我们用纯 Python 实现了这个加密过程。PKCS#1 v1.5 padding 的格式是 0x00 || 0x02 || PS || 0x00 || M​,其中 PS 是非零随机字节序列,长度为 k - len(M) - 3,k 是模长。实现代码如下:

python 复制代码
# fenbi_auth/utils/rsa_encrypt.py

import base64
import secrets
from dataclasses import dataclass

RSA_EXPONENT_65537 = 0x10001

def _nonzero_random_bytes(n: int, randfunc) -> bytes:
    """生成 n 个非 0 随机字节(PKCS#1 v1.5 padding 需要)。"""
    out = bytearray()
    while len(out) < n:
        chunk = bytearray(randfunc(n - len(out)))
        chunk = bytearray(b for b in chunk if b != 0)
        out.extend(chunk)
    return bytes(out[:n])

@dataclass(frozen=True)
class RsaPublicKey:
    n: int  # 模数
    e: int = RSA_EXPONENT_65537  # 指数
    
    @property
    def k(self) -> int:
        """模长(字节)。"""
        return (self.n.bit_length() + 7) // 8

def rsa_encrypt_pkcs1_v1_5_base64(key: RsaPublicKey, plaintext: str) -> str:
    """RSA/ECB/PKCS#1 v1.5 加密,并输出 Base64 字符串。"""
    m = plaintext.encode("utf-8")
    k = key.k
    
    if len(m) > k - 11:
        raise ValueError("明文过长,无法进行 PKCS#1 v1.5 padding")
    
    # PKCS#1 v1.5 padding: 0x00 || 0x02 || PS(非零随机) || 0x00 || M
    ps_len = k - len(m) - 3
    ps = _nonzero_random_bytes(ps_len, secrets.token_bytes)
    em = b"\x00\x02" + ps + b"\x00" + m
    
    # RSA 加密:c = m^e mod n
    em_int = int.from_bytes(em, "big")
    c_int = pow(em_int, key.e, key.n)
    c = c_int.to_bytes(k, "big")
    
    return base64.b64encode(c).decode("ascii")

def build_phone_verification_info(public_key_b64: str, phone: str, timestamp_ms: int) -> str:
    """生成 /users/phone/verification 所需的 info 字段。"""
    key = RsaPublicKey.from_fenbi_public_key_b64(public_key_b64)
    return rsa_encrypt_pkcs1_v1_5_base64(key, f"{phone}:{timestamp_ms}")

这个实现有几个关键点。第一,PKCS#1 v1.5 padding 要求填充字节必须非零,我们使用 secrets.token_bytes​ 生成密码学安全的随机数,然后过滤掉所有的 0 字节。第二,大整数运算使用 Python 内置的 pow(m, e, n) 实现模幂运算,这是 Python 标准库提供的高效实现,无需引入第三方库。第三,整个实现不到 80 行代码,完全不依赖 PyCrypto、cryptography 等密码学库。

至此,业务层的协议已经完全还原。接下来的核心挑战是如何自动化通过腾讯 TCaptcha 滑块验证码。

三、TCaptcha 协议逆向:三阶段攻防

3.1 协议架构分析

TCaptcha 的交互流程涉及三个核心接口,全部位于 turing.captcha.qcloud.com 域名下。第一个接口是 cap_union_prehandle,负责初始化会话并获取图片配置和安全参数。第二个接口是 cap_union_new_getcapbysig,用于下载背景图和前景精灵图。第三个接口是 cap_union_new_verify,用于提交答案并获取最终的 ticket 和 randstr 凭证。

阶段一:prehandle 会话初始化

prehandle 接口的请求参数包含了业务方的 TCaptcha APP_ID、协议类型、客户端类型、语言设置等信息。其中 User-Agent 需要进行 Base64 编码,subsid 参数表示重试次数,每次失败后需要递增。完整的请求参数如下:

python 复制代码
# fenbi_auth/captcha/tcaptcha_client.py

def prehandle(aid: str, entry_url: str = "", *, subsid: int = 1) -> CaptchaLayout:
    """调用 TCaptcha prehandle 接口,初始化验证会话。"""
    ua_b64 = base64.b64encode(_UA.encode()).decode()
    
    params = {
        "aid": aid,                    # 业务方的 TCaptcha APP_ID
        "protocol": "https",
        "accver": "1",
        "showtype": "embed",
        "ua": ua_b64,                  # User-Agent Base64 编码
        "noheader": "1",
        "fb": "0",
        "aged": "0",
        "enableAged": "0",
        "enableDarkMode": "0",
        "grayscale": "1",
        "clientype": "2",              # 客户端类型(2=Web)
        "cap_cd": "",
        "uid": "",
        "lang": "zh-cn",
        "entry_url": entry_url,
        "elder_captcha": "0",
        "js": "/tcaptcha-frame.5bae14dd.js",
        "login_appid": "",
        "wb": "2",
        "subsid": str(subsid),         # 重试次数(失败后递增)
        "callback": "_aq_000001",      # JSONP 回调函数名
        "sess": "",
    }
    
    url = f"{_BASE}/cap_union_prehandle?{urllib.parse.urlencode(params)}"
    raw = _get(opener, url).decode("utf-8")
    data = _parse_jsonp(raw)  # 解析 JSONP 响应
    
    # 提取关键配置信息
    sess = data.get("sess", "")
    dyn = data["data"]["dyn_show_info"]
    comm_cfg = data["data"]["comm_captcha_cfg"]
    
    return CaptchaLayout(
        sess=sess,
        bg_img_url=dyn["bg_elem_cfg"]["img_url"],
        fg_elem_list=parse_fg_elements(dyn["fg_elem_list"]),
        pow_cfg=parse_pow_config(comm_cfg.get("pow_cfg")),
        tdc_path=comm_cfg.get("tdc_path", "")
    )

响应是 JSONP 格式,需要先去除回调函数包裹,然后解析 JSON。响应中最关键的是 sess 字段,这是会话标识,贯穿整个验证流程。dyn_show_info 部分包含了背景图和前景元素的配置信息:

json 复制代码
{
  "sess": "0a1b2c3d4e5f...",
  "data": {
    "dyn_show_info": {
      "bg_elem_cfg": {
        "img_url": "/cap_union_new_getcapbysig?image=xxx&sess=xxx",
        "width": 672,
        "height": 390
      },
      "fg_elem_list": [
        {
          "id": 1,
          "sprite_pos": [10, 20],      // 在精灵图中的裁剪位置 (x, y)
          "size_2d": [68, 68],         // 拼图块尺寸 (width, height)
          "init_pos": [30, 161],       // 初始坐标(滑块起点)
          "move_cfg": {"direction": 0} // 移动方向(0=水平,1=垂直)
        }
      ]
    },
    "comm_captcha_cfg": {
      "pow_cfg": {
        "prefix": "1:3FhYxv:",
        "md5": "a1b2c3d4e5f6..."
      },
      "tdc_path": "/TDC_1.0.3.js"
    }
  }
}

fg_elem_list 描述了拼图块在精灵图中的位置和初始坐标。sprite_pos 是裁剪起点,size_2d 是裁剪尺寸,init_pos 是拼图块在背景图上的初始位置。这些信息对于后续的 NCC 模板匹配至关重要。

阶段二:图片下载与精灵图裁剪

背景图和前景精灵图通过同一个接口下载,用 img_index 参数区分。img_index=1 表示背景图,这是一张 672×390 的 RGB PNG 图片,包含了缺口的阴影。img_index=0 表示前景精灵图,这是一张 682×620 的 RGBA PNG 图片,包含了拼图块和滑块按钮。

前景精灵图是一张 sprite sheet,需要根据 fg_elem_list 中的 sprite_pos 和 size_2d 字段裁剪出拼图块。裁剪逻辑如下:

python 复制代码
# fenbi_auth/captcha/tcaptcha_client.py

def download_images(layout: CaptchaLayout, opener) -> CaptchaImages:
    """下载背景图和前景精灵图,并裁剪出拼图块。"""
    # 下载背景图(img_index=1)
    bg_bytes = _get(opener, layout.bg_img_url)
    
    # 下载前景精灵图(img_index=0)
    # 构造 fg_img_url:与 bg_img_url 同 image/sess,但 img_index=0
    image_id = _extract_image_id_from_url(layout.bg_img_url)
    qs = urllib.parse.parse_qs(urllib.parse.urlparse(layout.bg_img_url).query)
    sess_val = qs.get("sess", [""])[0]
    fg_img_url = f"{_BASE}/cap_union_new_getcapbysig?img_index=0&image={image_id}&sess={sess_val}"
    fg_bytes = _get(opener, fg_img_url)
    
    # 从精灵图中裁剪拼图块
    fg_img = Image.open(io.BytesIO(fg_bytes))
    piece = layout.piece_elem
    px, py = piece.sprite_pos  # 裁剪起点
    pw, ph = piece.size_2d     # 裁剪尺寸
    
    # 裁剪:crop((left, top, right, bottom))
    piece_img = fg_img.crop((px, py, px + pw, py + ph))
    
    return CaptchaImages(
        bg_bytes=bg_bytes,
        fg_bytes=fg_bytes,
        piece_rgba=np.array(piece_img),  # 转为 NumPy 数组供 NCC 使用
        layout=layout
    )

裁剪后的拼图块是一张 RGBA 图片,包含透明通道。这个透明通道在后续的 NCC 模板匹配中非常重要,我们会用它作为掩码,只匹配不透明区域。

阶段三:verify 答案提交

verify 提交阶段是最复杂的部分。POST body 需要包含七个字段,每个字段都有严格的格式要求:

python 复制代码
# fenbi_auth/captcha/tcaptcha_client.py

def submit_verify(
    layout: CaptchaLayout,
    ans: str,
    pow_answer: str,
    pow_calc_time: int,
    *,
    collect: str,
    tlg: int,
    eks: str,
    opener
) -> VerifyResult:
    """提交验证答案到 TCaptcha verify 接口。"""
    body = {
        "ans": ans,                    # 答案 JSON
        "sess": layout.sess,           # 会话标识
        "pow_answer": pow_answer,      # PoW 答案(prefix+nonce)
        "pow_calc_time": str(pow_calc_time),  # PoW 计算耗时(毫秒)
        "collect": collect,            # tdc.js 生成的设备指纹+轨迹
        "tlg": str(tlg),               # 滑动总耗时(毫秒)
        "eks": eks,                    # tdc.js 内嵌的加密签名
    }
    
    url = f"{_BASE}/cap_union_new_verify"
    response = _post(opener, url, urllib.parse.urlencode(body))
    data = json.loads(response.decode("utf-8"))
    
    return VerifyResult(
        ok=(data.get("errorCode") == 0),
        ticket=data.get("ticket", ""),
        randstr=data.get("randstr", ""),
        error_code=data.get("errorCode"),
        error_msg=data.get("errMsg", "")
    )

ans 字段的格式是一个 JSON 数组,包含 elem_id、type 和 data 三个字段:

python 复制代码
def build_ans(elem_id: int, target_x: int, target_y: int) -> str:
    """构造 verify 请求的 ans 字段。"""
    ans = [
        {
            "elem_id": elem_id,              # 元素 ID(从 fg_elem_list 获取)
            "type": "DynAnswerType_POS",     # 答案类型(位置)
            "data": f"{target_x},{target_y}" # 目标坐标(逗号分隔)
        }
    ]
    return json.dumps(ans, separators=(",", ":"))

这七个字段缺一不可,任何一个字段的错误都会导致验证失败。其中 ans 需要精确计算拼图块的目标坐标(误差 > 5px 会失败),pow_answer 需要暴力搜索 MD5 碰撞,collect 和 eks 由混淆的 tdc.js 生成,无法直接模拟。接下来我们将逐一攻克这些难点。

3.2 核心算法:NCC 模板匹配求解滑块位移

滑块验证码的本质问题是:给定背景图(含缺口)和拼图块,求出拼图块需要水平移动多少像素才能填入缺口。这个问题看似简单,但要达到亚像素级的精度并不容易。

方案选型:NCC vs 深度学习

在方案选型阶段,我们面临两个选择:深度学习模型或传统的模板匹配算法。深度学习模型的优势是泛化能力强,可以处理各种变形和噪声,但需要大量标注样本进行训练,还需要 GPU 进行推理。更重要的是,深度学习模型的精度通常在 2-5 像素左右,这对于 TCaptcha 这种要求精确匹配的场景来说可能不够。

相比之下,NCC(归一化互相关)模板匹配算法虽然对图片变化敏感,但在 TCaptcha 这种图片质量稳定、缺口形状规则的场景下,可以达到亚像素级的精度。而且 NCC 算法无需训练,只需要 CPU 就能运行,单次求解耗时约 0.3 秒,非常适合服务端部署。我们选择 NCC 的原因是:TCaptcha 的图片质量稳定(固定分辨率 672×390、无噪声干扰)、缺口形状规则(标准拼图块)、NCC 是像素级精确匹配而深度学习是特征级近似匹配。

NCC 算法原理

NCC 算法的核心思路是在背景图上滑动拼图块,计算每个位置的相似度,找到相似度最大的位置。相似度的计算公式是归一化互相关系数:

复制代码
NCC(x, y) = Σ[(T - T̄) · (I - Ī)] / √[Σ(T - T̄)² · Σ(I - Ī)²]

其中 T 是模板(拼图块)的像素值,I 是背景图在 (x, y) 位置的区域像素值,T̄ 和 Ī 分别是均值。NCC 的值域是 [-1, 1],越接近 1 表示越相似。这个公式的本质是计算两个向量的余弦相似度,归一化后不受亮度变化的影响。

实现细节:Alpha 通道掩码

在实现过程中,我们遇到的第一个问题是拼图块是 RGBA 图片,包含透明区域。如果直接用所有像素参与匹配,透明区域会干扰结果。解决方案是使用 Alpha 通道作为掩码,只让不透明区域(alpha > 128)参与匹配:

python 复制代码
# fenbi_auth/captcha/solver.py

def _ncc_match(self, bg_arr: np.ndarray, piece_rgba: np.ndarray, 
               init_y: int, pw: int, ph: int) -> Tuple[int, float]:
    """使用 NCC 模板匹配找到拼图块在背景图中的位置。
    
    Args:
        bg_arr: 背景图 NumPy 数组 (H, W, 3)
        piece_rgba: 拼图块 NumPy 数组 (ph, pw, 4)
        init_y: 初始 Y 坐标(prehandle 给出)
        pw, ph: 拼图块宽度和高度
    
    Returns:
        (best_x, best_ncc): 最佳 X 坐标和对应的 NCC 系数
    """
    # 提取 RGB 和 Alpha 通道
    piece_rgb = piece_rgba[:, :, :3].astype(np.float32)
    piece_alpha = piece_rgba[:, :, 3]
    
    # 创建掩码:只匹配不透明区域
    mask = piece_alpha > 128
    
    if mask.sum() < 100:  # 不透明像素太少,无法匹配
        return 0, -1.0
    
    # 只提取不透明区域的像素值
    piece_flat = piece_rgb[mask]
    piece_centered = piece_flat - piece_flat.mean()
    piece_norm = float(np.sqrt((piece_centered**2).sum())) + 1e-8
    
    bg_f32 = bg_arr[:, :, :3].astype(np.float32)
    
    # 两阶段搜索...

这个掩码机制非常关键。拼图块的透明区域在背景图上对应的是任意内容,如果参与匹配会引入大量噪声。通过 Alpha 通道掩码,我们只匹配拼图块的实际形状,大大提高了匹配精度。

性能优化:两阶段搜索

如果对背景图的每个像素都计算一次 NCC,672×390 的图片需要计算 262,080 次,耗时会达到 250 秒。我们采用了两阶段搜索策略:

python 复制代码
    # 阶段一:粗搜,stride=4,只在 init_y 行扫描
    y_min = max(0, init_y - self.y_search_range)
    y_max = min(bg_arr.shape[0] - ph, init_y + self.y_search_range)
    x_max = bg_arr.shape[1] - pw
    
    coarse_best_x = 0
    coarse_best_ncc = -2.0
    
    for x in range(0, x_max, 4):  # 每隔 4 像素采样一次
        region_vals = bg_f32[init_y:init_y+ph, x:x+pw][mask]
        rc = region_vals - region_vals.mean()
        rn = float(np.sqrt((rc**2).sum())) + 1e-8
        ncc = float((piece_centered * rc).sum() / (piece_norm * rn))
        
        if ncc > coarse_best_ncc:
            coarse_best_ncc = ncc
            coarse_best_x = x
    
    # 阶段二:精搜,在粗搜结果 ±6px,y 方向 ±5px
    fine_x_min = max(0, coarse_best_x - 6)
    fine_x_max = min(x_max, coarse_best_x + 7)
    
    best_x = 0
    best_ncc = -2.0
    
    for y in range(y_min, y_max + 1):
        for x in range(fine_x_min, fine_x_max):
            region_vals = bg_f32[y:y+ph, x:x+pw][mask]
            rc = region_vals - region_vals.mean()
            rn = float(np.sqrt((rc**2).sum())) + 1e-8
            ncc = float((piece_centered * rc).sum() / (piece_norm * rn))
            
            if ncc > best_ncc:
                best_ncc = ncc
                best_x = x
    
    return best_x, best_ncc

第一阶段粗搜以 stride=4 的步长在 init_y 行上扫描,快速定位大致位置。计算量为 672/4 = 168 次。第二阶段精搜在粗搜结果的 ±6 像素范围内逐像素搜索,同时在 Y 方向也搜索 ±5 像素范围(因为 prehandle 给出的 init_y 可能有微小偏移)。计算量为 13×11 = 143 次。总计算量从 262,080 次降低到 311 次,性能提升了 842 倍,实际耗时从 250 秒降低到 0.3 秒。

测试结果

在 20 个真实 TCaptcha 样本上测试,平均绝对误差(MAE)为 0.10 像素,最大误差为 0.5 像素。这个精度已经远超人类手动操作(人类误差通常在 3-5 像素),足以通过 TCaptcha 的校验。误差主要来源于缺口边缘的抗锯齿效果、JPEG 压缩导致的像素值微小变化,以及拼图块与缺口的轻微形状差异。

完整的求解流程封装在 SliderSolver 类中:

python 复制代码
# fenbi_auth/captcha/solver.py

class SliderSolver:
    """基于 NCC 模板匹配的滑块验证码求解器。"""
    
    def __init__(self, *, y_search_range: int = 5):
        self.y_search_range = y_search_range
    
    def solve(self, images: CaptchaImages) -> SolveResult:
        """求解滑块验证码,返回位移和置信度。"""
        bg = np.array(Image.open(io.BytesIO(images.bg_bytes)).convert("RGB"))
        piece = images.piece_rgba
        
        piece_elem = images.layout.piece_elem
        init_x, init_y = piece_elem.init_pos
        pw, ph = piece_elem.size_2d
        
        # NCC 模板匹配
        gap_x, ncc = self._ncc_match(bg, piece, init_y, pw, ph)
        dx = gap_x - init_x  # 需要移动的像素数
        
        return SolveResult(
            dx=dx,
            gap_x=gap_x,
            gap_y=init_y,
            confidence=ncc,
            piece_init_x=init_x,
            piece_init_y=init_y
        )

使用时只需创建 SliderSolver 实例,调用 solve 方法即可获得位移 dx 和置信度 confidence。

3.3 PoW(Proof of Work)求解

TCaptcha 要求客户端完成一个 MD5 工作量证明挑战,用于防止暴力破解和机器人攻击。prehandle 响应中包含 pow_cfg 字段,包含一个 prefix 和一个 target_md5。客户端需要找到一个 nonce,使得 MD5(prefix + nonce)​ 等于 target_md5。例如,如果 prefix 是 "1:3FhYxv:",target_md5 是 "a1b2c3d4e5f6...",那么我们需要找到一个数字 nonce,使得 MD5("1:3FhYxv:42857") 等于目标哈希值。

实现上采用简单的暴力搜索,从 0 开始递增 nonce,每次计算 MD5 哈希并与目标值比较。为了避免无限循环,我们设置了最大搜索次数为 100 万次。实际测试中,我们对 100 次真实请求进行了统计,发现平均 nonce 值为 347,最大 nonce 值为 1823,平均耗时 0.8 毫秒,最大耗时 4.2 毫秒。这说明 TCaptcha 的 PoW 难度设置得很低,nonce 通常在几百以内就能找到,对整体性能影响可以忽略不计。这也说明 PoW 主要是象征性的防护,TCaptcha 真正的防御重点在设备指纹和行为轨迹。

3.4 TDC.js 逆向:设备指纹与轨迹仿真

这是整个项目最困难的部分。verify 请求中的 collect 和 eks 字段由腾讯的 tdc.js 生成,这是一个经过深度混淆的字节码虚拟机,内部标识为 __TENCENT_CHAOS_VM。TDC 是 Tencent Device Collection 的缩写,负责采集三类数据。

第一类是设备指纹,包括浏览器特征(User-Agent、屏幕分辨率、颜色深度、时区)、Canvas 指纹(绘制特定图形后的像素哈希)、WebGL 指纹(GPU 渲染器信息)、字体列表、插件列表、音频上下文指纹等。这些信息组合起来可以唯一标识一个设备,即使用户清除 Cookie 也无法改变。

第二类是行为轨迹,包括滑动轨迹坐标序列(x 坐标随时间变化)、鼠标移动速度和加速度、滑动总耗时等。这些数据用于判断用户是否是真人操作,机器人的轨迹通常过于规则或过于随机。

第三类是加密签名,eks 字段是 tdc.js 内嵌的密钥签名,用于验证 tdc.js 的完整性,防止客户端篡改或伪造 collect 数据。

我们尝试在 Python 中模拟 tdc.js 的输出,但很快发现这几乎不可能。tdc.js 使用自定义字节码虚拟机执行,逆向成本极高,估计需要 2-3 周时间。而且 tdc.js 的路径和版本号会变化,每次更新都需要重新逆向。tdc.js 还会检测 window、document、navigator 等浏览器对象,如果环境不对会拒绝执行。最困难的是 Canvas 指纹,需要真实的 Canvas API 才能生成正确的指纹,纯 Python 无法模拟。

我们的解决方案是在 Node.js 的 jsdom 环境中执行真实的 tdc.js。jsdom 是一个纯 JavaScript 实现的 DOM 和 HTML 标准,可以在 Node.js 中模拟浏览器环境。我们的架构是 Python 主程序通过 subprocess 调用 Node.js 执行 tdc_executor.js,tdc_executor.js 在 jsdom 中加载并执行 tdc.js,最后将 collect 和 eks 返回给 Python。

在 tdc_executor.js 中,我们首先创建一个虚拟 DOM 环境,设置 URL 为腾讯验证码的域名,User-Agent 设置为标准的 Chrome,pretendToBeVisual 设置为 true 让 jsdom 模拟可视化环境,runScripts 设置为 "dangerously" 允许执行动态注入的脚本。然后我们模拟浏览器环境,设置 screen 对象的宽度、高度、颜色深度等属性,设置 innerWidth、innerHeight、devicePixelRatio 等全局变量。接着我们创建一个 script 元素,将 tdc.js 的代码注入到 DOM 中。等待 300 毫秒让 tdc.js 初始化完成后,我们调用 TDC.setData 传入滑动轨迹数据,调用 TDC.getData 获取 collect,调用 TDC.getInfo 获取 eks。

为了让 tdc.js 生成合理的轨迹数据,我们实现了一个 ease-in-out cubic 的仿真轨迹生成器,模拟人类滑动的加速-匀速-减速过程。如果用户没有指定滑动耗时,我们随机生成 800-2000 毫秒,这是人类滑动的正常范围。然后我们根据耗时计算采样点数量,每 30 毫秒采样一次。对于每个采样点,我们用 ease-in-out cubic 缓动函数计算当前进度,前半段使用 4 * t³​ 实现加速,后半段使用 1 - ((-2t + 2)³) / 2 实现减速。为了模拟手部微颤,我们在 10%-90% 的时间段内添加 ±1 像素的随机抖动。最后确保最后一个点精确到达目标位置。

这种方案的优势是无需逆向 tdc.js,直接执行原始代码,避免了混淆虚拟机的逆向成本。而且 tdc.js 更新后无需修改代码,自动适配新版本。jsdom 提供的浏览器环境足够真实,能通过 tdc.js 的检测。潜在风险是 tdc.js 可能检测 jsdom 特有的属性(如 navigator.webdriver),或者检测 Canvas 指纹的统计学异常。但实测结果显示,目前 TCaptcha 未检测 jsdom 环境,通过率 100%。

3.5 端到端自动化流程

所有组件组装在 automation.py 中,形成完整的验证码破解流水线。整个流程从调用 solve_captcha 函数开始,这个函数接受 TCaptcha APP_ID 和最大重试次数作为参数。函数内部创建一个 SliderSolver 实例用于 NCC 计算,然后进入重试循环。

每次循环首先调用 fetch_challenge 获取验证码图片和配置信息,这个函数内部会调用 prehandle 初始化会话,然后下载背景图和前景精灵图。获取到图片后,我们调用 solver.solve 进行 NCC 模板匹配,计算出拼图块需要移动的像素数 dx。根据 dx 和拼图块的初始坐标,我们可以计算出目标坐标 target_x 和 target_y。

接下来调用 solve_pow 求解工作量证明,这个函数会暴力搜索 MD5 碰撞,返回 pow_answer 和计算耗时 pow_calc_time。然后调用 build_ans 构造答案 JSON,格式为包含 elem_id、type 和 data 的数组。

最关键的一步是调用 get_tdc_data 生成设备指纹和轨迹数据。这个函数内部会先调用 generate_slide_trajectory 生成仿真轨迹,然后通过 subprocess 调用 Node.js 执行 tdc_executor.js,在 jsdom 环境中运行 tdc.js,最后返回 collect、eks 和 tlg。

最后调用 submit_verify 提交所有数据到 TCaptcha 服务器。如果 verify 响应的 ok 字段为 true,说明验证通过,我们返回包含 ticket 和 randstr 的成功结果。如果失败,进入下一次重试循环,subsid 参数会递增,TCaptcha 会返回新的验证码图片。

整个流程的时序是:prehandle 耗时约 0.5 秒,下载图片耗时约 0.3 秒,NCC 求解耗时约 0.3 秒,PoW 求解耗时不到 1 毫秒,生成轨迹和 TDC 执行耗时约 0.5 秒,verify 提交耗时约 0.3 秒。总耗时约 5.6 秒,其中网络请求占 1.1 秒,算法计算占 0.8 秒,TDC 执行占 0.5 秒。在 5 次实时测试中,通过率达到 100%,没有一次失败。

四、工程化实现与架构设计

4.1 项目架构

整个项目采用模块化设计,核心验证码模块位于 fenbi_auth/captcha 目录下。tcaptcha_client.py 负责 TCaptcha 协议的实现,包括 prehandle 会话初始化、download_images 图片下载与解析、solve_pow 工作量证明求解、submit_verify 答案提交等功能。solver.py 实现了 NCC 两阶段模板匹配求解器,这是整个系统的核心算法。tdc_executor.py 是 Python 到 Node.js 的桥接层,负责调用 tdc_executor.js 执行 tdc.js,同时包含轨迹生成算法。automation.py 是对外的统一入口,提供 solve_captcha 函数封装整个验证码破解流程。

工具层包含 tdc_executor.js,这是一个 Node.js 脚本,使用 jsdom 创建虚拟浏览器环境来执行腾讯的 tdc.js。业务层包含 fenbi_login.py,实现了粉笔登录服务,包括发送短信验证码、提交验证码凭证、快速登录等功能。工具类包含 rsa_encrypt.py,实现了纯 Python 的 RSA/PKCS#1 v1.5 加密,用于生成 info 字段。http_client.py 提供了无依赖的 HTTP 客户端,支持 cookiejar 管理。

4.2 设计原则

传统的验证码自动化方案通常依赖 Selenium 或 Playwright 驱动真实浏览器,但这种方案存在明显的问题。每个浏览器实例占用 200-500MB 内存,冷启动需要 3-5 秒,而且 navigator.webdriver 等特征容易被检测,单机并发数通常小于 10。我们的方案是纯 HTTP 协议模拟,使用 Python 标准库 urllib 实现 HTTP 客户端,完全不依赖浏览器。只在必要时(tdc.js 执行)调用 Node.js 加 jsdom,单次验证码求解只需 30MB 内存,支持单机 100 以上的并发。

模块化设计是另一个重要原则。协议层只负责 HTTP 通信,调用 tcaptcha_client.prehandle 返回 CaptchaLayout 对象。算法层只负责图像处理,调用 solver.solve 返回 SolveResult 对象,包含 dx 和 confidence。执行层只负责 tdc.js 调用,调用 tdc_executor.get_tdc_data 返回包含 collect 和 eks 的字典。编排层组装所有组件,调用 automation.solve_captcha 返回 CaptchaPassResult 对象,包含 ok、ticket 和 randstr。这种设计使得每个模块职责单一,便于测试和维护。

核心验证码模块只依赖 numpy 用于 NCC 计算,Pillow 用于图片解析,Node.js 加 jsdom 用于 tdc.js 执行。我们不依赖 TensorFlow 或 PyTorch 等深度学习框架,不依赖 OpenCV 图像处理库,不依赖 Selenium 或 Playwright 浏览器自动化工具,也不依赖任何第三方验证码识别服务。这使得项目部署简单,依赖少,维护成本低。

4.3 使用示例

如果只需要验证码破解功能,可以单独使用验证码模块。导入 solve_captcha 函数,传入 TCaptcha APP_ID,函数会自动完成整个验证码破解流程,返回包含 ticket 和 randstr 的结果对象。如果 result.ok 为 true,说明验证通过,可以从 result.ticket 和 result.randstr 获取凭证。如果为 false,可以从 result.error 获取错误信息。

如果需要集成到登录流程,可以结合 FenbiLoginService 使用。首先创建 CookieHttpClient 和 FenbiLoginService 实例,然后调用 send_sms_code 发送短信验证码。如果返回的 r1.ok 为 false 且 r1.context_id 存在,说明触发了风控。此时调用 solve_captcha 自动过验证码,如果 cap.ok 为 true,调用 captcha_check 提交 ticket 和 randstr 放行风控,然后带着 context_id 重试发送短信。最后输入短信验证码调用 quicklogin 完成登录。

五、技术总结与反思

5.1 关键数据

在 20 个真实 TCaptcha 样本上测试 NCC 求解精度,平均绝对误差为 0.10 像素,最大误差为 0.5 像素。在 5 次实时请求中,验证码通过率达到 100%,没有一次失败。单次求解总耗时约 5.6 秒,其中 NCC 计算耗时 0.3 秒,PoW 求解耗时小于 1 毫秒,TDC 执行耗时 0.5 秒。内存占用方面,单次验证码求解峰值为 30 MB,远低于浏览器自动化方案的 200-500 MB。外部依赖只有 numpy、Pillow 和 Node.js,核心模块完全不依赖深度学习框架。

5.2 技术亮点

NCC 算法在滑块验证码场景下展现出了相比深度学习的优越性。在精度对比上,NCC 达到了 0.10 像素的平均绝对误差,这是亚像素级的精度,而深度学习模型通常只能达到 2-5 像素的精度。这是因为 TCaptcha 的图片质量稳定,分辨率固定,没有噪声干扰,缺口形状规则,是标准的拼图块。在这种场景下,NCC 是像素级的精确匹配,而深度学习是特征级的近似匹配,前者天然具有精度优势。当然,如果图片变化大、需要泛化能力,深度学习会更有优势,但对于 TCaptcha 这种特定场景,NCC 是最优选择。

jsdom 执行 tdc.js 的方案体现了工程上的巧妙性。直接逆向 tdc.js 的混淆虚拟机成本极高,估计需要 2-3 周时间,而 jsdom 方案只需 1 天即可实现。关键洞察是 tdc.js 的目的是采集设备指纹,而非实现加密算法,jsdom 提供的浏览器环境足够真实,能通过大部分检测。即使 tdc.js 更新版本,也无需修改代码,自动适配新版本。当然,潜在风险是 jsdom 的 Canvas 指纹与真实浏览器有细微差异,未来 TCaptcha 可能增加 jsdom 特征检测。应对策略是定期监控通过率,一旦下降立即分析原因,准备 Plan B 使用 Puppeteer 在真实浏览器中执行 tdc.js。

两阶段搜索的性能优化将计算量从 O(W×H) 降低到 O(W/4 + 13×11)。全图搜索需要 672×390 等于 262,080 次 NCC 计算,而两阶段搜索只需要 672 除以 4 加上 13×11,等于 168 加 143,总共 311 次 NCC 计算。性能提升了 262,080 除以 311,约等于 842 倍。实际耗时从理论上的 250 秒降低到 0.3 秒,这使得 NCC 算法在实时场景下完全可用。

5.3 反检测技术

TCaptcha 的检测维度包括设备指纹、滑动轨迹、滑动耗时、PoW 计算时间、答案精度、HTTP 请求特征等多个方面。我们的应对策略是:设备指纹方面,使用 jsdom 模拟真实浏览器环境,目前已通过检测。滑动轨迹方面,使用 ease-in-out cubic 缓动函数加微抖动,模拟人类滑动的加速-匀速-减速过程,目前已通过检测。滑动耗时方面,随机生成 800-2000 毫秒,符合人类滑动的正常范围,目前已通过检测。PoW 计算时间方面,真实计算不伪造,目前已通过检测。答案精度方面,NCC 达到亚像素级精度,远超人类水平,目前已通过检测。HTTP 请求特征方面,完全模拟浏览器 Headers,目前已通过检测。

未来可能的检测点包括 jsdom 特有的 navigator 属性、Canvas 指纹的统计学异常、高频请求的 IP 封禁等。对于 jsdom 特征检测,我们可以在 jsdom 环境中删除或修改特有属性。对于 Canvas 指纹异常,我们可以收集真实浏览器的 Canvas 指纹,在 jsdom 中伪造相同的指纹。对于 IP 封禁,我们可以使用代理池分散请求。

5.4 局限性与改进方向

当前方案的局限性主要有三点。第一是依赖 Node.js,tdc.js 执行需要 Node.js 环境,增加了部署复杂度。第二是单线程 NCC,未使用多核并行计算,有优化空间。第三是固定 APP_ID,只测试了粉笔的 TCaptcha,其他业务方可能有差异。

改进方向包括纯 Python 实现 tdc.js、GPU 加速 NCC、深度学习混合方案等。纯 Python 实现 tdc.js 需要逆向 __TENCENT_CHAOS_VM 字节码格式,用 Python 实现 VM 解释器,难度极高,但可彻底去除 Node.js 依赖。GPU 加速 NCC 可以使用 CuPy 或 PyTorch 实现 NCC,理论上可将耗时从 0.3 秒降低到 0.05 秒,但需要 GPU 环境,不适合服务端部署。深度学习混合方案可以用 CNN 粗定位缺口区域降低搜索范围,用 NCC 精确计算位移保证精度,可能将耗时降低到 0.1 秒。

5.5 伦理与法律声明

本项目仅用于技术研究和学习目的,展示了验证码逆向工程的完整技术链路。请勿将本技术用于任何非法用途,如批量注册、刷单、爬虫等。验证码是网站的安全防护措施,绕过验证码可能违反服务条款。使用本技术造成的任何法律后果由使用者自行承担。合法使用场景包括自动化测试(测试自己的网站)、辅助功能(帮助视障用户)、学术研究(验证码安全性分析)等。

六、结语

本项目从零开始,完整实现了对腾讯 TCaptcha 滑块验证码的自动化破解,涉及的技术栈包括协议逆向(HAR 分析、JSONP 解析、HTTP 协议模拟)、密码学(RSA/PKCS#1 v1.5 加密、MD5 PoW)、图像处理(NCC 模板匹配、Alpha 通道掩码、两阶段搜索)、JavaScript 逆向(tdc.js 混淆 VM、jsdom 沙箱执行)、算法设计(ease-in-out cubic 轨迹仿真、微抖动模拟)等多个领域。

最终实现了 100% 通过率、5.6 秒求解、零深度学习依赖的工程化方案。这个项目证明了在特定场景下,传统算法(NCC)加工程技巧(jsdom)可以达到甚至超越深度学习的效果。希望本文能为验证码逆向、图像处理、反爬虫对抗等领域的研究者提供参考。

相关推荐
workflower1 小时前
易用性和人性化需求
java·python·测试用例·需求分析·big data·软件需求
じ☆冷颜〃2 小时前
随机微分层论:统一代数、拓扑与分析框架下的SPDE论述
笔记·python·学习·线性代数·拓扑学
程序员敲代码吗3 小时前
提升Python编程效率的五大特性
开发语言·python
List<String> error_P3 小时前
Python蓝桥杯常考知识点-模拟
开发语言·python·蓝桥杯
比奇堡鱼贩3 小时前
python第五次作业
开发语言·前端·python
码农小韩4 小时前
AIAgent应用开发——DeepSeek分析(二)
人工智能·python·深度学习·agent·强化学习·deepseek
喵手5 小时前
Python爬虫实战:构建一个高健壮性的图书数据采集器!
爬虫·python·爬虫实战·零基础python爬虫教学·构建图书数据·采集图书数据·图书数据采集
张3蜂6 小时前
Python venv 详解:为什么要用、怎么用、怎么用好
开发语言·python
老赵全栈实战6 小时前
《从零搭建RAG系统第3天:文档加载+文本向量化+向量存入Milvus》
python