-
-
[原创]**电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs)
-
发表于: 2026-10-19 07:56 524
-
[原创]**电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs)
*******电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs)
本文是「JS 逆向实战 30 例」系列第 10 篇,项目源码:JS 逆向实战 30 例 · 开源仓库 (Project10)
一、目标与背景
山西省政府采购电子卖场(gpmall)的公告列表,我想抓它的 getNoticeList 接口。
站点:a6aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4F1P5r3W2K6K9r3g2F1k6#2)9J5k6s2A6X3j5$3N6V1P5X3#2U0k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6Y4M7r3#2S2L8r3I4Q4x3X3c8E0j5h3W2F1i4K6u0V1N6$3g2T1i4K6u0r3j5X3q4K6K9h3y4Q4x3V1k6K6P5p5&6G2N6r3W2U0k6b7`.`.
目标接口:145K9s2c8@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 09aK9s2c8@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": "924K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4F1P5r3W2K6K9r3g2F1k6#2)9J5k6s2A6X3j5$3N6V1P5X3#2U0k6$3!0$3i4K6u0W2j5$3&6Q4x3U0k6I4N6h3!0@1i4K6y4n7i4K6u0o6
"referer": "ba8K9s2c8@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 = "d53K9s2c8@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 函数反混淆,逻辑立刻就清楚了。
免责声明:本文仅供网络安全技术学习交流,请勿用于任何商业或非法用途。
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)
赞赏
- [原创]荔枝网 X-ITOUCHTV 头签名 WASM 逆向实战(webpack + wasm-bindgen + sig.json 中转) 877
- [原创]scrape.center 靶场 WASM 签名 JS 逆向实战(WebAssembly.encrypt + Node 子进程 + Python) 529
- [原创]国家医保服务平台国密 SM2/SM4 签名 JS 逆向实战(x-tif 头 + 补环境 + execjs) 480
- [原创]凤凰云智登录密码 RSA 加密 JS 逆向实战(大整数 RSA + 阿里 IDaaS + execjs 补环境) 928
- [原创]**电子卖场 nsssjss/sign 请求头签名逆向实战(JS 逆向 / RSA / 嵌套哈希 / execjs) 523