DEV Community

FeiHua
FeiHua

Posted on

JS Reverse Engineering Case Study #2 — Reconstructing code / timestamp (a Fixed-Salt MD5 Signature)

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: code and timestamp (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=
Enter fullscreen mode Exit fullscreen mode

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

This single block is very informative:

  • p = Date.now().toString() — the current millisecond timestamp (the timestamp in 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 through hashStr;
  • 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 / timestamp are built, and the hashStr call 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;
            // ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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("&timestamp")[0];
    return { timestamp, code };
}
Enter fullscreen mode Exit fullscreen mode

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

Key points:

  • get_code expects a string page number (str(page)) because it does page + ",1" string concatenation internally;
  • the returned arg is a dict — read arg["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]>
Enter fullscreen mode Exit fullscreen mode

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

  1. The page number must be a string. get_code does page + ",1", so passing a number may behave unexpectedly under execjs. Use str(page) consistently.

  2. timestamp is 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.

  3. Do not change the fixed salt. It is a constant baked into the bundle; changing it breaks the signature.

  4. hashStr is 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.

  5. webpack module ids change. After a re-deploy, ids such as BB(<SIGN_MODULE>) may become invalid; re-locate Fk / hashStr by search.

  6. Rate-limit. Market data updates quickly, but still keep the request rate low and respect the site's terms.


8. Takeaways

  1. code = MD5(timestamp + "<FIXED_SALT>" + timestamp.substring(0,6)) — the "fixed salt + timestamp" pattern is one of the most common signatures; searching for the timestamp literal often hits it immediately.
  2. Identify a JS hash library by its constants: _md5cycle / -680876936 means MD5; SHA-family implementations use different init values (e.g. 1779033703).
  3. If you can run the original function, don't re-implement the algorithm. Since the signature is timestamp-driven, letting execjs compute 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)

Collapse
 
aaaasds profile image
DEV VERIFY 👇 •

tr.ee/dev-to