-
-
[原创]五矿电商 ec.minmetals 请求 param 参数 RSA 长加密逆向实战(JS 逆向 / JSEncrypt / 补环境 / subprocess node)
-
发表于: 22小时前 250
-
[原创]五矿电商 ec.minmetals 请求 param 参数 RSA 长加密逆向实战(JS 逆向 / JSEncrypt / 补环境 / subprocess node)
五矿电商 ec.minmetals 请求 param 参数 RSA 长加密逆向实战(JS 逆向 / JSEncrypt / 补环境 / subprocess node)
本文是「JS 逆向实战 30 例」系列第 8 篇,项目源码:JS 逆向实战 30 例 · 开源仓库 (
Project8)
一、目标与背景
五矿电商(五矿集团采购平台)的采购信息页,我想抓它的采购公告列表。
- 站点:
7cdK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3 - 目标接口:
7c3K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8%4m8W2L8W2)9J5c8X3S2G2L8h3g2H3j5h3N6W2i4K6u0r3j5$3N6^5K9W2)9J5c8X3u0&6i4K6u0V1L8s2S2Q4x3X3c8H3j5h3N6W2 - 需还原字段:
param(RSA 长加密)。请求体里只有一个字段param,它是把所有查询条件(含签名、时间戳)整体 RSA 加密后的密文。 - 难度:⭐⭐⭐⭐(四星。RSA 长加密 + 前置 Cookie + 动态公钥,且公钥是每次请求从服务端拉的)
和上一篇(观鸟记录)的 RSA 有点像,但这里有两个新东西值得单独写一篇:
- 公钥不是写死的,而是启动时从
/open/homepage/public动态拉取; - 要先拿 Cookie(
__jsluid_s/JSESSIONID/SUNWAY-ESCM-COOKIE)才能拉公钥、才能请求业务接口。
二、抓包分析
打开 0c3K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8%4m8W2L8W2)9J5c8X3S2G2L8h3g2Q4x3V1k6H3N6i4u0U0K9r3q4K6k6g2)9J5k6r3W2F1k6X3!0Q4x3@1k6@1j5h3u0u0L8X3c8W2P5q4)9K6c8o6f1`.,F12 → Network,筛 Fetch/XHR,在不同 tab 之间切换就能抓到:
POST a2aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8%4m8W2L8W2)9J5c8X3S2G2L8h3g2H3j5h3N6W2i4K6u0r3j5$3N6^5K9W2)9J5c8X3u0&6i4K6u0V1L8s2S2Q4x3X3c8H3j5h3N6W2
Content-Type: application/json
{"param":"<一大段 Base64 密文>"}
整个请求体只有一个 param,其他什么查询条件都看不到——说明所有业务参数都被塞进 param 里一起加密了。
同时我注意到一个前置请求:
POST 85aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8%4m8W2L8W2)9J5c8X3S2G2L8h3g2H3j5h3N6W2i4K6u0r3M7s2g2T1L8r3W2U0
它的响应是一段 Base64,通常是 MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ... 开头 —— 一眼就是 RSA 公钥(MIGf 是 PEM/DER 里常见的前缀)。也就是说,站点是“动态下发公钥,前端用它加密 param”的模式。
再看请求 Cookie,有 __jsluid_s、JSESSIONID、SUNWAY-ESCM-COOKIE,其中 __jsluid_s 是典型的**加速乐(Jslu)**风控 Cookie。所以完整链路应该是:
1) GET /logonAction.do 拿基础 Cookie
2) POST /open/homepage/public 用 Cookie 换公钥
3) POST /open/homepage/cgxj/by-lx-page 用公钥加密 param 后请求
【图1:by-lx-page 请求体只有 param,以及 public 接口返回公钥 —— 建议截图】
三、定位加密入口
在 Sources 里搜 param 不太好搜(太常见),我改用搜加密库特征:
- 搜
setPublicKey、encryptLong、JSEncrypt、RSAKey。 - 很快定位到入口函数
get_arg,逻辑如下(来自真实文件js/minmetals2.js):
// 取自仓库真实 js:Project8/minmetals/js/minmetals2.js
function get_arg(page,publickey){
r = publickey
TT = new EEE()
TT.setPublicKey(r)
EE = {
"businessClassfication": "",
"dwmc": "",
"inviteMethod": "",
"lx": "CGGG",
"mc": "",
"pageIndex": page
}
function AAAAA (t) {
var e = "hex"
return new Md5(!0).update(t)[e]()
}
AAA = mmm(mmm({}, EE), {}, {
sign: AAAAA(JSON.stringify(EE)),
timeStamp: +new Date
})
s = TT.encryptLong(JSON.stringify(AAA))
return {"param":s}
}
一眼就懂:
EE是业务查询条件(分类、物资名称、采购方式、类型CGGG、页码pageIndex);AAAAA是 MD5(new Md5(!0).update(t)['hex'](),返回十六进制小写),对JSON.stringify(EE)求sign;AAA = { ...EE, sign, timeStamp };- 最后
TT.encryptLong(JSON.stringify(AAA))做 RSA 长加密,结果就是param。
再看这个文件顶部,站点给环境补了 window 和 crypto-js:
// 取自仓库真实 js:Project8/minmetals/js/minmetals2.js(顶部)
window = global
window.crypto = require('crypto-js');
这说明它内部依赖 window.crypto,Node 环境下需要把 crypto-js 挂上去。
另外仓库里还有个 js/arg.js,它不是业务代码,而是一段从浏览器里抓下来的 fetch 请求片段(用浏览器控制台直接取公钥用的,Cookie 是占位符):
// 取自仓库真实 js:Project8/minmetals/js/arg.js
var a = fetch("089K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8%4m8W2L8W2)9J5c8X3S2G2L8h3g2H3j5h3N6W2i4K6u0r3M7s2g2T1L8r3W2U0", {
"headers": {
"cookie": "你的cookie(形如 __jsluid_s=...; SUNWAY-ESCM-COOKIE=...; JSESSIONID=...)",
"Referer": "1c9K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8%4m8W2L8W2)9J5c8X3S2G2L8h3g2Q4x3V1k6H3N6i4u0U0K9r3q4K6k6g2)9J5k6r3W2F1k6X3!0Q4x3@1k6@1j5h3u0u0L8X3c8W2P5q4)9K6c8o6f1`."
},
"method": "POST"
});
console.log(a)
【图2:Sources 里 setPublicKey / encryptLong 调用栈 —— 建议截图】
四、JS 还原与补环境
minmetals2.js 是自包含的:RSA(EEE / TT)、MD5(Md5)都在文件里,唯一的外部依赖是 require('crypto-js')。所以补环境只做两件事:
window = global;window.crypto = require('crypto-js')。
长加密的实现值得一提。站点重写了 encryptLong,会把长文本按 50 字符分段分别 RSA 加密再拼接(来自真实文件):
// 取自仓库真实 js:Project8/minmetals/js/minmetals2.js
e.prototype.encryptLong = function(A) {
var e = this.getKey()
, t = (e.n.bitLength() + 7 >> 3) - 11;
var n = ""
, r = "";
if (A.length > t)
return n = A.match(/.{1,50}/g),
n.forEach((function(A) {
var t = e.encrypt(A);
r += t
}
)),
w(r);
var a = e.encrypt(A)
, s = w(a);
return s
}
分段长度 50 是站点固定的,不能改,否则拼接顺序和分段边界变了,服务端解不出来。
那么 Python 侧怎么调用这段 JS?这里用了一个很稳的方案:subprocess + node -e + vm,而不是 execjs。原因是站点 JS 里用 window = global、还 require('crypto-js'),execjs 对这类相对 require 支持不好。py/minmetals.py 里内联了一段 Node runner:
# 取自仓库真实 py:Project8/minmetals/py/minmetals.py
NODE_RUNNER = r"""
const fs = require('fs');
const vm = require('vm');
const nodeCrypto = require('crypto');
const jsPath = process.env.JS_PATH;
const fnName = process.env.FN_NAME;
const args = JSON.parse(process.env.FN_ARGS);
const source = fs.readFileSync(jsPath, 'utf-8');
const ctx = {
require: function (name) { return name === 'crypto-js' ? nodeCrypto : require(name); },
console: console,
module: module,
process: process,
Buffer: Buffer,
};
ctx.window = ctx;
ctx.global = ctx;
ctx.globalThis = ctx;
vm.createContext(ctx);
vm.runInContext(source, ctx, { filename: jsPath });
const result = ctx[fnName].apply(null, args);
process.stdout.write(JSON.stringify(result));
"""
这个 runner 有几个巧思:
- 用
vm.createContext造一个沙箱上下文,再把window / global / globalThis都指向它自己; - 把
require('crypto-js')重定向到 Node 内置的crypto(因为crypto-js未安装时,直接用内置crypto也能满足站点的window.crypto用法); - 通过环境变量把 JS 路径、函数名、参数传进 Node,最后
JSON.stringify输出,Python 端json.loads拿结果。
Python 侧再用这个 runner 调 get_arg:
# 取自仓库真实 py:Project8/minmetals/py/minmetals.py
def run_js(js_name: str, func_name: str, *args) -> dict:
js_path = os.path.abspath(os.path.join(JS_DIR, js_name))
env = os.environ.copy()
env["JS_PATH"] = js_path
env["FN_NAME"] = func_name
env["FN_ARGS"] = json.dumps(list(args), ensure_ascii=True)
proc = subprocess.run(
["node", "-e", NODE_RUNNER],
capture_output=True,
text=True,
encoding="utf-8",
env=env,
)
if proc.returncode != 0:
raise RuntimeError("Node 执行失败:\n" + (proc.stderr or proc.stdout))
return json.loads(proc.stdout)
五、Python 实现
py/minmetals.py 把整个链路(拿 Cookie → 拿公钥 → 生成 param → 请求)都串了起来,而且是单文件自包含,不依赖任何自写工具模块:
# 取自仓库真实 py:Project8/minmetals/py/minmetals.py(节选)
LOGON_URL = "73dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8r3!0Y4L8$3&6m8j5%4c8A6L8$3&6Q4x3X3g2V1L8H3`.`."
PUBLIC_KEY_URL = "a60K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8%4m8W2L8W2)9J5c8X3S2G2L8h3g2H3j5h3N6W2i4K6u0r3M7s2g2T1L8r3W2U0"
LIST_URL = "80aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6W2j5#2)9J5k6h3#2A6L8X3#2W2N6r3q4D9M7#2)9J5k6h3y4G2L8g2)9J5k6h3y4F1i4K6u0r3L8%4m8W2L8W2)9J5c8X3S2G2L8h3g2H3j5h3N6W2i4K6u0r3j5$3N6^5K9W2)9J5c8X3u0&6i4K6u0V1L8s2S2Q4x3X3c8H3j5h3N6W2"
def get_cookie() -> dict:
headers = {
"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",
"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\"",
}
response = requests.get(LOGON_URL, headers=headers)
return {
"__jsluid_s": response.cookies["__jsluid_s"],
"JSESSIONID": response.cookies["JSESSIONID"],
"SUNWAY-ESCM-COOKIE": response.cookies["SUNWAY-ESCM-COOKIE"],
}
def get_publicKey(cookie: dict) -> str:
cookies = {
"SUNWAY-ESCM-COOKIE": cookie["SUNWAY-ESCM-COOKIE"],
"JSESSIONID": cookie["JSESSIONID"],
"__jsluid_s": cookie["__jsluid_s"],
}
return requests.post(PUBLIC_KEY_URL, headers=headers, cookies=cookies).text
def Summary_arg(page: int) -> dict:
device = Device()
cookie = get_cookie()
publickey = get_publicKey(cookie)
js_arg = run_js("minmetals2.js", "get_arg", page, publickey)
headers = { ... }
return {
"url": LIST_URL,
"headers": headers,
"data": {"param": js_arg["param"]},
"cookies": {
"SUNWAY-ESCM-COOKIE": cookie["SUNWAY-ESCM-COOKIE"],
"JSESSIONID": cookie["JSESSIONID"],
"__jsluid_s": cookie["__jsluid_s"],
},
}
def run(req: dict) -> None:
print(req)
response = requests.post(
url=req["url"],
headers=req["headers"],
json=req["data"],
cookies=req["cookies"],
).text
print(response)
if __name__ == "__main__":
run(Summary_arg(1))
执行流程一目了然:get_cookie() → get_publicKey(cookie) → run_js("minmetals2.js","get_arg",page,publickey) 拿到 param → 带上同一套 Cookie 请求列表接口。
六、运行结果
执行 python minmetals.py,脚本会先打印组装好的请求(含 param),再打印接口响应。响应是采购公告列表的 JSON,能看到公告标题、发布时间、采购方式等字段:
{'url': '.../by-lx-page', 'data': {'param': 'MIIB...(RSA 密文)'}, ...}
{"code":0,"data":{"records":[{"mc":"某采购公告","lx":"CGGG","...":"..."}],"total":...}}
【图3:终端打印请求与返回的采购列表 —— 建议截图】
七、踩坑与注意
公钥是动态拉的,必须两步走。 直接写死一个公钥偶尔能跑,但站点会换公钥,正规做法是每次先
POST /open/homepage/public取最新公钥。Cookie 要复用同一套。 拿公钥用的 Cookie 和请求业务接口用的 Cookie 必须是同一次会话,
JSESSIONID一换,之前加密好的param就可能失效。分段长度 50 不能改。
encryptLong里A.match(/.{1,50}/g)是站点约定,改成别的大小,服务端解密会失败。crypto-js未安装时的替代。 runner 里把require('crypto-js')重定向到了 Node 内置crypto,省去装包;但如果你站点 js 用到 crypto-js 的高级 API,最好还是老实npm i crypto-js。__jsluid_s是加速乐风控 Cookie。 它是 Jslu 系反爬发的,别自己伪造;如果拿不到这三个 Cookie,首先要检查是不是被风控拦了(返回的是挑战页而不是正常 HTML)。subprocess的编码。subprocess.run(..., encoding="utf-8")必须指定,否则 Windows 下按 GBK 解码中文会报UnicodeDecodeError。json.dumps(..., ensure_ascii=True)。 传参数给 Node 时用了 ASCII 转义,避免命令行 / 环境变量里的中文编码问题。
八、总结
- “动态公钥 + RSA 长加密”的站点,标准流程是:先拿 Cookie,再用 Cookie 换公钥,最后加密业务参数。
- 站点重写的
encryptLong(分段长度、拼接方式)必须整块保留,不能换成原生 JSEncrypt。 - 当 JS 里有
window = global/ 相对require时,subprocess + node -e + vm比execjs更稳,还能通过环境变量优雅地把参数传进去。
免责声明:本文仅供网络安全技术学习交流,请勿用于任何商业或非法用途。
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)
赞赏
- [原创]五矿电商 ec.minmetals 请求 param 参数 RSA 长加密逆向实战(JS 逆向 / JSEncrypt / 补环境 / subprocess node) 210
- [原创]中国观鸟记录中心 birdreport 请求 RSA 加密 + 响应 AES 解密逆向实战(JS 逆向 / JSEncrypt / 补环境 / execjs) 233
- [原创][求助]数位观察 swguancha 响应 AES 解密逆向实战(JS 逆向 / 补环境 / execjs) 446
- [原创]全国建筑市场监管公共服务平台响应解密 JS 逆向实战(AES + 补环境 + execjs) 340
- [原创][求助]企查查动态请求头签名 JS 逆向实战(HMAC + 自定义字符表 + execjs) 270