-
-
[原创]七麦数据 qimai analysis 参数 JS 逆向实战(补环境 + execjs 还原混淆签名)
-
发表于: 2天前 252
-
> 本文是「JS 逆向实战 30 例」系列第 1 篇,项目源码:125K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6K6j5$3!0G2L8o6p5I4x3W2)9J5c8X3!0H3k6h3&6v1f1%4u0W2N6X3g2J5M7$3f1`. (`Project1`)
## 一、目标与背景
七麦数据(qimai.cn)是 App 排行榜数据站,我想抓它的「下架应用榜」列表。它的接口不是裸奔的,query 里带了一个叫 `analysis` 的签名参数,缺少或算错就直接被拒。
- 站点:`60eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2I4K9h3#2S2K9g2)9J5k6h3y4F1i4K6u0r3M7X3q4F1K9#2)9J5c8X3!0X3k6X3I4A6L8X3f1`.`
- 目标接口:`295K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2I4K9h3#2S2K9g2)9J5k6h3y4F1i4K6u0r3M7X3q4F1K9#2)9J5c8X3!0X3k6X3I4A6L8X3f1`.`
- 需还原字段:`analysis` 参数(另外几个是普通业务参数 `option` / `sdate` / `edate`)
- 难度:⭐⭐⭐⭐(四星。难点不在算法本身,而在目标 JS 被重度混淆,需要「补环境 + 执行原函数」才能拿到 `analysis`)
这个项目的逆向点非常典型:**参数签名**。它不像 MD5 那样一眼能看懂,而是被 javascript-obfuscator 一类工具做了字符串表 + 成员表达式混淆,单靠肉眼看变量名完全读不出来。我的策略是不硬刚混淆,而是**把浏览器环境补出来,直接调用站点自己导出的 `get_analysis()`**。
## 二、抓包分析
打开 `d4dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2I4K9h3#2S2K9g2)9J5k6h3y4F1i4K6u0r3M7X3q4F1K9#2)9J5c8X3!0X3k6X3I4A6L8X3g2Q4y4U0l9`.,F12 切到 **Network** 面板,筛选 `Fetch/XHR`,然后点一下页面上的榜单切换/翻页,就能看到对 `api.qimai.cn/rank/offline` 的请求。
点开这个请求看 **Payload**,query 长这样:
```text
analysis=eCRbWihbVghUX1xMCwdfWgwIAihbSEgHBwZUUVcMAlRUJ0tL
option=4
sdate=
edate=
```
`option` 是榜单类型(4 代表下架榜),`sdate`/`edate` 是日期区间,都是明文。**唯一不可读的就是 `analysis`**——它是一段大小写混杂、像 Base64 又像加密结果的字符串。每换一个榜单类型 / 日期,它都会变,说明它和数据强绑定,是一个真正的「签名」。
再看请求头:`Origin: 121K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2I4K9h3#2S2K9g2)9J5k6h3y4F1`、`Referer` 也是 qimai 站内,并且请求**带着登录 Cookie**(`PHPSESSID` / `qm_check` / `USERINFO` / `AUTHKEY` 等)。所以这个接口还需要登录态,光有 `analysis` 不够。
【图1:Network 面板里 rank/offline 请求的 Payload 与请求头 Cookie —— 建议截图】
结论:接口是 GET,参数明文,难点是把 `analysis` 的生成逻辑还原出来。
## 三、定位加密入口
既然是「参数签名」,我就去 **Sources** 面板全局搜参数名 `analysis`,或者直接搜它附近的业务字段 `option`、`sdate`。搜索结果会落到一个体积很大的混淆 JS 上(七麦的主包)。
一开始我尝试直接读它——变量名全是 `n2 / t2 / e2 / R / b / t` 这种单字母,字符串全被塞进一个字符串数组,通过 `n(12,13,14,...)` 这种「下标取值」函数还原(javascript-obfuscator 经典套路)。逐行还原成本太高,我决定换思路:**能不能直接把它的导出函数抠出来跑?**
顺着 `analysis` 的赋值处找,我找到了导出语句:
```js
// 取自仓库真实 js:Project1/qimai/qimai.js(文件开头与结尾)
var AA;
var get_analysis;
...
// (get_T 定义略,见下方第四节)
get_analysis = get_T;
}(function(a) {
return function() {
for (var n = arguments, t = "", e = 0, r = n.length; e < r; e++)
t += a[n[e]];
return t
}
}(["p", "u", "s", "h", "w", "e", "b", "a", "c", "k", ...
```
也就是说,执行完这一整包,全局就会多出一个 `get_analysis`,它指向内部那个被混淆得看不清的 `get_T` 函数。这正是我要的入口。
【图2:Sources 面板里 analysis 赋值处 / get_T 调用栈断点 —— 建议截图】
## 四、JS 还原与补环境
`get_T` 内部一开头就访问了 `document.cookie`(它要把登录态的 `synct` 之类信息也掺进签名),而 Node 里没有 `document`。所以补环境的第一步,就是在文件最顶上**伪造一个 `global.document`**,把 Cookie 塞进去:
```js
// 取自仓库真实 js:Project1/qimai/qimai.js(已脱敏,请换成你自己的登录 Cookie)
var AA;
var get_analysis;
global.document = {
cookie: "PHPSESSID=你的PHPSESSID; qm_check=你的qm_check; USERINFO=你的USERINFO; AUTHKEY=你的AUTHKEY; syncd=-700; synct=你的synct"
}
```
接着就是还原出来的核心函数 `get_T`。它把参数对象整理成一个数组,排序后拼串,再做哈希 / 对称加密,最后 `toString()` 成那段 `analysis`。真实代码如下:
```js
// 取自仓库真实 js:Project1/qimai/qimai.js(get_T 尾部,混淆变量原样保留)
function get_T() {
var i = AA(21725)
, r = AA(9669)
, o = AA[d7](r)
, r = AA(80129)
, u = AA[d7](r)
, c = AA(C)
, f = c[x][k][A]
, s = (o()[Pt][Tt] = At,
o()[Pt][Ct] = !H,
c[x][k][Rt])
, d = (0,
i[zt])(Bt, B)
, m = (0,
i[Ht])((0,
i[Nt])(_t), d)
, l = (0,
i[Ht])((0,
i[Nt])(Et), d)
, p = (0,
i[Ht])((0,
i[Nt])(Gt), d)
, v = ((0,
i[Ht])((0,
i[Nt])(It), d),
(0,
i[Ht])((0,
i[Nt])(Dt), d),
(0,
i[Ht])((0,
i[Nt])($t), d),
(0,
i[Ht])((0,
i[Nt])(Ft), d));
var n;
f || $ != s || (n = (0,
i[Zt])(m),
s = c[x][k][Rt] = -(0,
i[Zt])(l) || +new R[K] - r2 * n);
var e, r = +new R[K] - (s || H) - 1661224081041, a = [];
t = {
params: {
edate: "",
option: 4,
sdate: ""
},
url: "/rank/offline"
}
return void 0 === t[Ot] && (t[Ot] = {}),
R[W][o7](t[Ot])[M](function (n) {
if (n == v)
return !B;
t[Ot][_2](n) && a[b](t[Ot][n])
}),
a = a[jt]()[I5](N),
a = (0,
i[Jt])(a),
a = (a += p + t[qt][T](t[Tt], N)) + (p + r) + (p + 3),
e = (0,
i[Jt])((0,
i[Qt])(a, d))
return e.toString()
}
get_analysis = get_T;
```
从这段能看出 `analysis` 的大致构造:
1. 把 `params`(`edate` / `option` / `sdate`)遍历成数组 `a`;
2. `a[jt]()`(数组排序)后 `[I5](N)` 用分隔符 `N` 拼接;
3. 拼上 `url`、时间戳 `r` 和一串固定常量(`p`、`3` 等);
4. 用表达式里的对称加密方法 `i[Qt]` 加密、`i[Jt]` 做编解码,最终 `e.toString()` 输出。
那些常量(`p` / `N` / `d` / `Bt` / `_t` 等)都被字符串表还原函数 `n(...)` 藏着,逐个人肉解性价比太低。所以我的做法是:**不还原算法,直接把整包 JS 跑起来,调用它自己的 `get_analysis`。**
为了在 Node/`execjs` 下跑,我做了两件补环境的事:
- 伪造 `global.document`(上面已贴);
- 文件尾部原本还留了 `// console.log(get_analysis())` 的自测注释,说明在浏览器里它就是这么被调用的。
## 五、Python 实现
仓库里的 `py/qimai1.py` 非常短:读 JS → `execjs.compile` → 调 `get_analysis()` → 拼进 query:
```python
# 取自仓库真实 py:Project1/qimai/qimai1.py
import requests
import execjs
with open("qimai.js",encoding="utf-8") as f:
js_code = f.read()
js = execjs.compile(js_code)
analysis = js.call("get_analysis")
headers = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Origin": "314K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2I4K9h3#2S2K9g2)9J5k6h3y4F1i4K6t1$3M7i4g2G2N6q4)9K6b7W2)9J5b7H3`.`.
"Pragma": "no-cache",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
"dnt": "1",
"sec-ch-ua": "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-gpc": "1"
}
cookies = {
"PHPSESSID": "你的PHPSESSID",
"qm_check": "你的qm_check",
"USERINFO": "你的USERINFO",
"AUTHKEY": "你的AUTHKEY",
"synct": "你的synct",
"syncd": "-700"
}
url = "318K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7r3W2Q4x3X3g2I4K9h3#2S2K9g2)9J5k6h3y4F1i4K6u0r3M7X3q4F1K9#2)9J5c8X3!0X3k6X3I4A6L8X3g2Q4x3U0k6I4N6h3!0@1i4K6y4n7
params = {
"analysis": analysis,
"option": "4",
"sdate": "",
"edate": ""
}
print(params)
response = requests.get(url, headers=headers, cookies=cookies, params=params)
print(response.text)
print(response)
# "analysis": "eCRbWihbVghUX1xMCwdfWgwIAihbSEgHBwZUUVcMAlRUJ0tL",
```
几个细节:
- `js.call("get_analysis")` 无参调用,返回的就是那一整段 `analysis` 字符串;
- Cookie 用同一个登录态填进 `cookies`,和 JS 里 `document.cookie` 里塞的是同一份(**已脱敏,必须换成你自己登录后的值**);
- 参数保持 `option=4`(下架榜)和空的日期,和浏览器一致。
## 六、运行结果
执行 `python qimai1.py`,终端先打印出本次的 `params`(能看到 `analysis` 每次都不一样),然后打印接口返回的 JSON:
```text
{'analysis': 'eCRbWihbVghUX1xMCwdfWgwIAihbSEgHBwFSUVIPB1xXJ0tL', 'option': '4', 'sdate': '', 'edate': ''}
{"code":0,"msg":"成功","data":{"list":[{"appName":"...","appId":"...","rankChange":"..."}, ...], ...}}
<Response [200]>
```
只要返回 `code:0` 并且 `data.list` 有数据,就说明 `analysis` 算对了。如果返回的是 `code:-1` / `未登录` / `签名错误` 之类,优先怀疑 Cookie 失效或 `analysis` 对应的参数不对。
【图3:终端打印出的 analysis 与接口返回数据 —— 建议截图】
## 七、踩坑与注意
1. **`document` 必须补。** `get_T` 内部读 `document.cookie`,直接 `execjs.compile` 会报 `document is not defined`。我一开始忘了在文件顶部补 `global.document`,折腾半天。
2. **Cookie 是实时登录态。** `synct` 是带时间戳的会话字段,会过期。JS 里那串 Cookie 和 Python 请求头里的必须一致,且都得用你自己登录后的值(仓库里是 `你的xxx` 占位符)。Cookie 一失效,`analysis` 算得再对也会被拒。
3. **不要试图逐行人肉还原字符串表。** 混淆器的 `n(12,13,14,...)` 取值函数可以靠 AST + 自动化还原,但为了拿一个参数不值当;补环境跑原函数是最快路径。
4. **`analysis` 与参数强绑定。** 一旦把 `option` / `sdate` 改了,就要重新算 `analysis`(本例里 `get_T` 内部写死了 `option:4`,换榜单类型要改这里)。
5. **编码统一 UTF-8。** `open(..., encoding="utf-8")` 不能省,Windows 默认 GBK 会读出乱码导致 JS 解析失败。
6. **频率控制。** 这是需要登录的数据接口,务必低频、遵守站点条款与当地法规。
## 八、总结
1. **面对重度混淆的签名,最优解常常不是「还原算法」,而是「补环境执行原函数」**——只补它真正用到的 `document` / `window` 等几个对象即可。
2. **七麦 `analysis` 的本质是「参数排序拼串 + 哈希 + 对称加密 + Base64」**,入口就是站点自己导出的 `get_analysis`。
3. **`execjs` 调 JS 导出函数 + `requests` 带登录 Cookie**,是这类「混淆签名」最快落地的组合。
---
> 免责声明:本文仅供网络安全技术学习交流,请勿用于任何商业或非法用途。
赞赏
- [原创]山西省政府采购电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs) 0
- [原创]36Kr 登录接口 sign 签名 + 密码 RSA 加密逆向实战(JS 逆向 / MD5 签名 / JSEncrypt / execjs) 0
- [原创]五矿电商 ec.minmetals 请求 param 参数 RSA 长加密逆向实战(JS 逆向 / JSEncrypt / 补环境 / subprocess node) 251
- [原创]中国观鸟记录中心 birdreport 请求 RSA 加密 + 响应 AES 解密逆向实战(JS 逆向 / JSEncrypt / 补环境 / execjs) 277
- [原创][求助]数位观察 swguancha 响应 AES 解密逆向实战(JS 逆向 / 补环境 / execjs) 446