JS Reverse Engineering Case Study #2 — Reconstructing code / timestamp (a Fixed-Salt MD5 Signature)
Part 2 of the "JS Reverse Engineering in Practice — 30 Case Studies" series.
Source code (this case): https://github.com/scool112/openJSreverse (Project2)Note on sanitization: This is a sanitized, educational write-up. All real domains, endpoints and site-specific constants (including the salt) have been replaced with generic placeholders (
example.com,<FIXED_SALT>). No production endpoint 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 a public crypto market-cap ranking site (referred to as "Site B"). I wanted its coin ranking list.
-
Target site:
https://www.example.com(a crypto ranking site) -
Target endpoint:
https://api.example.com/<ranking-endpoint> -
Parameters to reconstruct:
codeandtimestamp(two query parameters) - Difficulty: ⭐⭐⭐ — the signature itself is a clear MD5; the work is getting the webpack-bundled, obfuscated export to run.
The reverse-engineering target is again a request signature, but much milder than the previous case. The JS is a webpack build with two-letter function names (Fk, xZ, ...), but once you untangle the logic it is a single MD5. My method is the same — emulate the environment and call the exported function — but this time I also read the formula out and verified it.
2. Traffic Analysis
Open https://www.example.com, press F12 → Network → Fetch/XHR, and browse the ranking list. You will see a request to the ranking endpoint.
Look at the Payload (query string):
pages=2,1
sizes=100,100
subject=market_cap
language=en_US
legal_currency=USD
code=<hex string>
timestamp=<ms timestamp>
platform=web_pc
v=0.1.0
extra_param=
Apart from code and timestamp, the other parameters are plain business values. timestamp is clearly a millisecond timestamp (it changes over time), and code is a hex string that changes together with timestamp — obviously a signature derived from it.
📷 [Figure 1 — screenshot suggestion] The Network panel: the ranking request's query string.
Conclusion: the thing to reconstruct is the relationship code = f(timestamp). Everything else can be copied verbatim.
3. Locating the Signature Entry Point
In the Sources panel, globally search for the parameter names code / timestamp, or for the endpoint path. The hit lands in the site's main JS (webpack build). Since it is split into modules, my trick was to search for the object-literal timestamp:, which jumps straight to where the signature object is built.
In the bundle I found the key lines:
// Anonymized excerpt from the reconstructed JS
r.d(t, { rQ: function () { return b }, /* ... */ xZ: function () { return k }, Fk: function () { return w } });
var /* ... deps ... */ v = r(<HASH_MODULE>),
p = Date.now().toString(),
d = {
timestamp: p,
code: v.V8.hashStr(p + "<FIXED_SALT>" + p.substring(0, 6)),
platform: "web_pc",
v: "0.1.0"
};
This single block is very informative:
-
p = Date.now().toString()— the current millisecond timestamp (thetimestampin the query); -
code = v.V8.hashStr(p + "<FIXED_SALT>" + p.substring(0, 6))— the signature: join (timestamp + fixed salt + first 6 chars of timestamp) and run it throughhashStr; -
platform: "web_pc",v: "0.1.0"— hard-coded constants.
v is a webpack module id whose default export is a V8 class. Fk (aliased to w) is the function that serializes the parameter object into a query string.
📷 [Figure 2 — screenshot suggestion] In the Sources panel: where
code/timestampare built, and thehashStrcall stack.
4. Restoring the JS & Emulating the Environment
First, confirm what V8.hashStr actually is. Digging into that module:
// Anonymized excerpt — the hash module
<HASH_MODULE>: function (t, e, r) {
"use strict";
r.d(e, { V8: function () { return s } });
class s {
constructor() {
this._dataLength = 0;
this._bufferLength = 0;
this._state = new Int32Array(4);
this._buffer = new ArrayBuffer(68);
this._buffer8 = new Uint8Array(this._buffer, 0, 68);
this._buffer32 = new Uint32Array(this._buffer, 0, 17);
this.start();
}
static hashStr(t, e = false) {
return this.onePassHasher.start().appendStr(t).end(e);
}
// ...
static _md5cycle(t, e) {
let r = t[0], s = t[1], n = t[2], i = t[3];
r += (s & n | ~s & i) + e[0] - 680876936 | 0;
r = (r << 7 | r >>> 25) + s | 0;
// ...
}
}
}
Seeing _md5cycle and the classic MD5 constants (-680876936, -389564586, 606105819, ...) confirms that V8 is an incremental MD5 implementation (128-bit output = 4×32-bit state words; a 68-byte buffer = one 64-byte block + 4-byte length). Therefore:
code = MD5(timestamp + "<FIXED_SALT>" + timestamp.substring(0, 6))
The algorithm is clear. To turn it into a query, we still need to see how Fk joins the object into a string:
// Anonymized excerpt — the serializer (Fk, aka w)
w = function (n) {
var t = new URLSearchParams, /* ... */
e = true, i = void 0;
try {
for (var u, a = Object.entries(n)[Symbol.iterator](); !( /* ... */ ); ) {
// append key=value into URLSearchParams
}
}
// ...
};
It simply uses URLSearchParams to join the object into key=value&.... Finally, the external entry point is get_code:
// Anonymized excerpt — the exported helper
function get_code(page) {
a = BB(<BUSINESS_MODULE>);
s = BB(<SIGN_MODULE>);
n = {
"pages": page + ",1", // page-dependent
"sizes": "100,100",
"subject": "market_cap",
"language": "en_US",
"legal_currency": "USD"
};
t = (0, s.Fk)((0, a.Z)({}, n, (0, s.xZ)(n?.extra_param)));
var t_s = t.toString();
var timestamp = t_s.split("timestamp=")[1].split("&")[0];
var code = t_s.split("code=")[1].split("×tamp")[0];
return { timestamp, code };
}
Here BB(...) is webpack's __webpack_require__; it loads the utility module and the signature module that contains Fk / xZ. get_code merges the business params with the signature object, hands them to Fk to build the full query, then splits the string to extract timestamp and code.
The emulation cost is nearly zero: this code depends on nothing from document / window (the timestamp comes from Date.now()), so as long as Node can require the bundle dependencies, execjs runs it directly.
5. Python Implementation
The Python script: read JS → compile → call get_code(page) → take code / timestamp into the query.
# Anonymized Python (URLs replaced with placeholders)
import requests
import execjs
def spy(page: int):
with open("bundle.js", encoding="utf-8") as f:
js_code = f.read()
js = execjs.compile(js_code)
arg = js.call("get_code", str(page)) # note: pass page as a STRING
code = arg["code"]
timestamp = arg["timestamp"]
headers = {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"origin": "https://www.example.com",
"referer": "https://www.example.com/",
"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",
}
url = "https://api.example.com/<ranking-endpoint>"
params = {
"pages": "2,1",
"sizes": "100,100",
"subject": "market_cap",
"language": "en_US",
"legal_currency": "USD",
"code": code,
"timestamp": timestamp,
"platform": "web_pc",
"v": "0.1.0",
"extra_param": "",
}
response = requests.get(url, headers=headers, params=params)
print(response.text)
spy(4)
Key points:
-
get_codeexpects a string page number (str(page)) because it doespage + ",1"string concatenation internally; - the returned
argis a dict — readarg["code"]/arg["timestamp"]; - the remaining params (
subject/language/legal_currency...) match the browser.
6. Result
Running the script prints the params (note code / timestamp differ every run), followed by the returned coin list:
{'pages': '2,1', 'sizes': '100,100', 'subject': 'market_cap', 'language': 'en_US', 'legal_currency': 'USD', 'code': '<hex>', 'timestamp': '<ms>', 'platform': 'web_pc', 'v': '0.1.0', 'extra_param': ''}
{"data":{"tickerList":[{"symbol":"BTC","price_usd":...,"market_cap_usd":...}, ...]},"code":0}
<Response [200]>
As long as the list comes back with complete fields, the signature chain is fully working.
📷 [Figure 3 — screenshot suggestion] The terminal output: the retrieved ranking data.
7. Pitfalls & Notes
The page number must be a string.
get_codedoespage + ",1", so passing a number may behave unexpectedly underexecjs. Usestr(page)consistently.timestampis a millisecond string.Date.now().toString()is a string;substring(0, 6)takes the first 6 digits. Don't switch to seconds or a number type.Do not change the fixed salt. It is a constant baked into the bundle; changing it breaks the signature.
hashStris not a plain lowercase MD5 hex. It is an incremental MD5 and may output an upper-case variant or one seeded by the second argument (hashStr(t, e = false)). The safest path is to call the original function rather than substitute another MD5 library.webpack module ids change. After a re-deploy, ids such as
BB(<SIGN_MODULE>)may become invalid; re-locateFk/hashStrby search.Rate-limit. Market data updates quickly, but still keep the request rate low and respect the site's terms.
8. Takeaways
-
code = MD5(timestamp + "<FIXED_SALT>" + timestamp.substring(0,6))— the "fixed salt + timestamp" pattern is one of the most common signatures; searching for thetimestampliteral often hits it immediately. -
Identify a JS hash library by its constants:
_md5cycle/-680876936means MD5; SHA-family implementations use different init values (e.g.1779033703). -
If you can run the original function, don't re-implement the algorithm. Since the signature is timestamp-driven, letting
execjscompute it fresh each time is more robust than reimplementing the hash in Python.
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