东方航空 refer__1036逆向复盘

文章目录

    • 声明
    • [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 也搞没了。

更稳的策略:

  1. 先抓成功请求;
  2. 解出参数明文;
  3. 对比字段;
  4. 反推参与 hash 的输入;
  5. 再最小化复现。

不要上来就:

"我要模拟整个 Chrome。"

那条路很宽,但尽头往往是:

text 复制代码
node_modules 3GB
结果还是 405

不要把"算法正确"和"请求成功"混为一谈

算法正确只是门票。

服务端还可能校验:

  • cookie;
  • keepper challenge 状态;
  • 请求 body;
  • 时间窗口;
  • session id;
  • TLS 指纹;
  • 滑块状态;
  • 是否触发过前置页面;
  • 是否同一连接/同一会话。

不要迷信静态代码

静态代码告诉你"可能怎么跑"。

运行时告诉你"现在怎么跑"。

在这类 WAF 里,运行时永远优先。

不要一开始就补完整环境

先问三个问题:

text 复制代码
1. 这个值有没有被发到服务端?
2. 改它会不会影响结果?
3. 能不能用常量/参数化绕过,而不是完整模拟?

能少补一个环境项,就少一份未来的维护债。

refer__1036 不是签名,是 WAF 写给服务端的一封推荐信。

你不能只伪造落款,还得把正文、日期、信纸、墨水味儿都对上。

如果用一句话总结这次经历:

我们以为在逆一个 URL 参数,实际是在复刻一次 WAF challenge 的人生轨迹。

相关推荐
如烟花的信页3 小时前
外贸*登录逆向分析
javascript·爬虫·python·js逆向
冰履踏青云4 小时前
从 202 到 200:一次 aws-waf-token 纯 Python 还原实录
js逆向·aws-waf-token
Amo Xiang2 天前
SpiderDemo 第5题:OB混淆实战 —— 反调试绕过与 signature 签名还原
python·js逆向·爬虫逆向·反调试·spiderdemo·ob混淆
冰履踏青云3 天前
宏翼平台登录参数逆向
js逆向·宏翼平台登录
Amo Xiang3 天前
申万宏源证券新闻中心 —— AES/ECB 响应解密(摩斯电码派生密钥)
js逆向·python爬虫·逆向工程·aes加密·响应解密
冰履踏青云3 天前
某音x-tt-session-dtrait 算法逆向复盘
js逆向·session-dtrait
如烟花的信页4 天前
*花顺cookie逆向分析
javascript·爬虫·python·js逆向
Amo Xiang4 天前
SpiderDemo 第1题:请求头检测挑战 —— Disable cache 缓存头与请求特征差异
js逆向·爬虫逆向·spiderdemo·tls指纹·请求头检测·disable cache
Amo Xiang5 天前
全国新书网 —— AES/CBC 双向加解密
js逆向·python爬虫·cryptojs·前端加密·aes加解密·pycryptodome