目标:看雪登录(passport.kanxue.com)的网易易盾滑块,纯 Node 全自动通过。
结果:/api/v3/check 返回 result:true,连续 5/5;最终 validate 提交看雪登录接口被接受。
版本信息(所有结论绑定此版本):
| 项 | 值 |
|---|---|
| 验证码 ID | e019d55cf18348519c201a0d2f80ebfb |
| 加载器 | load.min.js v2.5.4 |
| core | core-optimi.m25b40.v2.28.5.min.js(631KB) |
| 协议 | c.dun.163.com/api/v3/* |
一、抓包:三个接口
登录页点出滑块,Network 里就三个接口:
| 接口 | 作用 | 关键参数 |
|---|---|---|
GET /api/v2/getconf | 初始化配置 | 返回 dt、resources、apiServer、staticServers |
GET /api/v3/get | 拉验证码 | fp、cb、dt、id…返回 token + 背景图/拼图 URL |
GET /api/v3/check | 提交结果 | token、data、cb…返回 result + validate |


get 请求参数(浏览器原样抓的)

referer=https://passport.kanxue.com/user-account_login.htm
zoneId=CN31 # getconf 下发
dt=... # getconf 下发
acToken= # 本 captchaId 为空
id=e019d55cf18348519c201a0d2f80ebfb
fp=... # 指纹,见 3.1
https=true
type= # 空 = 默认滑块
version=2.28.5
dpr=1
dev=3 # 设备类型
cb=... # 随机串加密,见 3.2
ipv6=false
runEnv=10
group= scene=
lang=zh-CN
sdkVersion=
loadVersion=2.5.4
iv=4 # IV_VERSION,core 常量
user=
width=320
audio=false
sizeType=10
smsVersion=v3
token= # 刷新时带上旧 token
callback=__JSONP_xxx_0check 请求参数

id, token, dt, zoneId, referer
data={"d":"...","m":"","p":"...","f":"...","ext":"..."} # 核心
width=320 type=2 version=2.28.5 cb=... bf=0 runEnv=10 iv=4 loadVersion=2.5.4data 四个密文字段(m 恒为空),全部由本地生成。
二、解混淆:两层字符串表
core 六百多 KB,字符串全部收进一张表,调用统一走 a0_0x1e60(0x..):
var a0_0x3d6e=['doIJY3r0CJ','bDgVsRFVwz8', ...共1545条];
function a0_0x1e60(_0x3d6ee5,_0x1e6084){
// 自定义 base64 字母表: kyclmxfigrhbswquanzevpjotdKYCLMXFIGRHBSWQUANZEVPJOTD0123456789+/=
// 解码: 自定义base64 -> %xx -> decodeURIComponent
}注意:不是标准 base64,字母表是自定义的。解码器自带 gtHdNm 方法,内部做了缓存。
更坑的是:解完这层,最底下的大模块还有第二层——自己的数组、自己的解码器,字母表是 2izvR3Ydkgw605lf(16 字符,映射到 hex)。

解混淆不硬啃,三步:
- 把数组 + 解码器原样抠出来,丢 Node vm 里跑,让解码器吐出全表
- 正则把
a0_0x1e60(0x..)全部替换成明文 - 用 Babel 解析整个包,定位 webpack 模块数组,逐个拆出,共 77 个模块
三、参数逐个还原
3.1 fp
get 的 fp 是浏览器指纹。全局搜 gdxidpyhxde 只找到一处读取,没有写入——因为生成它的模块把字符串名也编进了自己的嵌套表。
实际链路:
- cookie 里存
gdxidpyhxdE window.gdxidpyhxde由 cookie 解码而来- 格式:
自定义base64密文:时间戳,如VppCnhP7SV+Y\pbQCE8...:1786798405363

处理:把生成模块原样塞进 Node vm,补假的 window/document/navigator/localStorage/screen,跑完 window.gdxidpyhxde 就有值了,与浏览器一致。整个指纹生成器不用自己复刻。

3.2 cb
cb 是每次请求都会变的随机串:
uuid = 32 位随机 [0-9A-Za-z]
在位置 [1, 10, 12, 13, 26, 31] 依次注入 "vfnv46" 六个字符
cb = aes(uuid)suffix、code、pos 三个常量(m25b40 / vfnv46 / 位置数组)与版本绑定,换版本要重取。
3.3 自研 aes
这个 aes 不是 AES,是易盾自己搓的分组密码。常量:
| 常量 | 值 |
|---|---|
| SEED_KEY | fd6a43ae25f74398b61c03c83be37449 |
| ROUND_KEY | 037606da0296055c |
| SBOX | 256 字节置换表(__SBOX__) |
| 私有 base64 字母表 | MB.CfHUzEeJpsuGkgNwhqiSaI4Fd9L6jYKZAxn1/Vml0c5rbXRP+8tD3QTO2vWyo |
| 填充符 | 7 |
加密流程:
- 明文 UTF-8 字节 + CRC32(8 字节 hex)
- 按 64 字节分块,末尾补零 + 4 字节长度
- 密钥:SEED_KEY 扩到 64 字节,异或 4 字节随机数,再扩回 64 字节
- 每块做轮函数:ROUND_KEY 每 2 字节一组(操作码 + 操作数),对块做异或/加法,共 4 轮
- 链式:
块^密钥 → 加prev → 异或prev → S盒(S盒()),输出即下一轮 prev - 结果前 4 字节放随机数,最后私有 base64 编码
轮函数操作表(ROUND_KEY = 03 76 06 da 02 96 05 5c):
| 操作码 | 操作 |
|---|---|
| 0 | 不变 |
| 1 | 全块异或常量 |
| 2 | 全块加常量 |
| 3 | 逐字节异或递增 |
| 4 | 逐字节加递增 |
| 5 | 逐字节异或递减 |
| 6 | 逐字节加递减 |
逆运算(验证用):S 盒求逆 + 反向轮函数 + 反向链,CRC 校验通过即还原成功。后面解密真实样本全靠它。
3.4 dt / iv
dt:getconf 下发,存localStorage["ujg3ps2znyw"]iv=4:core 常量IV_VERSIONloadVersion=2.5.4:load.min.js 的 VERSION
四、最大卡点:data 全 False
d/m/p/f/ext 按模块逻辑生成后,get 正常(error:0),check 也正常返回 error:0,但 result 恒为 false。p 从 2 扫到 98(步长 2,同一 token 可重复 check)一个都不中。
此时能确定:不是位置问题,是数据本身有问题。但密文看不出问题在哪。
破局:在浏览器手动拖一次,抓通过的 check 请求(result:true),用自研 aes 逆运算把 data 全解密。拿到明文后,三个坑当场现形:
坑 1:p 不取整
真实明文:"77.8125"。
前端代码是 parseInt(jigsaw.style.left) / width * 100 + '',直接字符串拼接,没有 round。我之前生成的是 Math.round(...),全灭。
坑 2:f 不是原始轨迹
f 的明文是 47 个浮点数,不是 dx,dy,t,trusted 坐标。前端先把轨迹丢进一个统计模块(module_0x38),算出 47 维特征再拼接:
轨迹点 -> [x序列, y序列, t序列]
-> 速度序列(vx, vy, 合速度) -> 加速度序列(ax, ay, a合)
-> 统计: 数量/去重数/均值/标准差/最小/最大/25分位/75分位真实样本 f 明文开头:
124,17,0.2042,4.776,142,0,0.8333,0.2133,0.1808,38,0.0625,0.3333,-0.3333,...直接发坐标 = 风控一眼假。
坑 3:xorEncode 参数顺序
xorEncode(a, b) 输出长度由 b 决定:
xorEncode('数据', 'token') // 错:输出长度 = token 长度
xorEncode('token', '数据') // 对:输出长度 = 数据长度写反后密文长度直接不对,服务端自然不认。


经验:加密参数还原后,必须用真实通过样本对账。 猜出来的逻辑,错在哪个细节根本看不出来。
五、轨迹与 data 生成
真实通过样本透露的轨迹参数:
ext = "2,142" # mouseDown 次数=2,轨迹点数=142
d = 50 个采样点 # SAMPLE_NUM=50,等间隔采样
点格式: "dx,dy,t,1" # 最后一位 trusted=1(真实鼠标)
最后一点: "260,3,1716,1" # 拖动 260px,耗时 1716ms对应关系:jigsawLeft=249 时,dragX=260,差值 10.5 来自 restrict() 映射(见第六节)。
生成逻辑(核心代码):
function genTrace(dragX, token) {
// 140 点 / 1750ms / ease-out / trusted=1
for (let i = 0; i < 140; i++) {
const t = Math.round(1750 * i / 139);
const dx = Math.max(0, Math.round(dragX * (1 - Math.pow(1 - i/139, 3))));
const dy = Math.round((Math.random() - 0.5) * 6);
atomTrace.push([dx, dy, t, 1]);
traceData.push(xorEncode(token, [dx, dy, t, 1].join(',')));
}
}
function buildData({ token, jigsawLeft, traceData, atomTrace }) {
const d = aes(sample(traceData, 50).join(':')); // 50 采样
const p = aes(xorEncode(token, String(jigsawLeft / 320 * 100))); // 不取整
const f = aes(xorEncode(token, features(uniqueByTime(atomTrace)).join(','))); // 47 维
const ext = aes(xorEncode(token, '2,' + traceData.length));
return JSON.stringify({ d, m: '', p, f, ext });
}check 通过后拿到服务端 validate,还要再算一次才是给业务方的最终值:
finalValidate = zoneId + '_' + md5(aes(validate + '::' + fp)) + '_v_i_1'
// 例: CN31_90fffa737f70291f7a08893899dc5d3c_v_i_1六、缺口检测
一开始用拼图小块对背景做模板匹配,匹配到一个无关角落。原因:背景的缺口是被抠空的,拿拼图去匹配空腔,必然失败。
正确做法:拼图 alpha 轮廓 vs 背景 Canny 边缘。
mask = alpha > 0 # 拼图不透明区域
dist = distanceTransform(mask) # 取轮廓(距边界 ≤2px)
outline = (dist <= 2) & (dist > 0)
edges = Canny(bg)
res = matchTemplate(edges, outline, TM_CCOEFF_NORMED)
# 取 y 在拼图 bbox 高度范围内的最优 x用人工通过的图验证:命中 x=251,真实 jigsawLeft=249,差 1px。

restrict() 映射细节:前端滑块有个 restrict(),拼图位移 ≠ 鼠标位移:
jigsawLeft = dragX - 10.5 # 常规区间
jigsawLeft = dragX / 2 # 拖前 21px 内所以想落到 jigsawLeft=249,鼠标要拖 259.5;轨迹终点的 dx 也必须用 dragX,不是 jigsawLeft。
七、结果
验证:
check连续 5/5result:true- 最终 validate 提交看雪
/user-kanxue_login.htm,返回帐号或密码错误(假账号下即验证码已过)

