JS Reverse Engineering Case Study #10 — Reconstructing Custom Request-Header Signatures (nsssjss RSA + a Nested 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.
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/<notice-list-endpoint> - 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
nsssjssis essentially RSA encryption ofAPI path + "$$" + timestamp; -
signis not a plain MD5 but a nested hashmd5(sha1(...))— at first I only computed MD5 and could never get it to match.
2. Traffic Analysis
Open the notice page, press F12 → Network, filter by Fetch/XHR, and flip pages to capture:
POST https://www.example.com/<notice-list-endpoint> Look at the Request Headers — besides the normal ones, a bunch of site-defined headers really stand out:
nsssjss: <Base64 ciphertext> sign: <32-hex string> time: <ms timestamp> timestamp: <ms timestamp> sitecode: <SITE_CODE> siteguid: <SITE_GUID> regioncode: <REGION_CODE> regionguid: <REGION_GUID> url: /<notice-list-endpoint> 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:
-
nsssjssis Base64 ciphertext (an RSA signature — a public key starting withMIGf...produces long Base64 when used to encrypt); -
signis 32-hex (a hash signature); -
time/timestampare two millisecond timestamps whose values are very close, indicating they were taken separately at request time.
📷 [Figure 1 — screenshot suggestion] The
nsssjss/sign/time/timestampheaders on the notice-list request.
3. 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
/<notice-list-endpoint>, which locates theget_argcall 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,encryptto locate thensssjssgeneration 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("/<notice-list-endpoint>", "") 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() _ = "/<notice-list-endpoint>" So:
-
nsssjss = m(path + "$$" + b), andmis RSA encryption; -
timestamp = b; -
sign / timecome from thedefault()of the webpack moduleMMMX = 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 } 📷 [Figure 2 — screenshot suggestion] The call stack traced back to
get_argafter the XHR breakpoint hits the notice-list endpoint.
4. 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-obfuscated 6f3c.default t.default = function (e, t) { const i = new Date().getTime(); // time const salt = "<SALT_PART_1>" + "<SALT_PART_2>" + "<SALT_PART_3>"; // a fixed salt made of concatenated obfuscated constants const r = publicHash(i + "_" + String(e).split("?")[0] + salt); // core: the signature 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; } At this point I assumed publicHash was just MD5 and reproduced it in Node with crypto.createHash('md5') — it did not match. So I pulled publicHash apart and tested it in isolation, only to discover it is a nested hash:
sign = md5( sha1( time + "_" + path + <FIXED_SALT> ) ) In other words, compute SHA1 first (take the hex string), then run MD5 over that. I cross-validated using the time and sign from a real request:
time = <ms timestamp> path = /<notice-list-endpoint> salt = <FIXED_SALT> input = <ms>_/<notice-list-endpoint>_<FIXED_SALT> sha1 = <64-hex string> sign = md5(sha1) = <SIGN_HEX> ← matches the request header Once confirmed, I did not rewrite the hash myself. Instead I kept the site's original 6f3c module (including its internal SHA1/MD5 implementations) and only exposed the entry function get_arg. This is the most robust approach — no need to worry about tiny differences between the site's hash implementation and my local one.
The environment shim was simple; I only did:
-
window = global(some bundled code referenceswindow); - Keep the entire webpack runtime (
BB = ois__webpack_require__) — do not delete it; - The site's RSA uses
window.crypto.getRandomValues, and Node has a built-incryptofor that.
5. Python Implementation
The Python side is very easygoing: execjs compiles the whole file and one get_arg() call returns all four headers:
# From the real py (renamed to py/spider.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', 'sign.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": "<FORM_TOKEN>", "nsssjss": arg['nsssjss'], "origin": "https://www.example.com", "referer": "https://www.example.com/<notice-page>", "regioncode": "<REGION_CODE>", "regionguid": "<REGION_GUID>", "sign": arg["sign"], "sitecode": "<SITE_CODE>", "siteguid": "<SITE_GUID>", "time": arg["time"], "timestamp": arg["timestamp"], "url": "/<notice-list-endpoint>", "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": "<SITE_CODE>", "siteGuid": "<SITE_GUID>", "regioncode": "<REGION_CODE>", "regionguid": "<REGION_GUID>", "theme": "default", "regionpguid": "232", "rootregionguid": "232", "isOpenArea": "true", "tenantId": "<TENANT_ID>", "regionname": "<REGION_NAME>" } url = "https://www.example.com/<notice-list-endpoint>" data = { "regionGuid": "232", "regionCode": "<REGION_CODE>", "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) Notes:
-
nsssjss / sign / time / timestampare taken fromarg; -
sitecode / siteguid / regioncode / regionguid / form-tokenare site-level configuration parameters (not encryption artifacts) — just copy them from the captured request; -
form-tokenis a fixed hex string belonging to the site's public constants.
6. Result
Running python spider.py, the terminal prints the announcement-list JSON:
{"code":0,"data":{"list":[{"title":"<a government notice>","publishTime":"2024-xx-xx",...}],"total":...},"msg":"success"} If either sign or nsssjss fails to match, the server returns a signature-verification-failure / unauthorized error. If the list unfolds normally, then the whole RSA (nsssjss) + nested-hash (sign) header set has been restored correctly.
📷 [Figure 3 — screenshot suggestion] The procurement-notice list printed by the terminal.
7. Pitfalls & Notes
signis not a single MD5 butmd5(sha1(...)). This is the biggest trap in this project. At first I computed only MD5 and could never match; only after pullingpublicHashapart and testing it in isolation did I find that it runs SHA1 first, and that the outer MD5 processes the hex string of the SHA1.timeandtimestampare two different timestamps.timestampis(new Date).getTime()captured at module-load time;timeisgetTime()taken at the moment the signature function is called. The two are numerically close but usually not equal — do not mix them up.The signature uses the "path with the query stripped".
String(e).split("?")[0]means that if the URL carries parameters, only the part before the question mark is signed.The obfuscated string-array indices are shifted. You cannot guess what
n(303)is by array index alone — you must run the site's own decoding function (its string array is rotated into the correct order viapush/shiftduring initialization).$$insidensssjssis a separator — do not drop it. The concatenation format ispath + "$$" + timestamp; putting it in the wrong place makes the server unable to decrypt.The public key is charCode-encoded. The site does not write the Base64 public key directly but stores a long comma-separated list of numbers and restores it with
String.fromCharCode(Number(...)). When restoring, do not carry commas or spaces into the public key.form-tokenetc. are site-public constants. These are not your private data and can be copied; butsiteguid/regionmay differ across regions of the same site, so keep them consistent with the captured request.Encoding. Both reading the JS and sending the request must use UTF-8;
json.dumps(..., separators=(',', ':'))guarantees the compact format.
8. Takeaways
- The method for locating custom-header encryption: search the endpoint path + XHR-breakpoint backtracking, and the encryption logic is usually concentrated in a single
get_arg()that generates all headers at once. - Do not assume the hash algorithm is MD5. When you see "32 hex digits that will not compute correctly", you must try nested combinations like
md5(sha1(...))andsha1(md5(...)). - The obfuscated string array must be restored with the site's own decoding function — guessing indices by eye is guaranteed to be wrong; once you have the mapping, de-obfuscating the
defaultfunction makes the logic immediately clear.
Disclaimer: This article is for educational and security-research purposes only. Do not use it for any commercial or illegal activity. All endpoints/sites have been anonymized; always respect the target's terms of service and applicable laws.
