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.
Top comments (0)