
声明
本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!
本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!
前言
近期收到私信,有群友咨询某美世界的反爬相关问题。本文就针对某美世界的卡片状态查询页面,拆解其验证码机制与完整业务查询流程。这类场景有一个共性:真正的突破口并不藏在某一个独立接口内,而是存在于一整条有状态的请求链路之中:

逆向目标
- 网址:
aHR0cHM6Ly9wYXkud2FubWVpLmNvbS9uZXcvcXVlcnkvcXVlcnlDYXJkLmpzcA==
抓包分析
首先通过 queryCard.jsp 下发 cookie(JSESSIONID),后续请求必须复用同一个 Session。页面加载完成后,queryCard.js 执行 captchaInit() 拿到 capTicket:

然后请求 /mCaptcha/key 接口传入 capTicket 后返回 ,这里的 capKey 不是随机生成后就可以长期使用的普通参数,它是当前 capTicket 对应的验证码 key:

拿到 capKey 后,SDK 请求 /mCaptcha/info/<capKey> 返回 img 和 loc:
img是验证码图片地址loc是背景图片的 tile 乱序表

mCaptcha/validate 接口验证通过后返回 rand 参数:

最终卡校验接口需要将之前的参数组合,正确即可查询成功:

逆向分析
图片还原
mCaptcha.pc.min.js 开头的 GetCaptcha 函数定义了几个固定值:
javascript
var s = 260,
c = 120,
d = 20,
i = 9,
n = 61;
各自含义如下:
| 变量 | 含义 | 值 |
|---|---|---|
s |
背景宽度 | 260 |
c |
图片高度 | 120 |
d |
每行 tile 数 | 20 |
i |
位移修正量 | 9 |
n |
缺口块宽度 | 61 |
因此背景区域被切成:
text
20 列 x 2 行
每块 13 x 60
总计 40 块
图片接口调用成功后,将执行对应的函数处理逻辑:

javascript
$.each(t.loc, function(e, t) {
var i = n;
a += i += t < d
? "background-position: -" + s/d*t + "px 0;"
: "background-position: -" + s/d*(t%d) + "px -" + c/2 + "px;";
});
等价的 Python 算法:
python
src_x = (src_idx % 20) * 13
src_y = 0 if src_idx < 20 else 60
dst_x = (dst_idx % 20) * 13
dst_y = 0 if dst_idx < 20 else 60
最终的 mCaptcha/validate 请求中有两个看起来像密文的字段(op 和 validData),进入相关堆栈,它们的生成位置在 mCaptcha.pc.min.js 文件的鼠标释放逻辑中:
javascript
var e = JSON.stringify(r.getPartEventList(100));
var t = "";
h && (t = Encrypt(e, X(y)));
var m = g ? (new Date).getTime() - g : 0;
var data = {
length: D,
validateTimeMilSec: m
};
data = Encrypt(JSON.stringify(data), X(y));
$.ajax({
type: "get",
url: "https://captchas.wanmei.com/mCaptcha/validate",
data: {
capKey: x,
validData: data,
op: t,
fp: o,
label: l
}
});
两者都调用 Encrypt 方法,并且第二个参数都是 X(y),接着继续向上找变量赋值,SDK 初始化时有 y = e.capTicket:

而页面调用 SDK 的地方是:
javascript
mCaptcha.init({
appId: "10001",
capTicket: data.result,
capType: "embed",
bindBtn: "#sub"
});
这里的 data.result 就是 captchasInit 接口返回的 capTicket。因此完整的反向调用链如下:
javascript
mCaptcha/validate.validData
<- Encrypt(明文 JSON, X(y))
<- y = capTicket
<- captchasInit.result
进入 x 函数为切片函数,目的就是最终返回一个 16 个字符(盲猜为 aes key):
javascript
function X(e) {
return e.substring(1,3)
+ e.substring(10,13)
+ e.substring(20,22)
+ e.substring(26,31)
+ e.substring(21,25);
}
再看 Encrypt 的实现,它位于字节 14738 附近:
javascript
function Encrypt(e,t) {
t = CryptoJS.enc.Utf8.parse(t);
srcs = CryptoJS.enc.Utf8.parse(e);
t = CryptoJS.AES.encrypt(srcs, t, {
iv: t,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return CryptoJS.enc.Base64.stringify(t.ciphertext);
}
分析可知 op 是行为轨迹:
javascript
[
[441, 515, 1, 0],
[445, 515, 3, 120],
[453, 516, 3, 240]
]
validData 是结果摘要 "length": 67,"validateTimeMilSec": 3200(代表含义:轨迹最后移动到了哪里,轨迹耗时与 validateTimeMilSec 是否一致)。
继续向上分析轨迹组成,UserOpListener 的初始化形式为:new UserOpListener(window, 100, 20000);
当前前端只会把事件记录成四元组:clientX, clientY, eventType, elapsedMilliseconds

mousemove 记录要求相邻事件间隔超过 100ms,且 getPartEventList(100) 在事件数量不超过 100 时会直接保留全部事件。因此当前实现把采样点数限制在一个小而稳定的范围内。
让 AI 分析复现一个即可,然后根据轨迹异常率不断调整:
python
def build_op_plaintext(
distance: float,
validate_time_ms: int,
start_x: float = 441.0,
start_y: float = 515.0,
):
track_delta = distance # distance = e
events = [[start_x, start_y, 1, 0]]
sample_count = max(16, min(24, int(validate_time_ms / 125)))
last_t = 0
for index in range(1, sample_count + 1):
ratio = index / sample_count
progress = 1 - (1 - ratio) ** 3
x = start_x + track_delta * progress
y = start_y + math.sin(ratio * math.pi) * 0.6
t = int((validate_time_ms - 450) * ratio)
if t <= last_t:
t = last_t + 105
events.append([round(x, 1), round(y, 1), 3, t])
last_t = t
return events
最后再生成摘要字段:
javascript
valid_data_plain = {
"length": round(distance + pointer_offset, 1),
"validateTimeMilSec": validate_time_ms,
}
因此两份明文的关系是:
javascript
op 明文
-> 事件的起点、终点、类型和相对时间
validData 明文
-> length = e + 9
-> validateTimeMilSec = 本轮验证耗时
fp 指纹参数
PC SDK 初始化时会异步加载 analysis/vendor/fp.min.js 文件然后调用:
javascript
new Fingerprint({
canvas: true,
screen_resolution: true
}).get();

参与 hash 的字段
生成 keys 的顺序是:
text
navigator.userAgent
navigator.language
screen.colorDepth
screen.height + "x" + screen.width
timezone offset
sessionStorage 可用性
localStorage 可用性
indexedDB 可用性
document.body.addBehavior 类型
window.openDatabase 类型
navigator.cpuClass
navigator.platform
navigator.doNotTrack
navigator.plugins
Canvas data URL
最后:
javascript
murmurhash3_32_gc(keys.join("###"), 31)
Canvas 绘制函数位于底部附近,使用固定文字和颜色生成 Canvas data URL:

结果验证
