JS Reverse Engineering Case Study #3 — Reconstructing Request-Header Signatures (Content-Md5 + HMAC-SHA1) with Carved-Out Crypto Modules and execjs
Part 3 of the "JS Reverse Engineering in Practice — 30 Case Studies" series.
Source code (this case): https://github.com/scool112/openJSreverse (Project3)Note on sanitization: This is a sanitized, educational write-up. Every real domain, endpoint, site-specific constant and identifier has been replaced with a generic placeholder (
example.com,<FIXED_...>). No production endpoint or secret is disclosed. The purpose is to explain the technique, not to enable abuse of any specific service.
1. Goal & Background
The target this time is the news list of an industrial steel company's public web portal (referred to as "Site C"). The list API actually lives on a separate signing host, and the request carries an entire set of signature headers:
-
Target site:
https://www.example.com/media/news -
Target endpoint:
https://api.example.com/<content-list-endpoint>?domainId=<DOMAIN_ID> -
Parameters to reconstruct (request headers):
Content-Md5,x-date,x-nonce,x-signature,x-user -
Difficulty: ⭐⭐⭐ — the algorithm is a standard MD5 + HMAC-SHA1, but the signing-string layout, the HMAC key, and a "relative
require" gotcha are three real traps.
This project is a textbook case of request-header signing: the endpoint uses Content-Md5 to validate body integrity, x-nonce / x-date to defeat replay, x-signature (HMAC-SHA1) as the overall signature, and a fixed x-user application identifier. My approach was to split the signing logic into three reusable modules: MD5, HMAC-SHA1, and the assembly entry point.
2. Traffic Analysis
Open https://www.example.com/media/news, press F12 → Network → Fetch/XHR, and page through the news list. You will see a request to the content-list endpoint.
It is a POST with Content-Type: application/json;charset=UTF-8, and the body is:
{"pageNo":2,"pageSize":12,"condition":{"nodeId":<NODE_ID>}}
pageNo is the page number (note that it is zero-based here — pageNo:2 is actually page 3). The eye-catching request headers are these:
Content-Md5: <32-char lowercase hex md5 of the body>
x-date: <unix-seconds timestamp>
x-nonce: <10-digit random number>
x-signature: <40-char uppercase hex>
x-user: 0/<fixed-app-uuid>
A few observations:
-
x-userhas a fixed shape (0/plus a UUID) — it looks like a fixed application/tenant identifier whose value never changes between requests; -
x-dateis a seconds-level timestamp (10 digits), andx-nonceis a 10-digit random number; together they defeat replay; -
x-signatureis 40 characters of uppercase hex → the typical SHA1 length, which tells us it is HMAC-SHA1; -
Content-Md5is 32 characters of lowercase hex → MD5, used to validate the body.
📷 [Figure 1 — screenshot suggestion] The Network panel: the Request Headers of the content-list request.
Conclusion: what must be reconstructed is "how method / url / body / nonce / date are combined into x-signature", plus the Content-Md5 algorithm.
3. Locating the Signature Entry Point
In the Sources panel, do a global search for x-signature or x-nonce; it quickly lands on an axios request interceptor (interceptors.request.use). Because this is an axios wrapper, the interceptor is usually the block that assigns values to config.headers one by one.
I carved that interceptor out and kept the irrelevant axios fields as a config object, producing the entry point js/arg_sum.js in the repo. Here is the full logic:
// From the real repo JS: Project3/<site>/js/arg_sum.js
var Md5 = require("./arg_md5");
var Hmac = require("./arg_HmacSHA1")
function arg_sum(page){
var page_tru = page - 1;
function l(e) {
for (var n = [], t = Array.of("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"), a = 0; a < e; a++) {
var c = Math.floor(10 * Math.random());
n[a] = t[c]
}
return n.join("")
}
function s() {
return parseInt((new Date).getTime() / 1e3)
}
var e = {
"transformRequest": {},
"transformResponse": {},
"timeout": 20000,
"xsrfCookieName": "XSRF-TOKEN",
"xsrfHeaderName": "X-XSRF-TOKEN",
"maxContentLength": -1,
"headers": {
"common": {
"Accept": "application/json, text/plain, */*"
},
"delete": {},
"get": {},
"head": {},
"post": {
"Content-Type": "application/x-www-form-urlencoded"
},
"put": {
"Content-Type": "application/x-www-form-urlencoded"
},
"patch": {
"Content-Type": "application/x-www-form-urlencoded"
},
},
"method": "post",
"baseURL": "https://api.example.com",
"url": "/<content-list-endpoint>?domainId=<DOMAIN_ID>",
"data": {
"pageNo": page_tru,
"pageSize": 12,
"condition": {
"nodeId": <NODE_ID>
}
}
}
var n = l(10)
, t = s()
, a = Md5.Md5(e.data)
, c = ""
, o = "0/<fixed-app-uuid>"
, i = "<HMAC_KEY>";
e.headers["x-user"] = o,
e.headers["x-nonce"] = n,
e.headers["x-date"] = t,
e.headers["Content-Md5"] = a,
c = "".concat(e.method.toUpperCase(), "\n").concat(e.url.replace(e.baseURL, ""), "\nx-user:").concat(o, "\nx-nonce:").concat(n, "\nx-date:").concat(t, "\nContent-Md5:").concat(a, "\n");
var u = Hmac.hmac(c,i)
e.headers["x-signature"] = u
e.headers["x-date"] = e.headers["x-date"].toString();
// console.log(e.headers)
return e.headers
}
// console.log(arg_sum(2))
From this block you can read the entire signing protocol:
-
n = l(10)generates a 10-digit random number →x-nonce; -
t = s()→parseInt(Date.now()/1000)→ a seconds-level timestamp →x-date; -
a = Md5.Md5(e.data)→ MD5 over the request body →Content-Md5(note thatMd5.Md5internallyJSON.stringifys the object before hashing); -
o = "0/<fixed-app-uuid>"is the fixedx-user;i = "<HMAC_KEY>"is the HMAC key; - Build the "to-be-signed string"
c; its format is crucial:
POST
/<content-list-endpoint>?domainId=<DOMAIN_ID>
x-user:0/<fixed-app-uuid>
x-nonce:<10-digit-nonce>
x-date:<unix-seconds>
Content-Md5:<body-md5>
(with a trailing \n as well)
-
u = Hmac.hmac(c, i)runs HMAC-SHA1 overcwith keyi, and the result isx-signature.
The field order, field names, letter case, and the trailing \n on every line must match exactly — one character off and the signature is dead.
📷 [Figure 2 — screenshot suggestion] In the Sources panel: the axios request interceptor, or the HMAC breakpoint call stack.
4. Restoring the JS & Emulating the Environment
This site's JS does not depend on window / document, but the crypto library it uses was "shortcut" a bit (the MD5 / HMAC pieces of crypto-js were carved out into separate files). So I split the algorithm into three files:
// From the real repo JS: Project3/<site>/js/arg_md5.js (tail)
function MD5_my(Data){
var md5 = r.MD5(JSON.stringify(Data ? Data : "")).toString(r.enc.Hex)
return md5
}
module.exports = {
Md5:MD5_my
}
// From the real repo JS: Project3/<site>/js/arg_HmacSHA1.js (tail)
RR = {
HmacSHA1:function(e, r) {
return new LL.HMAC.init(TT,r).finalize(e)
}
}
function Hmac_my(c,i){
var u = RR.HmacSHA1(c, i).toString().toUpperCase();
return u
}
module.exports = {
hmac:Hmac_my
}
Two things to note:
- In
arg_md5.js,MD5_mydoesJSON.stringifyonData, so the Python side can safely pass the request-body object straight in; passing""/ nothing hashes an empty string; -
Hmac_myinarg_HmacSHA1.jscalls.toUpperCase()at the end, which is whyx-signatureis uppercase — this must be copied exactly.
The top of both files is a hand-"reshaped" crypto-js internal structure (the proto3 / proto2 / Object.create prototype-chain boilerplate). The comment at the top of arg_HmacSHA1.js records the pitfall that was hit at the time:
// From the real repo JS: Project3/<site>/js/arg_HmacSHA1.js (file-header comment)
/*
proto1_TT.cfg.__proto__.extend = function(t) { ... }
proto1_TT.cfg.__proto__ points at Object.prototype, which is equivalent to adding an
enumerable extend method to every object. When the mixIn loop `for (var e in t)` walks
the argument t, it climbs the prototype chain up to Object.prototype, enumerates
"extend", and so your breakpoint sees e = "extend".
In the browser, extend and mixIn are defined on the same Base prototype object;
Object.prototype has no extend, so e is correct there.
*/
In other words: in the browser crypto-js hangs extend on the Base prototype, not on the top-level Object; if you accidentally attach extend to Object.prototype while carving the code out, for...in will enumerate an extra extend, and the HMAC result will be wrong. This is one of the core pitfalls of this project.
5. Python Implementation
One crucial detail in py/<site>.py — os.chdir into the js directory first — because arg_sum.js uses require("./arg_md5"):
# From the real repo py: Project3/<site>/py/<site>.py
import requests
import json
import os
import execjs
def Spider(page:int):
base_path = os.path.dirname(os.path.abspath(__file__))
js_path = os.path.join(base_path,"..","js","arg_sum.js")
js_dir = os.path.dirname(js_path)
os.chdir(js_dir)
with open(js_path,encoding="utf-8") as f:
js_code = f.read()
js = execjs.compile(js_code)
headrs_arg = js.call("arg_sum",page)
headers = {
"Accept": "application/json, text/plain, */*",
"Content-Md5": headrs_arg['Content-Md5'],
"Content-Type": "application/json;charset=UTF-8",
"Origin": "https://www.example.com",
"Referer": "https://www.example.com/",
# ... the rest are ordinary browser headers (Accept-Language / Cache-Control /
# Connection / Pragma / Sec-Fetch-* / User-Agent / dnt / sec-ch-ua* etc.), trimmed here
"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",
"x-date": headrs_arg['x-date'],
"x-nonce": headrs_arg['x-nonce'],
"x-signature": headrs_arg['x-signature'],
"x-user": headrs_arg['x-user']
}
url = "https://api.example.com/<content-list-endpoint>"
params = {
"domainId": "<DOMAIN_ID>"
}
data = {
"pageNo": page - 1,
"pageSize": 12,
"condition": {
"nodeId": <NODE_ID>
}
}
data = json.dumps(data, separators=(',', ':'))
print(headers)
response = requests.post(url, headers=headers, params=params, data=data)
print(response.text)
print(response)
Spider(3)
Key points:
-
os.chdir(js_dir): this is the step most easily overlooked.execjsuses Node by default, and a relative path likerequire("./arg_md5")resolves relative to the current working directory (cwd), not relative to the JS file. So first switch the cwd tojs/; only then willrequirefindarg_md5.js/arg_HmacSHA1.jsin the same directory. -
The body must be byte-for-byte identical to what was signed. Inside
arg_sum, the signede.datais{"pageNo": page-1, "pageSize": 12, "condition": {"nodeId":<NODE_ID>}}; the body Python sends to the server must use the same object, the same key order, and the same compactness (separators=(',', ':')), orContent-Md5will not match. -
pageandpageNodiffer by 1. Passpage=3, and the actualpageNois 2.
6. Result
Running python <site>.py first prints the whole set of JS-generated signature headers, then the news list returned by the API:
{'Content-Md5': '<body-md5>', 'x-user': '0/<fixed-app-uuid>', 'x-nonce': '<10-digit-nonce>', 'x-date': '<unix-seconds>', 'x-signature': '<40-char uppercase hex>'}
{"code":200,"data":{"records":[{"title":"...","publishDate":"...","contentId":...}, ...],"total":...},"message":"success"}
<Response [200]>
As long as the server returns code:200 with a populated records, both the MD5 and the HMAC-SHA1 chains are correct. If you instead get a signature error (401/403), first re-check the signing-string format and whether x-date / x-nonce are the same values used at signing time.
📷 [Figure 3 — screenshot suggestion] The terminal output: the signature headers and the news list data.
7. Pitfalls & Notes
The relative
requireis the biggest trap.require("./arg_md5")insidearg_sum.jsdepends on the cwd, so the Python side mustos.chdir(js_dir), otherwiseexecjsraisesCannot find module './arg_md5'. This is also called out specifically in the README.Attaching
extendto the wrong prototype breaks HMAC. When carving crypto-js out,extendmust be attached to its ownBaseprototype, not toObject.prototype, otherwisefor...inenumerates an extraextend(the file-header comment records this pitfall).x-signaturemust be uppercase.Hmac_myapplies.toUpperCase(), so on the Python side do not "helpfully" call.lower().The signing string is fragile. Method uppercased, url with the baseURL stripped, field names untouched, a
\nat the end of every line (including the last one) — any single mismatch fails. Compare it character by character against theccaptured at a breakpoint in the browser.Body consistency.
Content-Md5is computed over the body, so the body's key order / compactness must match the signing step; thepageargument andpageNodiffer by 1.Keep the request rate low and respect the terms of service.
8. Takeaways
-
Content-Md5 + x-nonce + x-date + HMAC-SHA1is a common combination for CMS-style endpoints: MD5 protects the body, nonce/date defeat replay, and HMAC does the overall signing. -
When carving crypto-js out, the thing to fear most is prototype-chain pollution (
extendonObject.prototype): it silently changes the hash result, so always mount it the way the original library does. -
Calling JS that uses a relative
requirefromexecjsrequiresos.chdirinto the JS directory first — this is the key step that makes it work.
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 (1)
tr.ee/dev-to