协议过某瓣验证码:手撕collect/eks加密参数(附源码)

协议过某瓣验证码:手撕collect/eks加密参数(附源码)

上篇文章我们讲了某瓣的自动化过登录验证码,今天回到JS逆向的方向。通过JS逆向接口加密参数的生成方式,在本地自己构建加密参数,最后把构建的加密参数和需要提交的参数一同封装发送给服务器校验。

本文目录

  1. 整体校验流程梳理
  2. 抓包分析:接口请求顺序
  3. 获取验证码:关键参数提取
  4. 图片下载与OpenCV偏移计算
  5. 加密参数逆向:collect与eks
  6. tdc.js代码扣取与环境补全
  7. Python完整流程串联
  8. 常见问题与解决方案
  9. 技术总结
  10. 课件下载

一、整体校验流程梳理

先来理一下大致思路,方便我们理解和执行。整体分为以下几个步骤:

  1. 登录是否需要进行校验
  2. 获取验证码
  3. 计算滑动位置
  4. 校验验证数据是否正确,正确则返回ticket
  5. 再次登录带上ticket完成最终校验

这就是整个校验流程了。

图1:整体校验流程图

二、抓包分析:接口请求顺序

先来抓包看一下都发生了哪些请求。打开浏览器开发者工具,进入登录页面,登录并滑动滑块,可以看到接口的请求顺序和上面逻辑一致:

login/basic
cap_union_prehandle
cap_union_new_getcapbysig
cap_union_new_verify
login/basic

经过这样梳理,整个流程的脉络是不是就清晰了?我们只需要按照这个流程一步一步进行开发就行了。

图2:抓包接口请求顺序

三、获取验证码:关键参数提取

登录请求没什么好说的,你们直接看一下就行。获取验证码的话,直接把请求从浏览器拷贝下来,让AI转成Python代码,其他什么也不用改直接请求。

需要注意的是,返回的内容除了背景图和滑块图,我们还需要以下几个参数,后面校验的时候需要传递:

{
    "sess": data["sess"],
    "bg_url": "图片网址" + dyn_info["bg_elem_cfg"]["img_url"],
    "sprite_url": "图片网址" + dyn_info["sprite_url"],
    "pow_prefix": pow_cfg["prefix"],
    "top_original": dyn_info["fg_elem_list"][1]["init_pos"][1]  # 滑块初始Y坐标
}
图3:验证码接口返回的关键参数

四、图片下载与OpenCV偏移计算

背景图和滑块图的地址有了,那我们必须下载到本地,通过OpenCV计算出偏移量的位置。

我把上篇文章的get_slide_distance函数简单做了修改。传递的top_original就是上面获取到的["init_pos"][1]值,这个函数会返回滑动块在原始图片中的x,y坐标。

# ==================== 图片下载 ====================
def download_image(url, save_path):
    """下载图片到本地"""
    headers = {"User-Agent": "Mozilla/5.0", "Referer": "脱敏"}
    resp = requests.get(url, headers=headers, timeout=10)
    with open(save_path, "wb") as f:
        f.write(resp.content)
    print(f"已下载: {save_path}")

# ==================== 缺口识别 ====================
def get_slide_distance(bg_path, slider_path, top_original=230, offset=-2.8562):
    """
    计算滑块滑动距离
    top_original: 滑块原始Y坐标,从配置获取,默认230
    """
    # 常量
    rate = 278 / 672  # 0.41369047619047616
    slider_width_display = 49.6429

    # 图片处理
    bg = cv2.imread(bg_path)
    slider_big = cv2.imread(slider_path, cv2.IMREAD_UNCHANGED)
    slider = slider_big[490:610, 140:260]  # 裁剪小滑块
    slider_gray = slider[:, :, 3]
    bg_gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
    bg_edges = cv2.Canny(bg_gray, 100, 200)
    slider_edges = cv2.Canny(slider_gray, 100, 200)

    # 模板匹配
    result = cv2.matchTemplate(bg_edges, slider_edges, cv2.TM_CCOEFF_NORMED)
    _, _, _, max_loc = cv2.minMaxLoc(result)

    # 计算缺口中心在原图上的X坐标
    center_original = max_loc[0] + 60

    # 换算到显示坐标下的 left 值
    left_display = center_original * rate - slider_width_display / 2

    # 计算 top 显示坐标
    top_display = top_original * rate

    # 返回提交服务器需要的格式
    x = int(left_display / rate)   # = int(center_original + offset)
    y = int(top_display / rate)    # = top_original

    print(f"模板匹配位置 max_loc[0]: {max_loc[0]}")
    print(f"缺口中心原图X: {center_original:.4f}")
    print(f"计算出的 left_display: {left_display:.4f}")
    print(f"计算出的 top_display: {top_display:.4f}")
    print(f"最终提交坐标: {x},{y}")

    return f"{x},{y}"
图4:OpenCV计算滑块偏移位置

五、加密参数逆向:collect与eks

最后就是把这些数据整合,封包进行请求校验。我们来看看 cap_union_new_verify 这个请求都传递了哪些数据。

通过分析,请求参数包括 sessanscollecttlgekspow_answerpow_calc_time。其中 sesspow_prefix 从上一步获取,ans 由 OpenCV 计算得出,pow_answerpow_calc_time 按规则拼接即可。需要 JS 逆向处理的是 collecteks 这两个加密参数

{
  "collect": "jCcHiGhwQx%2Fs4gy%2BvW...",
  "tlg": "1752",
  "eks": "w%2FXX9eDOvRaNRwH42CWt2...",
  "sess": "s0h3umpooZYF7naMdmwQ96...",
  "ans": "[{\"elem_id\":1,\"type\":\"DynAnswerType_POS\",\"data\":\"344,137\"}]",
  "pow_answer": "97e6134ea4683b5#88822",
  "pow_calc_time": "423"
}

先给 cap_union_new_verify 下一个XHR断点,跟一下栈看看 collecteks 是在哪里生成的。最终通过堆栈向上不断地跟踪,发现 collecteks 是在堆栈 e.verify 函数里面赋值的。

e.prototype.verify = function(e, t, r) {
    var i, n = this, a = decodeURIComponent((0,
    o.getTdcData)()), s = (0,
    o.getKeyInfo)(), c = {
        collect: a,
        tlg: a.length,
        eks: s,
        sess: this.sess,
        ans: JSON.stringify(e)
    }, u = this.workLoadData, d = u.workloadAns, l = u.workloadDuration, p = u.workloadNonce;
    u.runWorkload && (c.pow_answer = null !== d && d.length > 0 ? "".concat(p).concat(d) : d,
    c.pow_calc_time = l);
    // ...后续代码
}

collect 是由 a 赋值,a 又是 o.getTdcDataeks 同理是由 s 赋值,s 又是 o.getKeyInfo 赋的值。

图5:堆栈分析定位加密参数

六、tdc.js代码扣取与环境补全

继续跟踪到了下面两个函数。其中 getKeyInfo 函数是进行了外包装的,原型是 c 函数。跟到这里就差不多了,再跟下去没啥意义了,因为会进入 tdc.js 核心算法文件,里面全是虚拟机对字节码压栈出栈等操作,很难分析。

function c() {
    return window.TDC && "function" == typeof window.TDC.getInfo && window.TDC.getInfo() || {}
}

t.getTdcData = function() {
    return a({
        ft: (0,
        n["default"])()
    }),
    window.TDC && "function" == typeof window.TDC.getData ? window.TDC.getData(!0) || "---" : "------"
}

想纯算的可以参考一下看雪这位大神的文章《使用AI还原腾讯点选验证码算法-动态jsvmp》.

6.1 扣取tdc.js并创建调用文件

还是老规矩,把 tdc.js 文件拷贝下来开始补环境。先创建一个调用文件,把上面的函数调用给实现一下。

需要注意的是,tdc.js 是动态的,每次刷新后内容都会不一样。只需要拷贝一份 tdc.js 保存到本地就行了。

图6:tdc.js文件

6.2 环境补全

创建一个 env.js 补环境,这次的补环境代码有点多,就不在这里贴出了。基本上的浏览器基础环境已经补好,剩下的就需要你们自行发挥了。

  • ✅ Window、Document、Navigator、Screen、Location
  • ✅ EventTarget、Event、MouseEvent、TouchEvent
  • ✅ WebGL、Canvas、XMLHttpRequest
  • ✅ Storage、PluginArray、MimeTypeArray
  • ✅ createDataChannel(已添加)
  • ✅ MouseEvent 的 offsetX/pageX 等属性(已添加)
  • ✅ v_getele/v_geteles 不返回 null(已处理)

七、Python完整流程串联

到这一步,我们的整个流程已经完成了。下面用 Python代码 把整个流程给串联起来:

登录 → 获取验证码 → 下载图片 → OpenCV计算偏移 → 生成加密参数 → 发送校验

完整代码如下:

import requests
import json
import re
import time
import cv2
import execjs

LOGIN_API = "脱敏处理/j/mobile/login/basic"

# ==================== JS 初始化 ====================
def init_js_context(js_file_path="main.js"):
    """初始化 execjs 上下文"""
    with open(js_file_path, 'r', encoding='utf-8') as f:
        js_code = f.read()
    return execjs.compile(js_code)


# ==================== 验证码配置获取 ====================
def get_captcha_config(aid="2044348370"):
    """获取验证码配置(sess、图片URL、pow参数)"""
    url = "https://turing.captcha.qcloud.com/cap_union_prehandle"
    params = {
        "aid": aid,
        "protocol": "https",
        "accver": "1",
        "showtype": "popup",
        "ua": "TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NCkgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE0OC4wLjAuMCBTYWZhcmkvNTM3LjM2",
        "noheader": "1",
        "fb": "1",
        "aged": "0",
        "enableAged": "0",
        "enableDarkMode": "0",
        "grayscale": "1",
        "clientype": "2",
        "cap_cd": "",
        "uid": "",
        "lang": "zh-cn",
        "entry_url": "https://脱敏处理/passport/login_popup",
        "elder_captcha": "0",
        "js": "/tcaptcha-frame.85eb58a4.js",
        "login_appid": "",
        "wb": "1",
        "subsid": "1",
        "callback": "_aq_517162",
        "sess": ""
    }
    headers = {
        'Accept': '*/*',
        'Referer': 'https://脱敏处理/',
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    }
    try:
        r = requests.get(url, params=params, headers=headers, timeout=10)
        json_str = re.search(r'\{.*\}', r.text).group()
        data = json.loads(json_str)
        dyn_info = data["data"]["dyn_show_info"]
        pow_cfg = data["data"]["comm_captcha_cfg"]["pow_cfg"]
        return {
            "sess": data["sess"],
            "bg_url": "https://脱敏处理" + dyn_info["bg_elem_cfg"]["img_url"],
            "sprite_url": "https://脱敏处理" + dyn_info["sprite_url"],
            "pow_prefix": pow_cfg["prefix"],
            "top_original": dyn_info["fg_elem_list"][1]["init_pos"][1]  # 滑块初始Y坐标
        }
    except Exception as e:
        print(f"获取验证码配置失败: {e}")
        return None


# ==================== 图片下载 ====================
def download_image(url, save_path):
    """下载图片到本地"""
    headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://脱敏处理/"}
    resp = requests.get(url, headers=headers, timeout=10)
    with open(save_path, "wb") as f:
        f.write(resp.content)
    print(f"已下载: {save_path}")


# ==================== 缺口识别 ====================
def get_slide_distance(bg_path, slider_path, top_original=230, offset=-2.8562):
    """
    计算滑块滑动距离
    top_original: 滑块原始Y坐标,从配置获取,默认230
    """
    # 常量
    rate = 278 / 672  # 0.41369047619047616
    slider_width_display = 49.6429

    # 图片处理
    bg = cv2.imread(bg_path)
    slider_big = cv2.imread(slider_path, cv2.IMREAD_UNCHANGED)
    slider = slider_big[490:610, 140:260]  # 裁剪小滑块
    slider_gray = slider[:, :, 3]
    bg_gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
    bg_edges = cv2.Canny(bg_gray, 100, 200)
    slider_edges = cv2.Canny(slider_gray, 100, 200)

    # 模板匹配
    result = cv2.matchTemplate(bg_edges, slider_edges, cv2.TM_CCOEFF_NORMED)
    _, _, _, max_loc = cv2.minMaxLoc(result)

    # 计算缺口中心在原图上的X坐标
    center_original = max_loc[0] + 60

    # 换算到显示坐标下的 left 值
    left_display = center_original * rate - slider_width_display / 2

    # 计算 top 显示坐标
    top_display = top_original * rate

    # 返回提交服务器需要的格式
    x = int(left_display / rate)   # = int(center_original + offset)
    y = int(top_display / rate)    # = top_original

    print(f"模板匹配位置 max_loc[0]: {max_loc[0]}")
    print(f"缺口中心原图X: {center_original:.4f}")
    print(f"计算出的 left_display: {left_display:.4f}")
    print(f"计算出的 top_display: {top_display:.4f}")
    print(f"最终提交坐标: {x},{y}")

    return f"{x},{y}"


# ==================== 验证码提交 ====================
def submit_captcha(sess, distance, pow_prefix, collect_data, eks):
    """提交滑块验证结果"""
    url = "https://脱敏处理/cap_union_new_verify"

    # distance 格式是 "x,y"
    ans = json.dumps([{
        "elem_id": 1,
        "type": "DynAnswerType_POS",
        "data": distance
    }])

    timestamp = int(time.time() * 1000)
    pow_answer = f"{pow_prefix}{timestamp}"

    data = {
        "sess": sess,
        "ans": ans,
        "pow_answer": pow_answer,
        "pow_calc_time": str(timestamp),
        "collect": collect_data,
        "tlg": len(collect_data),
        "eks": eks
    }
    print(data)
    headers = {
        'Accept': 'application/json, text/javascript, */*; q=0.01',
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        'Origin': 'https://t脱敏处理',
        'Referer': 'https://脱敏处理/',
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    }

    try:
        r = requests.post(url, headers=headers, data=data, timeout=10)
        return r.json()
    except Exception as e:
        print(f"提交验证失败: {e}")
        return None


# ==================== 登录 ====================
def douban_login(username, password, ticket, randstr):
    """使用验证码ticket登录"""
    headers = {
        'Accept': 'application/json, text/plain, */*',
        'Content-Type': 'application/x-www-form-urlencoded',
        'Referer': 'https://accounts.douban.com/passport/login_popup?login_source=anony',
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
    }
    data = {
        "name": username,
        "password": password,
        "remember": "true",
        "ticket": ticket,
        "randstr": randstr
    }
    try:
        r = requests.post(LOGIN_API, headers=headers, data=data, timeout=10)
        return r.json()
    except Exception as e:
        print(f"登录失败: {e}")
        return None


# ==================== 主流程 ====================
def main():
    # 1. 获取验证码配置
    print("1. 获取验证码配置...")
    config = get_captcha_config()
    if not config:
        print("获取配置失败")
        return
    print(f"   sess: {config['sess'][:50]}...")

    # 2. 下载图片
    print("\n2. 下载图片...")
    download_image(config['bg_url'], "bg.png")
    download_image(config['sprite_url'], "slider.png")

    # 3. 识别缺口位置
    print("\n3. 识别缺口位置...")
    distance = get_slide_distance("bg.png", "slider.png", top_original=config['top_original'])
    print(f"   滑动距离: {distance}")
  
    # 4. 获取 collect 和 eks
    print("\n4. 获取collect和eks...")
    ctx = init_js_context("main.js")
    collect_data = ctx.call("getCollectData")
    eks = ctx.call("getEks")
    print(f"   collect长度: {len(collect_data)}")
    print(f"   eks: {eks[:50] if len(eks) > 50 else eks}")

    # 5. 提交验证
    print("\n5. 提交滑动验证...")
    result = submit_captcha(
        sess=config["sess"],
        distance=distance,
        pow_prefix=config["pow_prefix"],
        collect_data=collect_data,
        eks=eks
    )

    if not result:
        print("提交失败")
        return

    print(f"   验证结果: {result}")

    if result.get("ticket"):
        print(f"\n   验证成功!ticket: {result['ticket'][:50]}...")
    else:
        print(f"\n   验证失败: {result.get('message', '未知错误')}")


if __name__ == "__main__":
    main()
图7:Python完整运行效果

八、常见问题与解决方案

  • Q:tdc.js每次刷新都不一样怎么办?
    A:每次需要重新拷贝一份最新的tdc.js到本地,或者通过脚本动态获取。
  • Q:OpenCV计算的偏移量不准确?
    A:微调get_slide_distance函数中的常量值(rate、offset等)。
  • Q:Node.js调用collect/eks返回undefined?
    A:检查env.js环境补全是否完整,特别是Window和DOM相关对象。
  • Q:校验失败返回错误码?
    A:检查sess是否过期,ans格式是否正确,collect是否为空。

九、技术总结

本篇文章完整演示了从抓包分析到本地生成加密参数的JS逆向全流程。核心难点在于:

  • 验证码图片的尺寸换算与OpenCV偏移计算
  • tdc.js动态加载与虚拟机代码分析
  • Node.js环境补全与collect/eks参数生成
  • Python完整流程串联与异常处理

这套方案可以适配大部分同类滑动验证码的校验流程

十、课件下载

👉 获取完整Python代码课件:https://pan.quark.cn/s/17dfbd58b702?pwd=8Eda

🔔 后续代码持续更新,欢迎关注公众号【孤狼网络科技

温馨提示:本文技术仅用于学习研究,请遵守相关法律法规,不得用于非法用途。

Comments

No comments yet. Why don’t you start the discussion?

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注