DEV Community

FeiHua
FeiHua

Posted on

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

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:

  1. The weird name nsssjss is essentially RSA encryption of API path + "$$" + timestamp;
  2. 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. 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now look at the Payload, which is an ordinary JSON:

{"regionGuid":"232","regionCode":"<REGION_CODE>","webApp":"1","pageSize":10,"pageNum":4,"noticeType":"0"}
Enter fullscreen mode Exit fullscreen mode

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.

📷 [Figure 1 — screenshot suggestion] The nsssjss / sign / time / timestamp headers 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:

  1. Globally search the endpoint path: search for /<notice-list-endpoint>, which locates the get_arg call site;
  2. 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;
  3. 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("/<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
}
Enter fullscreen mode Exit fullscreen mode

Here _ and b are two variables defined at the bottom of the file:

// From the real JS
b = (new Date).getTime()
_ = "/<notice-list-endpoint>"
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

📷 [Figure 2 — screenshot suggestion] The call stack traced back to get_arg after 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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> ) )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 references window);
  • Keep the entire webpack runtime (BB = o is __webpack_require__) — do not delete it;
  • The site's RSA uses window.crypto.getRandomValues, and Node has a built-in crypto for 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)
Enter fullscreen mode Exit fullscreen mode

Notes:

  • nsssjss / sign / time / timestamp are taken from arg;
  • sitecode / siteguid / regioncode / regionguid / form-token are site-level configuration parameters (not encryption artifacts) — just copy them from the captured request;
  • form-token is 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"}
Enter fullscreen mode Exit fullscreen mode

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

  1. sign is not a single MD5 but md5(sha1(...)). This is the biggest trap in this project. At first I computed only MD5 and could never match; only after pulling publicHash apart 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.

  2. time and timestamp are two different timestamps. timestamp is (new Date).getTime() captured at module-load time; time is getTime() taken at the moment the signature function is called. The two are numerically close but usually not equal — do not mix them up.

  3. 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.

  4. 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 via push/shift during initialization).

  5. $$ inside nsssjss is a separator — do not drop it. The concatenation format is path + "$$" + timestamp; putting it in the wrong place makes the server unable to decrypt.

  6. 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.

  7. form-token etc. are site-public constants. These are not your private data and can be copied; but siteguid / region may differ across regions of the same site, so keep them consistent with the captured request.

  8. Encoding. Both reading the JS and sending the request must use UTF-8; json.dumps(..., separators=(',', ':')) guarantees the compact format.


8. Takeaways

  1. 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.
  2. 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(...)) and sha1(md5(...)).
  3. 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 default function 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)