文章目录
-
- 声明
- [0. 开场:这个参数一开始看起来很像"普通签名"](#0. 开场:这个参数一开始看起来很像“普通签名”)
- [1. 先看现象:shoppingv2请求为什么总是 405?](#1. 先看现象:shoppingv2请求为什么总是 405?)
- [2. 解开 refer__1036:它其实是一个压缩后的明文结构](#2. 解开 refer__1036:它其实是一个压缩后的明文结构)
- [3. 第一个坑:压缩 alphabet 不是标准 Base64 表](#3. 第一个坑:压缩 alphabet 不是标准 Base64 表)
- [4. 核心算法拆解](#4. 核心算法拆解)
-
- [4.1 P():MurmurHash2 32-bit](#4.1 P():MurmurHash2 32-bit)
- [4.2 a0():从 hash 字符串里循环取 16 位](#4.2 a0():从 hash 字符串里循环取 16 位)
- [4.3 a2():十六进制字符 Caesar shift](#4.3 a2():十六进制字符 Caesar shift)
- [4.4 M():session id](#4.4 M():session id)
- [4.5 w():fingerprint mask](#4.5 w():fingerprint mask)
- [5. 最大的隐藏坑:POST body 必须参与 field1](#5. 最大的隐藏坑:POST body 必须参与 field1)
- [6. keepper 流程:不是"算一个 refer"就完事](#6. keepper 流程:不是“算一个 refer”就完事)
- [7. 如何验证算法是真的对?](#7. 如何验证算法是真的对?)
-
- [7.1 解真实 refer](#7.1 解真实 refer)
- [7.2 用解出来的字段重新压缩](#7.2 用解出来的字段重新压缩)
- [7.3 用真实请求 body 反推 hash](#7.3 用真实请求 body 反推 hash)
- [8. 经验总结](#8. 经验总结)
声明
本文章中所有内容仅供学习交流,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请私信我立即删除!
《JS逆向为爱发电》专栏素材征集令:十年饮冰,热血难凉 | JS逆向为爱发电
继之前的文章东航逆向实录:refer__1036、req/res、ssxmod_itna/itna2 一锅端之后,有朋友问refer__1036的生成能不能详细展开讲讲,这次就来说说这个参数吧。
目标:还原东方航空 H5
shoppingv2请求里的refer__1036生成逻辑,并尽量走纯协议请求。
0. 开场:这个参数一开始看起来很像"普通签名"
很多 H5 站点都会在接口 URL 上挂一个类似这样的参数:
text
refer__1036=214d4f07715-xxxxxxxxxxxxxxxx
乍一看,它像是:
- 一个固定前缀;
- 后面跟一段压缩/加密串;
- 服务端校验不过就 403 / 405 / 滑块。
看到这种参数,第一反应一般是:
"问题不大,定位 JS,扣算法,复刻一把。"
然后现实会温柔地给你一巴掌:
"你复刻的是算法,但服务端校验的是状态、body、时间、cookie、TLS、WAF 流程,还有你今天有没有喝够咖啡。"
这次 refer__1036 的逆向,核心难点不在某个单点算法,而在于它是 WAF challenge 流程 + 动态状态 + 请求体绑定 + 自定义压缩 alphabet 的组合拳。
1. 先看现象:shoppingv2请求为什么总是 405?
目标接口:
text
POST https://m.ceair.com/m-base/sale/shoppingv2
业务页面:
text
https://m.ceair.com/mapp/reserve/flightList?...SHA -> BJS...
浏览器里正常流程大概是:
text
1. 页面加载
2. 首次 POST shoppingv2
3. 返回 keepper HTML
4. HTML 内联 WAF JS 执行,写入运行时状态
5. 后续 POST shoppingv2?refer__1036=...
6. 返回加密 JSON
最关键的浏览器行为是:
text
首次请求:
POST /m-base/sale/shoppingv2
返回:
content-type: text/html
punish-loc: keepper
响应 HTML 里会出现:
html
<textarea id="renderData" style="display:none">
{"_waf_bd8ce2ce37":"..."}
</textarea>
<script>
window._waf_bd8ce2ce37 = renderData._waf_bd8ce2ce37;
window._waf_a86dfdc5f2 = new Date().getTime();
localStorage.removeItem("_waf_a23a0b772");
localStorage.removeItem("_waf_a86dfdc5f2");
localStorage.removeItem("_waf_bd8ce2ce37");
</script>
<script src="/u21pn7x6/r8lw5pzu/psk8uqfi"></script>
<script name="aliyunwaf_6a6f5ea8">...</script>
也就是说:
refer__1036不是凭空生成的,它吃的是 keepper challenge 下发的_waf_bd8ce2ce37、当前时间、浏览器状态、请求 body 等上下文。
如果直接裸请求:
text
POST shoppingv2?refer__1036=<自己随便算的>
大概率得到:
text
405 aliyun_waf_block_405
这时候不要急着怀疑人生。先怀疑自己少拼了东西。
2. 解开 refer__1036:它其实是一个压缩后的明文结构
refer__1036 的整体格式:
text
214d4f07715-<compressed_payload>
去掉前缀后,后半段是一个类似 LZ-string 的压缩结果。解压后明文结构如下:
text
a2(a0(signSourceWithBody)) |
a2(a0(signSourceNoBody)) |
fingerprintMask |
nowMs |
_waf_bd8ce2ce37 |
_waf_a86dfdc5f2 |
sessionId
真实样例解压后长这样:
text
c879cb344ac879cb
|6171891362617189
|67240104
|1778861007900
|8mli8weSbGi+2zW881XvUXN+DdBSMmS0FjNs75Cx/11NyRrUSDfpBM/lLN3YUG5VOrnY4akrulDwmTCoDAV/SQ==
|1778845666555
|1565396372a19e1ce90b6c
字段解释:
| 字段 | 含义 |
|---|---|
| field1 | 带 body 的签名 hash,经过 a2 位移 |
| field2 | 不带 body 的签名 hash,经过 a2 位移 |
| field3 | 指纹环境 mask |
| field4 | 当前毫秒时间戳 |
| field5 | keepper 下发的 _waf_bd8ce2ce37 |
| field6 | _waf_a86dfdc5f2,通常是 challenge 初始化时间 |
| field7 | 本地持久化 session id |
所以它并不是传统意义上的"加密串",更准确地说:
refer__1036是一个经过自定义 alphabet LZ 压缩的 WAF 状态快照。
3. 第一个坑:压缩 alphabet 不是标准 Base64 表
最开始我按常见 LZ-string 思路处理:
js
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_/=
结果能解一点,但对不上真实请求。后来在运行时观察到,初始化后 alphabet 被洗牌了。
真实线上 alphabet 是:
js
TXiPqC/jvmu_y61N2At0c=ezZwknOK4bfxJsWl8SGQFLhHgM9YDI7pVUoBaRd53Er
这一步非常关键。
如果 alphabet 错了,会出现一种很迷惑的状态:
- 你觉得算法大方向对;
- 解出来的东西像人话;
- 但重新压缩回去就是和浏览器不一致;
- 服务端当然也不认。
这类问题很像做饭:
菜谱没错,火候没错,但你把糖当盐了。
最后出锅的不是红烧肉,是人生的教训。
4. 核心算法拆解
4.1 P():MurmurHash2 32-bit
内联 WAF 里的 P() 本质是:
text
MurmurHash2 32-bit, seed = 0
输入字符串,输出 unsigned 32-bit 十进制字符串。
例如:
text
murmur_hash2_32("abc") = 324500635
4.2 a0():从 hash 字符串里循环取 16 位
逻辑:
python
def inline_a0(value: str) -> str:
hashed = murmur_hash2_32(value)
return "".join(hashed[i % len(hashed)] for i in range(3, 19))
它不是再 hash 一次,而是从 MurmurHash2 的十进制结果里,从 index 3 开始取 16 个字符,长度不够就循环。
这招不复杂,但很"前端混淆味":
看着像密码学,实际像拿着吸管从 hash 里喝了 16 口。
4.3 a2():十六进制字符 Caesar shift
a2() 是一个对 hex 字符的位移:
python
def inline_a2(token: str, shift: int) -> str:
alphabet = "0123456789ABCDEF"
out = []
for ch in str(token):
try:
v = int(ch, 16)
except ValueError:
v = 0
out.append(alphabet[(v + int(shift)) % 16])
return "".join(out).lower()
在 shoppingv2 成功抓包中,实际观测到:
text
field1 shift = 3
field2 shift = 1
这个点后面也写进了默认实现。
4.4 M():session id
session id 形态类似:
text
3433013009a19ea79347c2
结构上是:
text
rollingHash(randomInt|timestamp) + "a" + hex(timestamp)
它会持久化在:
text
localStorage["__00b204e9800998__"]
浏览器里的值类似:
text
3433013009a19ea79347c2||1796479907786
后半段是过期时间。
4.5 w():fingerprint mask
w() 会读取环境指纹、随机数、时间位、堆栈检测等信息,最后洗牌成一个整数 mask。
浏览器成功请求里见过:
text
67240104
75628712
可以先把它参数化,而不是一开始就试图补完整浏览器环境。
能枚举验证的,不要先玄学补环境。
不然你会从
navigator.webdriver一路补到星座运势。
5. 最大的隐藏坑:POST body 必须参与 field1
早期还原时,以为只有 GET/HEAD 才拼 body:
python
if body and method in {"get", "head"}:
sign_source += encodeURIComponent(body)
这导致生成的 refer__1036:
- 能解压;
- 字段结构正确;
- 时间、bd、session 都像真的;
- 但是服务端一直 keepper / 405。
后来用成功抓包反推发现:
text
field1 = a2(a0(base + encodeURIComponent(compactPostBody)), shift=3)
field2 = a2(a0(base), shift=1)
也就是说,POST shoppingv2 的 body 必须进入第一段 hash。
修正后的逻辑:
python
canonical = "m.ceair.com/m-base/sale/shoppingv2"
base = encodeURIComponent(
waf_bd + "post" + "214d4f07715" + canonical
)
sign_source_with_body = base + encodeURIComponent(compact_post_body)
sign_source_no_body = base
field1 = inline_a2(inline_a0(sign_source_with_body), 3)
field2 = inline_a2(inline_a0(sign_source_no_body), 1)
注意这里的 body 是 compact JSON:
json
{"req":"..."}
不能用 requests.post(json=...) 默认序列化出来的带空格版本:
json
{"req": "..."}
签名的是前者,你发的是后者,服务端就会用一种很平静的方式告诉你:
text
再见,keepper。
因此最终请求要这样发:
python
request_body = json.dumps(request_json, ensure_ascii=False, separators=(",", ":"))
requests.post(
url,
params={"refer__1036": refer_1036},
headers=headers,
data=request_body,
)
6. keepper 流程:不是"算一个 refer"就完事
真实协议链路应该是:
text
GET flightList
-> 获取 acw_tc / SERVERID / 基础会话
POST shoppingv2,不带 refer__1036
-> 返回 keepper HTML
-> 提取 _waf_bd8ce2ce37
生成 refer__1036
-> 使用 bd、a86、sessionId、fingerprintMask、compact body
再次 POST shoppingv2?refer__1036=...
-> 成功时返回 application/json;charset=UTF-8
-> 响应头 m-ceair-encrypted: true
-> body: {"res":"..."}
其中 keepper 返回的 _waf_bd8ce2ce37 是强绑定状态:
python
def extract_keepper_bd(html: str) -> str | None:
m = re.search(
r'<textarea[^>]+id=["\\']renderData["\\'][^>]*>(.*?)</textarea>',
html,
re.S,
)
if not m:
return None
data = json.loads(m.group(1))
return data.get("_waf_bd8ce2ce37")
不要拿旧抓包里的 bd 硬塞。
旧 bd 就像过期优惠券:
看着像钱,但收银员只会礼貌地说:这个不能用了。
7. 如何验证算法是真的对?
不要只看请求能不能过。WAF 状态太复杂,过不了可能是 cookie/TLS/会话/滑块,不一定是算法错。
更可靠的验证方式是:
7.1 解真实 refer
text
refer__1036 -> 解压 -> plain
看字段是否合理:
text
hash1|hash2|mask|now|bd|a86|session
7.2 用解出来的字段重新压缩
text
plain -> compress -> refer__1036
必须 byte-level 对齐。
7.3 用真实请求 body 反推 hash
成功抓包验证结果:
text
base + encodeURIComponent(body) -> a0 -> shift 3 -> field1
base -> a0 -> shift 1 -> field2
当这三步都对齐后,才能说:
refer__1036主算法闭环。
8. 经验总结
这类 WAF JS 通常会有:
debugger循环;Function构造器检测;toString校验;- runtime alphabet 洗牌;
- localStorage/sessionStorage 状态;
- 内联脚本 + 外链脚本协同;
- 一次 challenge,多次重放。
一开始如果强行全局 patch:
js
Function = Proxy(Function, ...)
eval = patchedEval
很容易把页面正常 XHR 也搞没了。
更稳的策略:
- 先抓成功请求;
- 解出参数明文;
- 对比字段;
- 反推参与 hash 的输入;
- 再最小化复现。
不要上来就:
"我要模拟整个 Chrome。"
那条路很宽,但尽头往往是:
text
node_modules 3GB
结果还是 405
不要把"算法正确"和"请求成功"混为一谈
算法正确只是门票。
服务端还可能校验:
- cookie;
- keepper challenge 状态;
- 请求 body;
- 时间窗口;
- session id;
- TLS 指纹;
- 滑块状态;
- 是否触发过前置页面;
- 是否同一连接/同一会话。
不要迷信静态代码
静态代码告诉你"可能怎么跑"。
运行时告诉你"现在怎么跑"。
在这类 WAF 里,运行时永远优先。
不要一开始就补完整环境
先问三个问题:
text
1. 这个值有没有被发到服务端?
2. 改它会不会影响结果?
3. 能不能用常量/参数化绕过,而不是完整模拟?
能少补一个环境项,就少一份未来的维护债。
refer__1036不是签名,是 WAF 写给服务端的一封推荐信。你不能只伪造落款,还得把正文、日期、信纸、墨水味儿都对上。
如果用一句话总结这次经历:
我们以为在逆一个 URL 参数,实际是在复刻一次 WAF challenge 的人生轨迹。