免责声明: 本文所有分析均基于数美官网注册演示页(https://www.ishumei.com/account/register.html)的公开前端 JS 代码,仅用于安全研究、学习与了解验证码防护机制。文中涉及的 organization、接口域名等均为数美官方演示环境参数。请勿将本文技术用于任何未授权的系统,违者后果自负。
一、背景介绍
本文以数美(Ishumei)风控 SDK protocol=206 新版的滑块验证码为研究对象,分析其前端安全机制。研究入口为数美官网注册演示页:
https://www.ishumei.com/account/register.html
基本交互逻辑:
- 前端请求
register接口,获取背景图(bg)、缺口图(fg)、会话标识(rid)及服务端密钥种子(k/l) - 用户拖动滑块到缺口位置,前端实时记录鼠标轨迹
- 前端将滑动距离比例、轨迹、耗时、环境检测结果 等 12 个参数逐一加密后,提交到
fverify接口 - 服务端解密参数,综合判断是否为人工滑动,返回 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.jsgetMouseAction()函数,每个字段对应独立 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.js、smEncrypt.js、smStringify.js等可读文件。
4.3 定位加密逻辑
反混淆后,在 smCaptcha.js 的 getMouseAction() 函数中可直接找到 slide 模式的加密组包逻辑。
每个字段统一调用 getEncryptContent(value, hardcodedKey) 完成加密,处理链路:
- 选择密钥 :优先使用传入的硬编码 Key,未传入则回退到动态派生的
__key - 序列化 :调用自定义
smStringify(value)转为字符串(非标准 JSON,见第五节) - 加密:DES-ECB + Zero Padding
- 编码:密文 Base64 编码后作为参数值
slide 模式下,
gq字段在smCaptcha.js706 行明确传入了硬编码 Keyxxxxx,因此__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.jsgetMouseAction()函数中,可通过 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(时间戳+随机字符)和 callback(sm_ + 时间戳),两次请求必须一致 |
| 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-ECB → Base64 |
| 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
本文技术仅供安全研究与学习,研究对象为数美官网公开演示环境,切勿用于任何未授权系统,违者后果自负。