JS Reverse Engineering Case Study #10 — Reconstructing Custom Request-Header Signatures (nsssjss RSA + a Nested md5(sha1(...)))

本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。

JS Reverse Engineering Case Study #10 — Reconstructing Custom Request-Header Signatures (nsssjss RSA + a Nested md5(sha1(…)))

JS 逆向工程案例研究 #10 — 重构自定义请求头签名 (nsssjss RSA + 嵌套 md5(sha1(…)))

Part 10 of the “JS Reverse Engineering in Practice — 30 Case Studies” series. Source code (this case): JS Reverse Engineering — 30 Case Studies · GitHub (Project10) Note on sanitization: This is a sanitized, educational write-up. All real domains, endpoint paths and site-specific constants (salt parts, site/region ids, the form token) have been replaced with generic placeholders (example.com, <FIXED_SALT>, <SITE_GUID>, <FORM_TOKEN>, …). No production endpoint or site-specific constant is disclosed. The purpose is to explain the technique, not to enable abuse of any specific service.

“JS 逆向工程实战 — 30 个案例研究”系列第 10 部分。源代码(本案例):JS Reverse Engineering — 30 Case Studies · GitHub (Project10)。脱敏说明:这是一篇经过脱敏的教育性文章。所有真实域名、接口路径及站点特定常量(盐值部分、站点/区域 ID、表单令牌等)均已替换为通用占位符(example.com, <FIXED_SALT>, <SITE_GUID>, <FORM_TOKEN> 等)。文中未披露任何生产环境接口或站点特定常量。本文旨在解释技术原理,而非用于滥用任何特定服务。

  1. Goal & Background The target is the announcement list of a provincial government procurement e-marketplace (referred to as “Site J”). I wanted to scrape its notice-list endpoint. Target site: https://www.example.com (a government procurement e-marketplace) Target endpoint: https://www.example.com/ Fields to reconstruct: nsssjss, sign, time, timestamp — all of them custom request headers Difficulty: ⭐⭐⭐⭐ — one header is RSA-encrypted, another is a “SHA1 wrapped in MD5” nested hash, and the whole JS is obfuscated with a string array. This case has two highlights: The weird name nsssjss is essentially RSA encryption of API path + ”$$” + timestamp; sign is not a plain MD5 but a nested hash md5(sha1(…)) — at first I only computed MD5 and could never get it to match.

  2. 目标与背景:目标是某省级政府采购电子交易平台(简称“J 站”)的公告列表。我希望爬取其公告列表接口。目标站点:https://www.example.com(某政府采购电子交易平台)。目标接口:https://www.example.com/。需要重构的字段:nsssjss, sign, time, timestamp — 均为自定义请求头。难度:⭐⭐⭐⭐ — 其中一个请求头经过 RSA 加密,另一个是“MD5 包裹 SHA1”的嵌套哈希,且整个 JS 代码通过字符串数组进行了混淆。本案例有两个亮点:奇怪的名称 nsssjss 本质上是对 API 路径 + ”$$” + 时间戳进行的 RSA 加密;sign 并非简单的 MD5,而是嵌套哈希 md5(sha1(…)) — 起初我只计算了 MD5,导致始终无法匹配。

  3. Traffic Analysis Open the notice page, press F12 → Network, filter by Fetch/XHR, and flip pages to capture: POST https://www.example.com/ Look at the Request Headers — besides the normal ones, a bunch of site-defined headers really stand out: nsssjss: sign: <32-hex string> time: timestamp: sitecode: <SITE_CODE> siteguid: <SITE_GUID> regioncode: <REGION_CODE> regionguid: <REGION_GUID> url: / form-token: <FORM_TOKEN> webapp: 1 Now look at the Payload, which is an ordinary JSON: {“regionGuid”:“232”,“regionCode”:“<REGION_CODE>”,“webApp”:“1”,“pageSize”:10,“pageNum”:4,“noticeType”:“0”} Conclusion: the request body is plaintext; all the encryption is in the headers. Specifically: nsssjss is Base64 ciphertext (an RSA signature — a public key starting with MIGf… produces long Base64 when used to encrypt); sign is 32-hex (a hash signature); time / timestamp are two millisecond timestamps whose values are very close, indicating they were taken separately at request time.

  4. 流量分析:打开公告页面,按 F12 → Network,过滤 Fetch/XHR,翻页抓包:POST https://www.example.com/。查看请求头(Request Headers)——除了常规字段外,还有一堆站点自定义的请求头非常显眼:nsssjss: <Base64 密文>,sign: <32位十六进制字符串>,time: <毫秒时间戳>,timestamp: <毫秒时间戳>,sitecode: <SITE_CODE>,siteguid: <SITE_GUID>,regioncode: <REGION_CODE>,regionguid: <REGION_GUID>,url: /,form-token: <FORM_TOKEN>,webapp: 1。再看 Payload,是一个普通的 JSON:{“regionGuid”:“232”,“regionCode”:“<REGION_CODE>”,“webApp”:“1”,“pageSize”:10,“pageNum”:4,“noticeType”:“0”}。结论:请求体是明文的,所有加密都在请求头中。具体来说:nsssjss 是 Base64 密文(一种 RSA 签名——以 MIGf… 开头的公钥加密后会产生较长的 Base64);sign 是 32 位十六进制(哈希签名);time / timestamp 是两个毫秒级时间戳,数值非常接近,表明它们是在请求时分别获取的。

  5. Locating the Encryption Entry Point This site’s JS is string-array obfuscated (javascript-obfuscator style), so a plaintext search for nsssjss finds nothing. My approach: Globally search the endpoint path: search for /, which locates the get_arg call site; XHR breakpoint: set an XHR breakpoint on the endpoint in Sources, then inspect the call stack when it pauses to find the header-generating function; Search RSA signatures: search for setPublicKey, JSEncrypt, encrypt to locate the nsssjss generation logic. Once extracted, the structure of get_arg is actually very clear (from the real file — renamed here to js/sign.js, around line 3152): // From the real JS (renamed to js/sign.js in this write-up) function get_arg() { var nsssjss = m("".concat(_, ”$$“).concat(b)) // nsssjss var timestamp = b.toString() // timestamp var MMMX = BB(“6f3c”) var vvx = MMMX.default(”/”, "") time = vvx[“time”].toString() sign = vvx[“sign”] li = {} li[“nsssjss”] = nsssjss li[“timestamp”] = timestamp li[“time”] = time li[“sign”] = sign return li } Here _ and b are two variables defined at the bottom of the file: // From the real JS b = (new Date).getTime() _ = ”/” So: nsssjss = m(path + ”$$” + b), and m is RSA encryption; timestamp = b; sign / time come from the default() of the webpack module MMMX = BB(“6f3c”). The definition of m (same file) is readable at a glance too — it restores a Base64 public key from char codes, then setPublicKey + encrypt: // From the real JS o = “77,73,71,102,77,65,48,…(omitted)…,65,81,65,66” // comma-separated char codes 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) } And p() simply restores that comma-separated list of numbers back into a string (the public key): // From the real 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 }

  6. 定位加密入口点:该站点的 JS 经过了字符串数组混淆(javascript-obfuscator 风格),因此直接搜索 nsssjss 找不到任何结果。我的方法:全局搜索接口路径:搜索 /,定位到 get_arg 的调用点;XHR 断点:在 Sources 面板中对该接口设置 XHR 断点,当断点触发时检查调用栈,找到生成请求头的函数;搜索 RSA 签名:搜索 setPublicKey, JSEncrypt, encrypt 以定位 nsssjss 的生成逻辑。提取后,get_arg 的结构非常清晰(来自真实文件——此处重命名为 js/sign.js,约第 3152 行):// 来自真实 JS(本文中重命名为 js/sign.js) function get_arg() { var nsssjss = m("".concat(_, ”$$“).concat(b)) // nsssjss var timestamp = b.toString() // timestamp var MMMX = BB(“6f3c”) var vvx = MMMX.default(”/”, "") time = vvx[“time”].toString() sign = vvx[“sign”] li = {} li[“nsssjss”] = nsssjss li[“timestamp”] = timestamp li[“time”] = time li[“sign”] = sign return li } 这里 _ 和 b 是文件底部定义的两个变量:// 来自真实 JS b = (new Date).getTime() _ = ”/” 因此:nsssjss = m(path + ”$$” + b),其中 m 是 RSA 加密;timestamp = b;sign / time 来自 webpack 模块 MMMX = BB(“6f3c”) 的 default() 方法。m 的定义(在同一文件中)也一目了然——它通过字符编码还原 Base64 公钥,然后执行 setPublicKey + encrypt:// 来自真实 JS o = “77,73,71,102,77,65,48,…(省略)…,65,81,65,66” // 逗号分隔的字符编码 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 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 }

  7. Restoring the JS & Emulating the Environment The nsssjss part is easy to restore (it is just RSA). The genuinely hard part is sign. Inside the module obtained by MMMX = BB(“6f3c”), the default function is string-array obfuscated, and its raw form looks like this: // From the real JS (the 6f3c module, in its obfuscated form) 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) and so on are string-array indices. I pulled the file’s string array x() out and restored it with the site’s own decoding function, which gave this mapping: n(303) => getTime n(311) => split n(301) => <SALT_PART_1> n(305) => <SALT_PART_2> n(296) => <SALT_PART_3> n(312) => time n(295) => url n(313) => sign n(308) => tokensign Substituting back in gives the de-obfuscated form: // De-obfus

  8. 还原 JS 并模拟环境:nsssjss 部分很容易还原(只是 RSA)。真正困难的是 sign。在通过 MMMX = BB(“6f3c”) 获取的模块内部,default 函数经过了字符串数组混淆,其原始形式如下:// 来自真实 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) => <SALT_PART_1>, n(305) => <SALT_PART_2>, n(296) => <SALT_PART_3>, n(312) => time, n(295) => url, n(313) => sign, n(308) => tokensign。代入后得到去混淆后的形式:// 去混淆后