JD Cloudflare 验证码逆向踩坑记录
搞国航机票搜索时遇到的京东云验证码,记录一下从一头雾水到跑通的全过程。
环境:纯 Node.js,不依赖浏览器/无头浏览器
目录
- 验证码流程长什么样
- 抓包看接口
- 参数是怎么拼出来的
- 加密算法是啥
- WASM 卡住了,换个路子
- asm.js 回退才是出路
- FP 请求实现
- Check 请求:获取图片 vs 提交验证
- 完整流程
- 踩坑总结
1. 验证码流程长什么样
国航的机票查询页,点搜索之后会弹出一个京东云验证码。样式就是那种"在背景图里找出某某图标点一下"的点击式验证码。
整个流程拆开来看其实就两步:
- 拿验证码图片(背景大图 + 指引小图)
- 提交点击坐标让服务器判断对不对
后端只有两个接口,三步操作:
Step 1: FP 请求 → 拿到临时 token (st) Step 2: Check 请求(传空数据)→ 拿到验证码图片 Step 3: Check 请求(传点击坐标)→ 提交验证有个有意思的点:获取图片和提交验证用的是同一个接口,区别只在于加密参数tk里面塞的数据不同。
2. 抓包看接口
2.1 FP 请求
FP 请求是第一步,用来获取一个临时 token(st)。需要传一个加密后的设备指纹ct,以及一个认证头x-jdcloud-captcha-auth,格式是;时间戳;32位hex。
参数说明:
| 参数 | 说明 |
|---|---|
si | sessionId,浏览器的 localStorage 里拿b2c-web_sid |
ct | 加密后的设备指纹 |
x-jdcloud-captcha-auth | 认证头 |
| 其他 | version=2,lang=1,client=m都是固定的 |
返回的st就是后续请求需要的 token。
2.2 Check 请求(获取图片)
Check 请求比 FP 多了一个tk参数,它是加密后的验证数据。获取图片的时候传的是空数据加密后的结果。
返回的img字段里有两张 base64 图片:一张背景大图(b1)和一张拼图指引(b2)。
3. 参数是怎么拼出来的
3.1 核心函数 Q
在 SDK 里翻到一个核心函数,所有 check 请求都是它发出去的。它的逻辑大致是:
先把验证数据用encodeURI编码一下,然后按固定格式拼接明文。明文里包含了时间戳、sessionId、st(token)、验证数据、设备指纹等信息,还会在特定位置插入随机长度的随机串。拼接完成后用加密函数加密,带上认证头发送 POST 请求。
3.2 参数拼接规则
两个关键参数tk和ct的明文拼接规则不一样:
ct的明文:随机前缀 + sessionId长度(4位固定宽度) + sessionId + 设备指纹 + 时间戳tk的明文:时间戳 + sessionId长度(4位固定宽度) + sessionId + st长度(4位固定宽度) + st + 验证数据长度(6位固定宽度) + 验证数据 + 触摸信息 + 随机后缀
随机前缀的长度由时间戳 % 19决定,随机后缀的长度由时间戳 % 41决定。
3.3 常量
SDK 里藏了几个关键常量,包括默认的加密密钥、tdat_ctx 上下文、md5 salt 等。具体值就不贴了,感兴趣的可以自己去 SDK 里找。
4. 加密算法是啥
在 SDK 里看到0x9e3779b9这个数,熟悉的人应该一眼就能认出来——这是XXTEA 算法的 delta 常数。
加密链路大致是:明文先做 UTF-8 编码,然后转成 32 位无符号整数数组,走 XXTEA 加密轮,最后转成二进制串用 URL-safe 的 Base64 编码输出。它的 Base64 字母表把标准版的+/换成了-_。
至于 auth 头的 hash 算法,尝试用纯 JS 去复现,但输出跟浏览器里的不一致。后来发现这个 hash 的计算在 WASM 模块里,涉及 C++ 的整数运算逻辑,JS 模拟不了。
5. WASM 卡住了,换个路子
加密核心的两个函数都在 WASM 模块里。当时试了几条路:
- 直接下载 WASM 文件→ 返回 404,WASM 被内联到 SDK 里了
- 用 jsdom 加载完整 SDK→ WASM 编译在 Node 里跑不起来
- 纯 JS 手写 XXTEA→ 输出跟 WASM 不一致,C++ 和 JS 的整数运算有差异
三条路都走不通的时候差点想放弃了。后来仔细看了 SDK 代码,发现它在 WASM 编译失败的时候会降级到一个 asm.js 版本。
那能不能让它强制走 asm.js 回退?
6. asm.js 回退才是出路
6.1 问题在哪
SDK 加载时会检测是不是 Node.js 环境。如果检测到是 Node.js,它会走不同的初始化路径,那个路径里不包含我们需要的两个函数。
6.2 怎么解决
思路很简单:
- 把这个检测结果 patch 成
false,让它以为自己在浏览器里 - 禁用 WebAssembly,逼它降级到 asm.js
- mock 几个浏览器全局对象(
document、window之类的)
具体做法就是读取 SDK 的 JS 文件,把检测 process 的那段代码替换掉,然后在执行前把WebAssembly.instantiateStreaming设成一个直接 reject 的函数,再补上global.document和global.window的 mock。
然后 eval 执行 patch 后的 SDK 代码,轮询等待 asm.js 初始化完成,就能拿到加密函数了。
6.3 验证
跑了个测试,asm.js 版的加密输出和浏览器 WASM 版完全一致。到这里核心问题就破了。
7. FP 请求实现
拿到加密函数之后,FP 请求就很简单了。
流程就是:按规则拼出ct的明文 → 调用加密函数加密 → 生成 auth 头 → 发起 POST 请求。返回的st就是后续需要的 token。
8. Check 请求:获取图片 vs 提交验证
Check 请求的接口、参数结构完全一样,区别只在于tk里加密的数据不同。
| 场景 | tk 里加密的 data |
|---|---|
| 获取图片 | 空数据 |
| 提交验证 | 点击坐标数据 |
坐标数据就是x、y和点击时间戳ts组成的数组。
Check 的tk明文拼接规则和 FP 的ct类似,只是多了 st 和验证数据的部分。
响应解析也很简单:code为 0 且有img字段说明拿到图片了(或者验证失败返回了新图片),code为 0 且没有img说明验证通过。其他错误码对应不同的异常情况。
9. 完整流程
FP 请求 → 拿 st → Check 请求(空数据)→ 拿图片 → Check 请求(点击坐标)→ 验证结果- sessionId 从浏览器 localStorage 获取
- st 有效期几分钟,每次验证前重新 FP
- tdat_ctx、sensor 从浏览器抓一次就能重复用
最后说两句
整个逆向过程最折腾的就是 WASM 那块,试了好几种方案都没成,最后发现 SDK 自带的 asm.js 回退才是最省事的方案——不用自己重写加密,不用折腾 WASM 运行时,patch 几行代码就能直接用。
对了,sessionId 和设备指纹都是从浏览器抓的固定值,实际用的时候记得换成你自己的。
运行结果示例
注意:本文只涉及验证码的协议逆向与加密突破,不包含验证码图片识别(找图、坐标计算等)。
Running] node "f:\project\crawler\验证码\国际航空\work\1.js" === JD Cloudflare Captcha 纯Node.js验证 === [1/4] 初始化加密模块... ✓ jcap asm.js就绪 [2/4] FP请求(获取token)... ✓ FP成功 st=sY87rOSMAGjYO2iI [3/4] 获取验证码图片... ✓ 背景图已保存 ✓ 拼图已保存 [4/4] 提交验证... === 验证结果 === { "st": "", "code": 16807, "s_code": 16102, "msg": "验证失败,请重新验证" } ❌ 验证失败(坐标不对): 验证失败,请重新验证