-
-
[原创]山西省政府采购电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs)
-
发表于: 1小时前 58
-
山西省政府采购电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs)
本文是「JS 逆向实战 30 例」系列第 10 篇,项目源码:JS 逆向实战 30 例 · 开源仓库 (
Project10)
一、目标与背景
山西省政府采购电子卖场(gpmall)的公告列表,我想抓它的 getNoticeList 接口。
站点:
16eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4F1P5r3W2K6K9r3g2F1k6#2)9J5k6s2A6X3j5$3N6V1P5X3#2U0k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6Y4M7r3#2S2L8r3I4Q4x3X3c8E0j5h3W2F1i4K6u0V1N6$3g2T1i4K6u0r3j5X3q4K6K9h3y4Q4x3V1k6K6P5p5&6G2N6r3W2U0k6b7`.`.目标接口:
a4cK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4F1P5r3W2K6K9r3g2F1k6#2)9J5k6s2A6X3j5$3N6V1P5X3#2U0k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6Y4j5i4c8W2N6$3q4&6i4K6u0r3k6%4m8E0j5h3I4D9i4K6u0V1j5Y4m8G2j5#2)9J5c8X3&6G2N6r3W2U0k6g2)9J5c8Y4j5I4i4K6u0r3K9h3N6F1L8%4u0W2i4K6u0r3k6$3g2@1e0X3!0@1K9h3y4W2e0r3W2K6N6l9`.`.需还原字段:
nsssjss、sign、time、timestamp(全部是自定义请求头)难度:⭐⭐⭐⭐(四星。一个头是 RSA 加密,另一个头是“SHA1 套 MD5”的嵌套哈希,且整段 JS 被字符串数组混淆)
这个项目的看点有两个:
nsssjss这个诡异的名字,本质是 RSA 对接口路径 + "$$" + 时间戳加密;sign不是普通 MD5,而是md5(sha1(...))的嵌套哈希,我一开始只算 MD5 死活对不上。
二、抓包分析
打开 .../gpmall-main-web/basic/sxNotice,F12 → Network,筛 Fetch/XHR,翻页时抓到:
POST 972K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4F1P5r3W2K6K9r3g2F1k6#2)9J5k6s2A6X3j5$3N6V1P5X3#2U0k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6Y4j5i4c8W2N6$3q4&6i4K6u0r3k6%4m8E0j5h3I4D9i4K6u0V1j5Y4m8G2j5#2)9J5c8X3&6G2N6r3W2U0k6g2)9J5c8Y4j5I4i4K6u0r3K9h3N6F1L8%4u0W2i4K6u0r3k6$3g2@1e0X3!0@1K9h3y4W2e0r3W2K6N6l9`.`.
看 Request Headers,除了常规头,有一堆站点自定义头特别扎眼:
nsssjss: <Base64 密文>
sign: <32 位十六进制>
time: <毫秒时间戳>
timestamp: <毫秒时间戳>
sitecode: sxzfcg
siteguid: 6883226032309207040
regioncode: 140001000
regionguid: 140001
url: /gateway/gpmall-bpoc/notice/v1/ignore/getNoticeList
form-token: d502cbd21715dc472875dddc20611dcf
webapp: 1
再看 Payload,是一个普通 JSON:
{"regionGuid":"232","regionCode":"140000000","webApp":"1","pageSize":10,"pageNum":4,"noticeType":"0"}结论:请求体是明文,加密全在请求头里。其中:
nsssjss是 Base64 密文(RSA 特征,MIGf...那种公钥加密后就是长 Base64);sign是 32 位十六进制(哈希特征);time/timestamp是两个毫秒时间戳,且数值非常接近,说明是请求时分别取的。
【图1:getNoticeList 请求头里的 nsssjss / sign / time / timestamp —— 建议截图】
三、定位加密入口
这个站点的 JS 被字符串数组混淆(javascript-obfuscator 风格),所以直接搜 nsssjss 这种明文是没有的。我的做法:
全局搜接口路径:搜
/gateway/gpmall-bpoc/notice/v1/ignore/getNoticeList,能定位到get_arg调用处;XHR 断点:在 Sources 里对
getNoticeList下 XHR 断点,命中断下来后看调用栈,找到生成请求头的函数;搜 RSA 特征:搜
setPublicKey、JSEncrypt、encrypt,定位到nsssjss的生成逻辑。
抠出来之后,get_arg 的结构其实很清晰(来自真实文件 js/zfcgdzmcgov.js 第 3152 行):
// 取自仓库真实 js:Project10/zfcgdzmcgov/js/zfcgdzmcgov.js
function get_arg(){
var nsssjss = m("".concat(_, "$$").concat(b)) // nsssjss
var timestamp = b.toString() // timestamp
var MMMX = BB("6f3c")
var vvx = MMMX.default("/gateway/gpmall-bpoc/notice/v1/ignore/getNoticeList","")
time = vvx["time"].toString()
sign = vvx["sign"]
li = {}
li["nsssjss"] = nsssjss
li["timestamp"] = timestamp
li["time"] = time
li["sign"] = sign
return li
}
这里 _ 和 b 是文件底部定义的两个变量:
// 取自仓库真实 js:Project10/zfcgdzmcgov/js/zfcgdzmcgov.js
b = (new Date).getTime()
_ = "/gateway/gpmall-bpoc/notice/v1/ignore/getNoticeList"
所以:
nsssjss = m(路径 + "$$" + b),而m是 RSA 加密;timestamp = b;sign / time来自MMMX = BB("6f3c")这个 webpack 模块的default()。
m 的定义(同上文件)也一眼可读——它用 charCode 还原出 Base64 公钥,再 setPublicKey + encrypt:
// 取自仓库真实 js:Project10/zfcgdzmcgov/js/zfcgdzmcgov.js
o = "77,73,71,102,77,65,48,...(省略)...,65,81,65,66" // 逗号分隔的 charCode
var m = function (e) {
var t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : p(o)
, n = new i.a;
return n.setPublicKey(t),
n.encrypt(e)
}
而 p() 就是把那段逗号分隔的数字还原成字符串(公钥):
// 取自仓库真实 js:Project10/zfcgdzmcgov/js/zfcgdzmcgov.js
function p() {
for (var e = "", t = (arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "").split(","), n = 0; n < t.length; n++)
e += String.fromCharCode(Number(t[n]));
return e
}
【图2:XHR 断点命中 getNoticeList 后回溯到 get_arg 的调用栈 —— 建议截图】
四、JS 还原与补环境
nsssjss 这块很好还原(就是 RSA)。真正难的是 sign。
MMMX = BB("6f3c") 拿到的模块里,default 函数是被字符串数组混淆的,原文长这样:
// 取自仓库真实 js:Project10/zfcgdzmcgov/js/zfcgdzmcgov.js(6f3c 模块,混淆形态)
t.default = function (e, t) {
const n = b
, i = (new Date)[n(303)]()
, r = _(p(i + "_" + String(e)[n(311)]("?")[0] + (n(301) + n(305) + n(296))))
, o = {};
if (o[n(312)] = i,
o[n(295)] = String(e)[n(311)]("?")[0],
o[n(313)] = r,
null != t && "" !== t) {
const r = _(p("" + t + String(e)[n(311)]("?")[0] + i));
o[n(308)] = r
}
return o
}
n(303)、n(311) 这些是字符串数组索引。我把文件里的字符串数组 x() 拿出来,用站点自己的解码函数还原,得到映射:
n(303) => getTime n(311) => split n(301) => _bosssoft_ n(305) => platform_0 n(296) => 95285 n(312) => time n(295) => url n(313) => sign n(308) => tokensign
代入后反混淆得到:
// 反混淆后的 6f3c.default
t.default = function (e, t) {
const i = new Date().getTime(); // 时间
const salt = "_bosssoft_" + "platform_0" + "95285"; // 混淆常量拼成的固定盐
const r = publicHash(i + "_" + String(e).split("?")[0] + salt); // 核心:签名
const o = {};
o["time"] = i;
o["url"] = String(e).split("?")[0];
o["sign"] = r;
if (t != null && t !== "") {
o["tokensign"] = publicHash("" + t + String(e).split("?")[0] + i);
}
return o;
}到这一步我以为 publicHash 就是 MD5,直接在 Node 里用 crypto.createHash('md5') 复现——结果对不上。于是我把 publicHash 拆开单独测,才发现它是嵌套哈希:
sign = md5( sha1( time + "_" + 路径 + "_bosssoft_platform_095285" ) )
也就是先算 SHA1(取十六进制字符串),再对它做一次 MD5。我用真实请求返回的 time 和 sign 做了对拍验证:
time = 1792149059952 path = /gateway/gpmall-bpoc/notice/v1/ignore/getNoticeList salt = _bosssoft_platform_095285 传入 = 1792149059952_/gateway/.../getNoticeList_bosssoft_platform_095285 sha1 = <64位十六进制> sign = md5(sha1) = c3b1d9f4ec6359a4b18be6fc78d49e3f ← 与接口头一致
确认无误后,我没有自己重写哈希,而是保留了站点原始的 6f3c 模块(连同它内部的 SHA1/MD5 实现),只把入口函数 get_arg 暴露出来。这样做最稳,不需要担心站点的哈希实现和我本地的细节差异。
补环境方面很简单,只做了:
window = global(部分打包代码会引用window);保留整个 webpack 运行时(
BB = o是__webpack_require__),不能删;站点的 RSA 用到了
window.crypto.getRandomValues,Node 下有内置crypto可用。
五、Python 实现
Python 侧非常省事:execjs 编译整个文件,调一次 get_arg() 就拿到全部四个头:
# 取自仓库真实 py:Project10/zfcgdzmcgov/py/zfcgdzmcgov.py
import requests
import json
import os
import execjs
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
JsPath = os.path.join(BASE_DIR, '..', 'js', 'zfcgdzmcgov.js')
with open(JsPath, encoding="utf-8") as f:
Js_code = f.read()
Js = execjs.compile(Js_code)
arg = Js.call('get_arg')
headers = {
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
"form-token": "d502cbd21715dc472875dddc20611dcf",
"nsssjss": arg['nsssjss'],
"origin": "9f0K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4F1P5r3W2K6K9r3g2F1k6#2)9J5k6s2A6X3j5$3N6V1P5X3#2U0k6$3!0$3i4K6u0W2j5$3&6Q4x3U0k6I4N6h3!0@1i4K6y4n7i4K6u0o6
"referer": "85dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4F1P5r3W2K6K9r3g2F1k6#2)9J5k6s2A6X3j5$3N6V1P5X3#2U0k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6Y4M7r3#2S2L8r3I4Q4x3X3c8E0j5h3W2F1i4K6u0V1N6$3g2T1i4K6u0r3j5X3q4K6K9h3y4Q4x3V1k6K6P5p5&6G2N6r3W2U0k6g2)9J5y4Y4q4#2L8%4c8Q4x3@1u0Q4x3V1x3`.
"regioncode": "140001000",
"regionguid": "140001",
"sign": arg["sign"],
"sitecode": "sxzfcg",
"siteguid": "6883226032309207040",
"time": arg["time"],
"timestamp": arg["timestamp"],
"url": "/gateway/gpmall-bpoc/notice/v1/ignore/getNoticeList",
"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",
"webapp": "1"
}
cookies = {
"webapp": "1",
"siteCode": "sxzfcg",
"siteGuid": "6883226032309207040",
"regioncode": "140001000",
"regionguid": "140001",
"theme": "default",
"regionpguid": "232",
"rootregionguid": "232",
"isOpenArea": "true",
"tenantId": "ZF_GYS_000001",
"regionname": "%E7%9C%81%E6%9C%AC%E7%BA%A7"
}
url = "1eaK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4F1P5r3W2K6K9r3g2F1k6#2)9J5k6s2A6X3j5$3N6V1P5X3#2U0k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6Y4j5i4c8W2N6$3q4&6i4K6u0r3k6%4m8E0j5h3I4D9i4K6u0V1j5Y4m8G2j5#2)9J5c8X3&6G2N6r3W2U0k6g2)9J5c8Y4j5I4i4K6u0r3K9h3N6F1L8%4u0W2i4K6u0r3k6$3g2@1e0X3!0@1K9h3y4W2e0r3W2K6N6q4)9J5y4Y4q4#2L8%4c8Q4x3@1t1`.
data = {
"regionGuid": "232",
"regionCode": "140000000",
"webApp": "1",
"pageSize": 10,
"pageNum": 4,
"noticeType": "0"
}
data = json.dumps(data, separators=(',', ':'))
response = requests.post(url, headers=headers, cookies=cookies, data=data)
print(response.text)
print(response)说明:
nsssjss / sign / time / timestamp固定从arg里取;sitecode / siteguid / regioncode / regionguid / form-token是站点级配置参数(不是加密产物),照抄抓包即可;form-token是一个固定的十六进制串,属于站点公开常量。
六、运行结果
执行 python zfcgdzmcgov.py,终端会打印公告列表 JSON:
{"code":0,"data":{"list":[{"title":"某政府采购公告","publishTime":"2024-xx-xx",...}],"total":...},"msg":"success"}如果 sign 或 nsssjss 任一对不上,会返回签名校验失败 / 未授权。能看到 list 正常展开,就说明 RSA(nsssjss)+ 嵌套哈希(sign)全套头都还原对了。
【图3:终端打印出的采购公告列表 —— 建议截图】
七、踩坑与注意
sign不是单一 MD5,而是md5(sha1(...))。 这是本项目最大的坑。我一开始只算 MD5,怎么都对不上;后来把publicHash拆开单独验证,才发现里面先跑了 SHA1,并且外层 MD5 处理的是 SHA1 的十六进制字符串。time和timestamp是两个不同的时间戳。timestamp是模块加载时(new Date).getTime();time是签名函数调用那一刻再取的getTime()。两者数值接近但通常不相等,别混用。签名里拼的是“去掉 query 的路径”。
String(e).split("?")[0]说明如果 URL 带参数,签名只取问号前的部分。混淆字符串数组的索引是位移过的。 不能按数组下标硬猜
n(303)到底是什么,必须跑一遍站点自己的解码函数(它的字符串数组在初始化时会push/shift旋转到正确顺序)。nsssjss里$$是分隔符,不能漏。 拼接格式是路径 + "$$" + 时间戳,直接拼错位置服务端就解不出来。公钥是 charCode 编码的。 站点没有直接写 Base64 公钥,而是存了一长串逗号分隔的数字,用
String.fromCharCode(Number(...))还原。还原时别把逗号或空格带进公钥。form-token等是站点公开常量。 这些不是你的隐私,可以照抄;但siteguid/region等在同站点不同区域可能不同,注意和抓包保持一致。编码问题。 Python 读取 JS 与发送请求都要 UTF-8;
json.dumps(..., separators=(',', ':'))保证紧凑格式。
八、总结
自定义请求头加密的定位方法:搜接口路径 + XHR 断点回溯,加密逻辑通常集中在一个
get_arg()里一次性生成全部头。别假设哈希算法就是 MD5。 遇到“32 位十六进制但算不对”的情况,一定要试试
md5(sha1(...))、sha1(md5(...))这类嵌套组合。混淆字符串数组必须用站点自己的解码函数还原,靠肉眼猜索引一定会错;拿到映射后把
default函数反混淆,逻辑立刻就清楚了。
免责声明:本文仅供网络安全技术学习交流,请勿用于任何商业或非法用途。
赞赏
- [原创]山西省政府采购电子卖场 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