DEV Community

FeiHua
FeiHua

Posted on

JS Reverse Engineering Case Study #8 — Reconstructing a param RSA Long-Encryption Payload (Dynamic Public Key + subprocess/Node Emulation)

JS Reverse Engineering Case Study #8 — Reconstructing a param RSA Long-Encryption Payload (Dynamic Public Key + subprocess/Node Emulation)

Part 8 of the "JS Reverse Engineering in Practice — 30 Case Studies" series.
Source code (this case): JS Reverse Engineering — 30 Case Studies · GitHub (Project8)

Note on sanitization: This is a sanitized, educational write-up. All real domains, endpoint paths and site-specific constants (cookie names, the RSA public key) have been replaced with generic placeholders (example.com, <SESSION_COOKIE>, <RSA_PUBLIC_KEY_BASE64>, ...). 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 this time is the procurement-info page of a large state-owned enterprise's e-procurement platform (referred to as "Site H"). I wanted to scrape its procurement-announcement list.

  • Target site: https://www.example.com (an enterprise e-procurement platform)
  • Target endpoint: https://www.example.com/<procurement-list-endpoint>
  • Field to reconstruct: param (RSA long encryption). The request body has only one field, param, which is the whole set of query conditions (including the signature and timestamp) RSA-encrypted into a single ciphertext.
  • Difficulty: ⭐⭐⭐⭐ — RSA long encryption, a prerequisite cookie, and a dynamic public key that is fetched from the server on every request.

It looks a bit like the earlier RSA case (a birdwatching-records site), but two things here are worth a dedicated write-up:

  1. The public key is not hard-coded — it is fetched dynamically from /<public-key-endpoint> at startup;
  2. You must get cookies first (<WAF_COOKIE> / <SESSION_COOKIE> / <APP_COOKIE>) before you can fetch the public key and hit the business endpoint at all.

2. Traffic Analysis

Open https://www.example.com/<purchase-info-page>?tabIndex=5, press F12 → Network, filter by Fetch/XHR, and switch between the tabs. You will capture:

POST https://www.example.com/<procurement-list-endpoint>
Content-Type: application/json

{"param":"<one long Base64 ciphertext>"}
Enter fullscreen mode Exit fullscreen mode

The whole request body is a single param — none of the query conditions are visible, which means all business parameters have been stuffed into param and encrypted together.

At the same time I noticed a prerequisite request:

POST https://www.example.com/<public-key-endpoint>
Enter fullscreen mode Exit fullscreen mode

Its response is a Base64 string, usually starting with MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ... — instantly recognizable as an RSA public key (MIGf is a common DER/PEM prefix). In other words, the site uses a "server hands down a public key, the front end encrypts param with it" pattern.

Looking at the request cookies, I see <WAF_COOKIE>, <SESSION_COOKIE> and <APP_COOKIE>; <WAF_COOKIE> is the classic risk-control cookie issued by a third-party WAF (a well-known anti-bot vendor). So the full chain should be:

1) GET  /<logon-endpoint>               get the base cookies
2) POST /<public-key-endpoint>          exchange cookies for the public key
3) POST /<procurement-list-endpoint>    encrypt `param` with the public key and request
Enter fullscreen mode Exit fullscreen mode

📷 [Figure 1 — screenshot suggestion] The list request body containing only param, plus the public-key response returning the public key.


3. Locating the Encryption Entry Point

Searching for param in Sources does not work well (it is far too common), so I switched to searching for encryption-library signatures:

  • Search for setPublicKey, encryptLong, JSEncrypt, RSAKey.
  • This quickly landed on the entry function get_arg, which looks like this (from the real file — renamed here to js/sign.js):
// From the real JS (renamed to js/sign.js in this write-up)
function get_arg(page, publickey) {
    r = publickey
    TT = new EEE()
    TT.setPublicKey(r)

    EE = {
        "businessClassfication": "",
        "dwmc": "",
        "inviteMethod": "",
        "lx": "CGGG",
        "mc": "",
        "pageIndex": page
    }

    function AAAAA(t) {
        var e = "hex"
        return new Md5(!0).update(t)[e]()
    }

    AAA = mmm(mmm({}, EE), {}, {
        sign: AAAAA(JSON.stringify(EE)),
        timeStamp: +new Date
    })

    s = TT.encryptLong(JSON.stringify(AAA))

    return { "param": s }
}
Enter fullscreen mode Exit fullscreen mode

It reads at a glance:

  • EE holds the business query conditions (classification, material name, procurement method, type CGGG, page number pageIndex);
  • AAAAA is MD5 (new Md5(!0).update(t)['hex'](), returning a lowercase hex string) and computes sign over JSON.stringify(EE);
  • AAA = { ...EE, sign, timeStamp };
  • finally TT.encryptLong(JSON.stringify(AAA)) performs the RSA long encryption, and the result is param.

At the top of that same file the site shims the environment with a window and crypto-js:

// From the real JS (top of the file)
window = global
window.crypto = require('crypto-js');
Enter fullscreen mode Exit fullscreen mode

So it depends internally on window.crypto, which means under Node I have to attach crypto-js.

The repo also contains a js/arg.js, which is not business code but a captured fetch snippet (used to pull the public key straight from the browser console, with placeholder cookies):

// From the real JS
var a = fetch("https://www.example.com/<public-key-endpoint>", {
    "headers": {
        "cookie": "<your-cookie, e.g. WAF_COOKIE=...; APP_COOKIE=...; SESSION_COOKIE=...>",
        "Referer": "https://www.example.com/<purchase-info-page>?tabIndex=5"
    },
    "method": "POST"
});
console.log(a)
Enter fullscreen mode Exit fullscreen mode

📷 [Figure 2 — screenshot suggestion] In Sources: the setPublicKey / encryptLong call stack.


4. Restoring the JS & Emulating the Environment

The site's bundle (renamed here to sign.js) is self-contained: RSA (EEE / TT) and MD5 (Md5) are both in the file. The only external dependency is require('crypto-js'). So the environment shim only needs to do two things:

  1. window = global;
  2. window.crypto = require('crypto-js').

The long-encryption implementation is worth a mention. The site overrides encryptLong so that a long string is split into 50-character chunks, each RSA-encrypted, then concatenated (from the real file):

// From the real JS
e.prototype.encryptLong = function (A) {
    var e = this.getKey()
        , t = (e.n.bitLength() + 7 >> 3) - 11;
    var n = ""
        , r = "";
    if (A.length > t)
        return n = A.match(/.{1,50}/g),
            n.forEach((function (A) {
                var t = e.encrypt(A);
                r += t
            }
            )),
            w(r);
    var a = e.encrypt(A)
        , s = w(a);
    return s
}
Enter fullscreen mode Exit fullscreen mode

The chunk length 50 is fixed by the site and must not be changed, otherwise the concatenation order and chunk boundaries change and the server cannot decrypt it.

So how does the Python side call this JS? I used a very robust approach: subprocess + node -e + vm instead of execjs. The reason is that the site JS uses window = global and require('crypto-js'), and execjs handles that kind of relative require poorly. The py/spider.py file inlines a small Node runner:

# From the real py (renamed to py/spider.py)
NODE_RUNNER = r"""
const fs = require('fs');
const vm = require('vm');
const nodeCrypto = require('crypto');
const jsPath = process.env.JS_PATH;
const fnName = process.env.FN_NAME;
const args = JSON.parse(process.env.FN_ARGS);
const source = fs.readFileSync(jsPath, 'utf-8');
const ctx = {
    require: function (name) { return name === 'crypto-js' ? nodeCrypto : require(name); },
    console: console,
    module: module,
    process: process,
    Buffer: Buffer,
};
ctx.window = ctx;
ctx.global = ctx;
ctx.globalThis = ctx;
vm.createContext(ctx);
vm.runInContext(source, ctx, { filename: jsPath });
const result = ctx[fnName].apply(null, args);
process.stdout.write(JSON.stringify(result));
"""
Enter fullscreen mode Exit fullscreen mode

This runner has a few neat tricks:

  • It uses vm.createContext to build a sandbox context, then points window / global / globalThis at that context itself;
  • It redirects require('crypto-js') to Node's built-in crypto (since the site only uses the basic window.crypto API, the built-in module is enough when crypto-js is not installed);
  • It passes the JS path, function name and arguments into Node via environment variables, then writes JSON.stringify to stdout, which the Python side picks up with json.loads.

The Python side then uses this runner to call get_arg:

# From the real py (renamed to py/spider.py)
def run_js(js_name: str, func_name: str, *args) -> dict:
    js_path = os.path.abspath(os.path.join(JS_DIR, js_name))
    env = os.environ.copy()
    env["JS_PATH"] = js_path
    env["FN_NAME"] = func_name
    env["FN_ARGS"] = json.dumps(list(args), ensure_ascii=True)
    proc = subprocess.run(
        ["node", "-e", NODE_RUNNER],
        capture_output=True,
        text=True,
        encoding="utf-8",
        env=env,
    )
    if proc.returncode != 0:
        raise RuntimeError("Node execution failed:\n" + (proc.stderr or proc.stdout))
    return json.loads(proc.stdout)
Enter fullscreen mode Exit fullscreen mode

5. Python Implementation

py/spider.py chains the entire flow (get cookies → get the public key → generate param → request) and is single-file and self-contained, without any custom helper module:

# From the real py (renamed to py/spider.py, excerpt)
LOGON_URL = "https://www.example.com/<logon-endpoint>"
PUBLIC_KEY_URL = "https://www.example.com/<public-key-endpoint>"
LIST_URL = "https://www.example.com/<procurement-list-endpoint>"


def get_cookie() -> dict:
    headers = {
        "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",
        "sec-ch-ua": "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"",
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": "\"Windows\"",
    }
    response = requests.get(LOGON_URL, headers=headers)
    return {
        "<WAF_COOKIE>": response.cookies["<WAF_COOKIE>"],
        "<SESSION_COOKIE>": response.cookies["<SESSION_COOKIE>"],
        "<APP_COOKIE>": response.cookies["<APP_COOKIE>"],
    }


def get_publicKey(cookie: dict) -> str:
    cookies = {
        "<APP_COOKIE>": cookie["<APP_COOKIE>"],
        "<SESSION_COOKIE>": cookie["<SESSION_COOKIE>"],
        "<WAF_COOKIE>": cookie["<WAF_COOKIE>"],
    }
    return requests.post(PUBLIC_KEY_URL, headers=headers, cookies=cookies).text


def Summary_arg(page: int) -> dict:
    device = Device()
    cookie = get_cookie()
    publickey = get_publicKey(cookie)
    js_arg = run_js("sign.js", "get_arg", page, publickey)

    headers = { ... }
    return {
        "url": LIST_URL,
        "headers": headers,
        "data": {"param": js_arg["param"]},
        "cookies": {
            "<APP_COOKIE>": cookie["<APP_COOKIE>"],
            "<SESSION_COOKIE>": cookie["<SESSION_COOKIE>"],
            "<WAF_COOKIE>": cookie["<WAF_COOKIE>"],
        },
    }


def run(req: dict) -> None:
    print(req)
    response = requests.post(
        url=req["url"],
        headers=req["headers"],
        json=req["data"],
        cookies=req["cookies"],
    ).text
    print(response)


if __name__ == "__main__":
    run(Summary_arg(1))
Enter fullscreen mode Exit fullscreen mode

The execution flow is obvious: get_cookie() → get_publicKey(cookie) → run_js("sign.js", "get_arg", page, publickey) to obtain param → request the list endpoint carrying the same set of cookies.


6. Result

Running python spider.py, the script first prints the assembled request (including param), then prints the endpoint response. The response is the procurement-announcement list JSON, with fields such as the announcement title, publish time and procurement method:

{'url': '.../<procurement-list-endpoint>', 'data': {'param': 'MIIB...<RSA ciphertext>'}, ...}
{"code":0,"data":{"records":[{"mc":"<notice title>","lx":"CGGG","...":"..."}],"total":...}}
Enter fullscreen mode Exit fullscreen mode

📷 [Figure 3 — screenshot suggestion] The terminal printing the request and the returned procurement list.


7. Pitfalls & Notes

  1. The public key is fetched dynamically — it must be a two-step flow. Hard-coding a public key works occasionally, but the site rotates keys; the correct approach is to POST /<public-key-endpoint> for the latest key every time.

  2. Reuse the same cookie set. The cookies used to fetch the public key and those used to call the business endpoint must belong to the same session. Once <SESSION_COOKIE> changes, a previously encrypted param may become invalid.

  3. The chunk length 50 must not change. A.match(/.{1,50}/g) inside encryptLong is a site convention; changing the size makes the server fail to decrypt.

  4. Fallback when crypto-js is not installed. The runner redirects require('crypto-js') to Node's built-in crypto, saving an install; but if your site JS uses advanced crypto-js APIs, it is better to honestly npm i crypto-js.

  5. <WAF_COOKIE> is a risk-control cookie from a third-party WAF. It is issued by that vendor's anti-bot layer — do not forge it yourself. If you cannot obtain these three cookies, first check whether you were blocked by the risk control (the response will be a challenge page rather than normal HTML).

  6. subprocess encoding. subprocess.run(..., encoding="utf-8") must be specified, otherwise on Windows the default GBK decoding of Chinese text throws a UnicodeDecodeError.

  7. json.dumps(..., ensure_ascii=True). When passing arguments to Node I used ASCII escaping to avoid encoding problems with Chinese characters in command lines / environment variables.


8. Takeaways

  1. For "dynamic public key + RSA long encryption" sites, the standard flow is: get cookies first, exchange cookies for the public key, then encrypt the business parameters.
  2. The site's overridden encryptLong (chunk size, concatenation scheme) must be kept whole — do not swap in stock JSEncrypt.
  3. When the JS contains window = global / relative require, subprocess + node -e + vm is more stable than execjs, and it lets you pipe arguments in cleanly via environment variables.

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)