滑块验证码前端安全研究:以数美(Ishumei)风控 SDK 为例


免责声明: 本文所有分析均基于数美官网注册演示页(https://www.ishumei.com/account/register.html)的公开前端 JS 代码,仅用于安全研究、学习与了解验证码防护机制。文中涉及的 organization、接口域名等均为数美官方演示环境参数。请勿将本文技术用于任何未授权的系统,违者后果自负。

一、背景介绍

本文以数美(Ishumei)风控 SDK protocol=206 新版的滑块验证码为研究对象,分析其前端安全机制。研究入口为数美官网注册演示页:

复制代码
https://www.ishumei.com/account/register.html

基本交互逻辑:

  1. 前端请求 register 接口,获取背景图(bg)、缺口图(fg)、会话标识(rid)及服务端密钥种子(k/l)
  2. 用户拖动滑块到缺口位置,前端实时记录鼠标轨迹
  3. 前端将滑动距离比例、轨迹、耗时、环境检测结果 等 12 个参数逐一加密后,提交到 fverify 接口
  4. 服务端解密参数,综合判断是否为人工滑动,返回 riskLevel

研究核心难点:

  • 识别缺口距离(计算机视觉)
  • 还原多字段加密体系(12 个字段各自独立加密,引入动态密钥派生)
  • 仿真轨迹生成(行为检测绕过)

二、整体流程图

复制代码
本地生成 captchaUuid(时间戳 + 16位随机字符)
本地生成 callback('sm_' + Date.now())
  ↓
register 接口(GET https://captcha1.fengkongcloud.cn/ca/v1/register)
  ↓ 返回 bg、fg、rid、密钥种子 k(Base64)、l(有效长度)
派生动态密钥 __key = DES('xxxxx', base64Decode(k), decrypt)[:l]
  ↓
下载 bg / fg 图片(https://castatic.fengkongcloud.cn)
ddddocr 识别缺口距离 distance(注意 /2 Retina 校正)
  ↓
四段式仿真轨迹生成 track [[x,y,t], ...]
  ↓
12 个字段各自:sm_stringify(原始值) → DES-ECB → Base64
  ↓
fverify 接口(GET https://captcha1.fengkongcloud.cn/ca/v2/fverify)
  ↓ 返回 riskLevel: PASS / REJECT

三、抓包分析

3.1 register 接口

请求:

复制代码
GET https://captcha1.fengkongcloud.cn/ca/v1/register

关键请求参数:

参数 来源 说明
captchaUuid 本地生成 generateTimeFormat() + 16 位随机字符,算法在 sdk.js 11309-11334 行
callback 本地生成 'sm_' + Date.now(),JSONP 回调名,register 和 verify 必须一致
model 固定 slide 表示滑块类型
appId 固定 default
organization 演示配置 业务方唯一标识,来自 new SmCaptcha({organization: '...'}) 初始化
sdkver SDK 常量 来自 smConfig.SDKVER,当前演示版本 1.1.3
rversion SDK 常量 来自 smConfig.VERSION,当前演示版本 1.0.4
lang 固定 zh-cn
channel 固定 default
data 固定 {}

captchaUuid 生成算法(来自 sdk.js getCaptchaUuid):

python 复制代码
def generate_captcha_uuid():
    """
    还原 sdk.js getCaptchaUuid() 的生成逻辑(sdk.js 11309-11334 行)。
    算法:generateTimeFormat() + 16 位随机字符串

    generateTimeFormat() 还原:
        年(4) + 月补零(2) + 日补零(2) + 时补零(2) + 分补零(2) + 秒补零(2)
        例:20260415156322

    随机字符集(sdk.js 'EDdNk' 字段解码值):
        ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678

    最终示例:20260415156322rD3cbQ24mf4GDdwDY
    """
    from datetime import datetime
    d = datetime.now()
    time_part = (str(d.year) + str(d.month).zfill(2) + str(d.day).zfill(2)
                 + str(d.hour).zfill(2) + str(d.minute).zfill(2) + str(d.second).zfill(2))
    char_pool = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'
    random_part = ''.join(random.choice(char_pool) for _ in range(16))
    return time_part + random_part

响应示例(JSONP 格式):

json 复制代码
sm_1776226502693({
  "code": 1100,
  "detail": {
    "bg": "/path/to/background.jpg",
    "fg": "/path/to/foreground.png",
    "rid": "xxxxxxxxxxxxxxxx",
    "k": "<Base64编码的密钥种子>",
    "l": 8
  }
})

注意: 响应为 JSONP 格式,回调函数名含毫秒时间戳,长度不固定,不能用硬编码偏移量(如 [17:-1])截取,需动态定位括号。

新版协议(206)关键新增字段: detail.k(Base64 密钥种子)和 detail.l(有效长度),用于本次会话的动态密钥派生。

3.2 fverify 接口

请求:

复制代码
GET https://captcha1.fengkongcloud.cn/ca/v2/fverify

verify 接口包含 12 个加密参数:

参数名 原始值语义 类型 加密 Key(来自 smCaptcha.js)
wi mouseEndX / trueWidth(滑动比例) float 需自行逆向
gq 完整轨迹数组 [[x,y,t],...] array 需自行逆向
vs endTime - startTime(总耗时 ms) int 需自行逆向
lx trueWidth(背景展示宽度 px) int 需自行逆向
es trueHeight(背景展示高度 px) int 需自行逆向
jq console 检测结果(固定 false bool 需自行逆向
zm runBotDetection()(固定 false bool 需自行逆向
tx 固定值 -1 int 需自行逆向
ww appId(固定 "default" string 需自行逆向
bb channel(固定 "default" string 需自行逆向
vj lang(固定 "zh-cn" string 需自行逆向
hq safeParams"10" 表示正常浏览器) string 需自行逆向

以上 Key 均为 8 字节固定字符串,来自反混淆后的 smCaptcha.js getMouseAction() 函数,每个字段对应独立 Key。

协议字段(明文传输):

参数 来源
organization 演示环境标识 SmCaptcha 初始化参数
act.os web_pc 固定
ostype web 固定
protocol 206 smConfig 常量
sdkver 1.1.3 smConfig.SDKVER
rversion 1.0.4 smConfig.VERSION
rid register 响应值 会话标识,原样透传
captchaUuid 同 register 保持一致
callback 同 register 保持一致

四、JS 逆向------SDK 反混淆分析

4.1 混淆特征识别

SDK(sdk.js)使用了 obfuscator.io 风格的混淆,主要特征:

  • 巨型字符串数组 + 数字索引间接访问(如 _0xXXXX[123]
  • 控制流平坦化while(!![]) { switch(...) { case ... } }
  • 所有字符串常量收进数组后乱序存储,如 _0x5dfd77['organization'](sdk.js 6114 行)

4.2 反混淆工具

推荐使用 webcrack,专为 obfuscator.io 设计,支持字符串数组还原 + 控制流还原 + browserify 解包:

bash 复制代码
npm install -g webcrack
webcrack sdk.js -o sdk_deobf/

webcrack 会自动识别 browserify bundle 并按模块解包输出,得到 smCaptcha.jssmEncrypt.jssmStringify.js 等可读文件。

4.3 定位加密逻辑

反混淆后,在 smCaptcha.jsgetMouseAction() 函数中可直接找到 slide 模式的加密组包逻辑。

每个字段统一调用 getEncryptContent(value, hardcodedKey) 完成加密,处理链路:

  1. 选择密钥 :优先使用传入的硬编码 Key,未传入则回退到动态派生的 __key
  2. 序列化 :调用自定义 smStringify(value) 转为字符串(非标准 JSON,见第五节)
  3. 加密:DES-ECB + Zero Padding
  4. 编码:密文 Base64 编码后作为参数值

slide 模式下,gq 字段在 smCaptcha.js 706 行明确传入了硬编码 Key xxxxx,因此 __key 对 slide 核心字段实际不生效

4.4 确认加密算法

通过分析反混淆后的 smEncrypt.js(294 行)可确认:

  • 加密算法:标准 DES-ECB,Zero Padding(不足 8 字节补 \x00
  • 输出格式:Base64 编码字符串
  • 12 个字段各自使用独立的 8 字节硬编码 Key

也可在 DevTools Console 中 Hook DES 加密函数的入参,动态确认 Key 与字段的对应关系:

javascript 复制代码
// Hook 思路:拦截 smEncrypt 导出函数,打印 key 和明文前20字符
const origEncrypt = smEncrypt.encrypt;
smEncrypt.encrypt = function(key, text) {
    console.log('[DES Hook]', 'key:', key, 'text:', text.slice(0, 20));
    return origEncrypt.call(this, key, text);
};

4.5 动态密钥派生(新版协议关键机制)

新版协议引入了服务端动态下发密钥种子的机制(来自 smCaptcha.js 678-679 行):

复制代码
k_bytes = base64Decode(register_detail['k'])
__key   = DES('<master_key>', k_bytes, decrypt)[:register_detail['l']]
  • register 响应 detail.k:Base64 编码的加密种子
  • detail.l:有效截取长度
  • master key:固定字符串,硬编码于 smCaptcha.js,需自行反混淆获取
python 复制代码
def derive_key(register_detail: dict) -> str:
    """
    从 register 响应 detail 中派生 __key。
    公式来自 smCaptcha.js 678-679 行。
    """
    k_bytes = base64.b64decode(register_detail['k'])
    decrypted = DESDecrypt('<master_key>', k_bytes)   # master key 需自行反混淆获取
    return decrypted[:register_detail['l']]

注意: slide 模式下各字段传入了硬编码 Key,__key 在 slide 场景下实际不影响核心字段,主要用于 getFullPageData() 等其他接口场景。


五、自定义序列化:smStringify

新版协议不使用标准 JSON.stringify,而是自定义的 smStringify(来自 smStringify.js)。

JSON.stringify 的差异:

特性 JSON.stringify smStringify
undefined 值的 key 保留 key(值为 undefined) 跳过该 key
bool 值 true / false true / false(一致)
输出空格 可配置 无空格
数组中的 null null null

Python 还原:

python 复制代码
def sm_stringify(obj):
    """
    还原前端 smStringify(value) 的序列化逻辑(smStringify.js)。
    与 json.dumps 差异:字符串不转义 unicode,bool 用小写,None/undefined 的 key 跳过。
    """
    if isinstance(obj, bool):
        return 'true' if obj else 'false'
    if isinstance(obj, (int, float)):
        return str(obj)
    if obj is None:
        return 'null'
    if isinstance(obj, str):
        return '"' + obj.replace('"', '\\"') + '"'
    if isinstance(obj, list):
        parts = ['null' if item is None else sm_stringify(item) for item in obj]
        return '[' + ','.join(parts) + ']'
    if isinstance(obj, dict):
        parts = ['"' + str(k) + '":' + sm_stringify(v)
                 for k, v in obj.items() if v is not None]  # 跳过 None/undefined
        return '{' + ','.join(parts) + '}'
    return '"' + str(obj) + '"'

六、Python 还原加密逻辑

6.1 DES 加密与解密

python 复制代码
from Crypto.Cipher import DES
import base64

def DESEncrypt(key: str, text: str) -> str:
    """
    DES-ECB 加密,Zero Padding,Base64 输出。
    还原自 smEncrypt.js(标准 DES 实现,294 行)。
    :param key: 8 字节密钥字符串
    :param text: 已经过 smStringify 序列化的字符串
    """
    des = DES.new(key.encode('utf-8'), DES.MODE_ECB)
    padded = text.encode('utf-8')
    pad_len = 8 - (len(padded) % 8)
    if pad_len != 8:
        padded += b'\x00' * pad_len   # Zero Padding
    return base64.b64encode(des.encrypt(padded)).decode('utf-8')


def DESDecrypt(key: str, data_bytes: bytes) -> str:
    """
    DES-ECB 解密,用于从 register 响应派生 __key。
    :param key: master key 字符串(需自行反混淆获取)
    :param data_bytes: base64Decode 后的字节
    """
    des = DES.new(key.encode('utf-8'), DES.MODE_ECB)
    return des.decrypt(data_bytes).rstrip(b'\x00').decode('utf-8', errors='ignore')

6.2 12 个加密字段完整说明

所有字段统一公式:DESEncrypt(KEY, sm_stringify(原始值))

字段 原始值语义 类型 硬编码 Key 特殊说明
wi mouseEndX / trueWidth float 需自行逆向 滑动比例,非像素值
gq 轨迹数组 [[x,y,t],...] array 需自行逆向 slide 模式用硬编码 Key,非 __key
vs endTime - startTime int 需自行逆向 单位 ms
lx trueWidth(背景宽度) int 需自行逆向 演示环境为 300px
es trueHeight(背景高度) int 需自行逆向 演示环境为 160px
jq console 检测结果 bool 需自行逆向 固定 false
zm runBotDetection() bool 需自行逆向 固定 false
tx 标志位 int 需自行逆向 固定 -1
ww appId string 需自行逆向 固定 "default"
bb channel string 需自行逆向 固定 "default"
vj lang string 需自行逆向 固定 "zh-cn"
hq safeParams string 需自行逆向 "10" = 正常浏览器(isBrowser=1,hookTest=0)

所有 Key 均为 8 字节硬编码字符串,存储在反混淆后的 smCaptcha.js getMouseAction() 函数中,可通过 webcrack 反混淆直接读取,或 Hook DES 加密函数动态获取。


七、图像识别------计算缺口距离

图片域名来自 register 响应 detail.domains,静态资源服务器为 castatic.fengkongcloud.cn

python 复制代码
from ddddocr import DdddOcr

def get_distance(bg: bytes, tp: bytes) -> int:
    """
    利用 ddddocr 识别滑块缺口位置,返回实际滑动距离(像素)。
    :param bg: 背景图二进制(带缺口的完整背景)
    :param tp: 缺口图(fg)二进制
    :return: 缺口 x 坐标(已校正 Retina 缩放)
    """
    det = DdddOcr(det=False, ocr=False, show_ad=False)
    res = det.slide_match(tp, bg, simple_target=True)
    # /2 还原 Retina 缩放:SDK 返回图片分辨率是页面展示宽度的 2 倍
    return int(res['target'][0] / 2)

res['target'] 返回 [left, top, right, bottom]left 即缺口 x 坐标。

数美与极验的关键差异: 数美 SDK 返回的图片是 Retina 2x 分辨率 (实际像素宽度 = 展示宽度 × 2),因此必须 / 2 还原;极验 GT4 图片为标准分辨率,无需此步骤。


八、仿真轨迹生成

这是绕过行为检测的关键。SDK 会对轨迹的加速度曲线、y 轴抖动、停留时间等特征做多维分析,纯匀速直线轨迹会直接被 REJECT。

采用四段式仿真运动模型(加速 → 匀速 → 减速 → 停留):

python 复制代码
def generate_slider_track(target_distance: int) -> list:
    """
    四段式仿真轨迹,每个点格式: [x位移, y偏移, 时间戳ms]
    对应前端 mm (mousemoveData) 字段,打包进 gq 的 sm_stringify 序列化中。
    """
    track = []
    # 初始点:(0, 随机y偏移, 0)
    track.append([0, <随机y偏移>, 0])

    # 阶段1:加速(0~15%,较大步长,较短时间间隔)
    while current_distance < target_distance * 0.15:
        # 递增 x,随机 y 小幅抖动,追加到 track
        ...

    # 阶段2:匀速(15%~85%,中等步长,适中时间间隔)
    while current_distance < target_distance * 0.85:
        # 递增 x,随机 y 中幅抖动,追加到 track
        ...

    # 阶段3:减速(85%~100%,小步长,较长时间间隔,y 抖动加大模拟紧张)
    while current_distance < target_distance:
        # 小步递增 x,y 抖动增大,追加到 track
        ...

    # 阶段4:停留(3~5个点,模拟松手前微抖,x 在目标位置 ±1px,较长间隔)
    for _ in range(<3~5次>):
        # 保持目标位置附近,追加到 track
        ...

    return track

具体步长与时间间隔参数需结合实际通过率自行调参,此处不公开具体数值。

四段参数参考:

阶段 距离范围 x 步长 时间间隔 y 抖动范围
加速 0~15% 4~8px 40~70ms ±10px
匀速 15%~85% 5~9px 80~120ms ±15px
减速 85%~100% 2~4px 100~150ms ±20px
停留 目标位置 ±1px 100~200ms ±2px

九、JSONP 响应解析

register 和 fverify 均为 JSONP 格式,callback 名含毫秒时间戳,长度不固定,不能硬编码偏移量

python 复制代码
def parse_jsonp(text: str, callback_name: str) -> str:
    """
    动态解析 JSONP 响应,提取 JSON 部分。
    硬编码偏移量(如 `[17:-1]`)在 callback 名长度变化时会崩溃。
    """
    prefix = callback_name + '('
    if text.startswith(prefix) and text.endswith(')'):
        return text[len(prefix):-1]
    # 兜底:动态定位括号
    return text[text.index('(') + 1: text.rindex(')')]

十、完整请求流程概述

步骤 动作 说明
1 生成会话标识 本地生成 captchaUuid(时间戳+随机字符)和 callbacksm_ + 时间戳),两次请求必须一致
2 调用 register GET /ca/v1/register,获取 bg/fg 路径、rid、密钥种子 k/l
3 派生 __key DES('<master_key>', base64Decode(k))[:l],slide 模式下不影响核心字段
4 下载图片识别距离 castatic.fengkongcloud.cn 下载图片,ddddocr 识别后 /2 校正
5 生成仿真轨迹 四段式模型生成 [[x, y, t], ...],模拟真实人类滑动行为
6 加密 12 个字段 每个字段:sm_stringify(原始值)DES-ECBBase64
7 组装 verify 参数 12 个加密字段 + 协议字段(organization / rid / protocol 等)
8 调用 fverify GET /ca/v2/fverify,解析返回的 riskLevel 判定结果

十一、关键知识点总结

知识点 详情
captchaUuid 来源 sdk.js getCaptchaUuid()generateTimeFormat() + 16 位随机字符(字符集受限)
organization SmCaptcha 初始化参数,演示环境可从注册页 JS 提取
图像识别库 ddddocr 滑块匹配,必须 /2 校正 Retina 缩放(极验 GT4 无需此步骤)
加密算法 标准 DES-ECB,Zero Padding,Base64 输出(来自 smEncrypt.js)
参数数量 12 个加密字段,Key 均来自反混淆 smCaptcha.js
Key 获取方式 webcrack 反混淆后在 getMouseAction() 直接读取,或 Hook DES 函数动态获取
__key 动态密钥 服务端下发种子(k/l),master key 硬编码于 smCaptcha.js(需自行逆向);slide 模式不影响 gq 等核心字段
自定义序列化 smStringify 非标准 JSON:跳过 None/undefined 的 key,无空格,bool 小写
轨迹格式 [[x, y, t], ...],三分量,需四段式仿真(匀速直线必 REJECT)
JSONP 解析 动态定位括号,不能硬编码偏移量
hq 字段含义 "10" = isBrowser(1) + hookTest(0),正常浏览器环境标志

十二、依赖安装

bash 复制代码
pip install requests pycryptodome ddddocr pillow
# 反混淆工具(Node.js 环境)
npm install -g webcrack

本文技术仅供安全研究与学习,研究对象为数美官网公开演示环境,切勿用于任何未授权系统,违者后果自负。

相关推荐
前端不太难19 小时前
从 OpenClaw 到端侧 AI:低算力智能体架构设计
人工智能·状态模式
阿珊和她的猫19 小时前
使用 TypeScript 实现数组类型判断方法
javascript·typescript·状态模式
Smoothcloud润云2 天前
从“预测下一个词”到“预测下一个世界状态”:世界模型作为AGI新范式的深度分析报告
人工智能·测试工具·微服务·容器·github·状态模式·agi
GISer_Jing2 天前
前端视频技术全解析:从编解码到渲染优化
前端·音视频·状态模式
ZC跨境爬虫2 天前
海南大学交友平台开发实战 day10(后端向前端输出_前端读取数据全流程联调+日志调试落地)
前端·python·sqlite·html·状态模式
前端不太难2 天前
OpenClaw 的设备架构设计:如何在有限算力上跑复杂智能体
状态模式·openclaw
Rabbit_QL3 天前
【服务出错问题排查记录】从一个“点击失败”开始:为什么“系统异常”其实是最差的错误设计
状态模式
老神在在0015 天前
Spring Boot 全局异常处理器(GlobalExceptionHandler)
spring boot·spring·java-ee·状态模式·
小陈工5 天前
Python Web开发入门(十八):跨域问题解决方案——从“为什么我的请求被拦了“到“我让浏览器乖乖听话“
开发语言·python·机器学习·架构·数据挖掘·回归·状态模式