DEV Community

FeiHua
FeiHua

Posted on

JS Reverse Engineering Case Study #11 — Reconstructing a Login Password RSA Encryption

JS Reverse Engineering Case Study #11 — Reconstructing a Login Password RSA Encryption (Big-Integer RSA + a Third-Party IDaaS + execjs Environment Shimming)

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

Note on sanitization: This is a sanitized, educational write-up. All real domains, endpoint paths and site-specific constants (module ids, private-ish config values) have been replaced with generic placeholders (example.com, <SCENE_ID>, <APP_NAME>, ...). 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 (referred to as "Site K") is an enterprise office platform whose login goes through a third-party identity-as-a-service (IDaaS) provider. Packet capture shows that the account appears only as a plaintext loginId, while password is a long hex string — that is the core of this article: the RSA encryption of the login password. Besides the password, the body also carries captchaVerifyParam (the ticket of the third-party captcha's invisible verification) and sceneId; the former must be obtained via a captcha-solving/fingerprint service, and the latter is a site-public constant.

This is a classic "JS encryption + restore only a single encryption function" scenario. The difficulty is not in the algorithm itself (it is standard RSA) but in:

  • the RSA is a big-integer implementation, and the key is one of three hard-coded public keys in the page (selected by environment);
  • the site JS is a single webpack bundle, so you must shim window / localStorage / GLOBAL_CONFIG before it runs;
  • the plaintext is encodeURIComponent-encoded before encryption — an easy detail to miss.

  • Target site: https://www.example.com (an enterprise office platform)

  • Target endpoint: https://api.example.com/<idp-login-endpoint> (POST)

  • Fields to reconstruct: password (RSA), captchaVerifyParam, sceneId

  • Difficulty: ⭐⭐⭐ (webpack environment shimming + big-integer RSA)

Field Meaning
loginId account (plaintext)
password RSA-encrypted hex string (the focus of this article)
captchaVerifyParam the verification ticket of the third-party captcha, to be obtained separately
sceneId captcha scene id (a site-public constant)

2. Traffic Analysis

Open the DevTools Network panel, type the account and password on the login page, click login, filter by the login endpoint, and you will see this POST:

POST https://api.example.com/<idp-login-endpoint>?access_token=
content-type: application/json;charset=UTF-8
origin: https://www.example.com
referer: https://www.example.com/
gray-lease-code: null
gray-user-id: null

{"loginId":"<your account>","password":"<your password (ciphertext)>","captchaVerifyParam":"<your captchaVerifyParam>","sceneId":"<SCENE_ID>"}
Enter fullscreen mode Exit fullscreen mode

It is immediately clear: loginId is plaintext, and password is not MD5 (MD5 is a fixed 32 characters) but a long hex string whose length varies with the plaintext — the hallmark of RSA. captchaVerifyParam is generated by the third-party captcha SDK and is an "environment-bound" artifact outside the scope of this restoration, so we leave a placeholder for it.

📷 [Figure 1 — screenshot suggestion] The captured login request in DevTools, with password circled.

I also copied the gray-lease-code / gray-user-id headers into Python, because the server validates them.


3. Locating the Encryption Entry Point

The site has only one big bundle (renamed here to js/sign.js, about 82 KB). The fastest way to locate the encryption function is to globally search keywords: RSA, encryptedString, password.

Searching for encryptedString lands in a webpack module 757026, which exports the "plaintext → ciphertext" function:

// From js/sign.js, module 757026 — the password-encryption entry
757026: (e, t, n) => {
    "use strict";
    var r = n(385784).default;
    var a = r(n(954746))          // big-integer RSA implementation
        , o = r(n(624362))        // daily environment public key
        , i = r(n(896378))        // pre environment public key
        , s = r(n(949284));       // prod environment public key
    const l = {
        daily: o.default, zlg_daily: o.default,
        pre:   i.default, zlg_pre:   i.default,
        prod:  s.default, zlg_prod:  s.default
    };
    a.default.setMaxDigits(130);
    const u = new a.default.RSAKeyPair(
        "10001", "",                                    // exponent e
        localStorage.publicKey || l[window.GLOBAL_CONFIG.env]   // modulus n (selected by environment)
    );
    t.default = e => a.default.encryptedString(u, encodeURIComponent(e))  // ★ encode first, then encrypt
}
Enter fullscreen mode Exit fullscreen mode

A few key points become clear at once:

  1. The public key is not fetched from an endpoint but chosen in-page by window.GLOBAL_CONFIG.env (three sets — daily / pre / prod — all hex moduli; the e.exports of modules 624362/896378/949284 are those long hex strings). In this project GLOBAL_CONFIG.env = "prod".
  2. The exponent e = "10001" (i.e. 65537) and d is empty (it is a public key only, no private key needed).
  3. setMaxDigits(130) determines the chunk size and must be called, otherwise the big-integer math breaks.
  4. The easiest detail to miss is encodeURIComponent(e) — URL-encode first, then encrypt.

Going up one level, module 954746 (the host of encryptedString) is a classic "big-integer + Barrett reduction" RSA:

// From js/sign.js — the export of the big-integer RSA
e.exports = {
    RSAKeyPair: function (e, t, n) {
        this.e = C(e), this.d = C(t), this.m = C(n),
        this.chunkSize = 2 * w(this.m), this.radix = 16, this.barrett = new G(this.m)
    },
    setMaxDigits: d,
    encryptedString: function (e, t) {          // e=RSAKey, t=plaintext
        for (var n = new Array, r = t.length, a = 0; a < r;) n[a] = t.charCodeAt(a), a++;
        for (; n.length % e.chunkSize != 0;) n[a++] = 0;     // pad with zeros to a multiple of chunkSize
        var o, i, s, l = n.length, u = "";
        for (a = 0; a < l; a += e.chunkSize) {
            for (s = new p, o = 0, i = a; i < a + e.chunkSize; ++o)
                s.digits[o] = n[i++], s.digits[o] += n[i++] << 8;
            var c = e.barrett.powMod(s, e.e);                // per-chunk modular exponentiation
            u += (16 == e.radix ? E(c) : v(c, e.radix)) + " ";
        }
        return u.substring(0, u.length - 1)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is an independently reproducible algorithm: split the plaintext into chunkSize chunks, treat each chunk as a big integer, compute m^e mod n, and concatenate the hex results.

📷 [Figure 2 — screenshot suggestion] Setting a breakpoint on the t.default = e => ...encryptedString(...) line and inspecting the call stack.


4. Restoring the JS & Emulating the Environment

Because the bundled code references window / localStorage / window.GLOBAL_CONFIG from the moment it starts, running it directly in Node throws "window is not defined". So I shim the environment at the top of the file first (this is also the repo's real approach):

// From js/sign.js — the environment shim at the top
var BB;

window = global
self = window

localStorage = {
    "__ETAG__CNA__ID__": "20260728_fpbvIgnmvHEBASQIgmayfsy0",
    "tfstk__": "...",       // a site tracking field, copied verbatim, not private
    "isg__": "...",
    "syfhs": "1346664329",
    "ETLCD": "false",
    "auyst": "1785243896"
}
localStorage.publicKey = undefined          // key: undefined by default, so it takes the GLOBAL_CONFIG.env branch
window.GLOBAL_CONFIG = {
    "assets": "//cdn.example.com/<assets-path>/",
    "envType": "PROD",
    "appType": "YZ",
    "appName": "<APP_NAME>",
    "env": "prod",                          // ★ decides which public key set is used
    "version": "5.0.23"
}
Enter fullscreen mode Exit fullscreen mode

After shimming the environment, the bundle initializes normally, and the tail is the original author's example call:

// From js/sign.js — the trailing call
NN = { "loginId": "<your account>", "password": "<your plaintext password>" }  // placeholder / example values

DD = BB("757026")
const password = (0, DD.default)(NN.password)   // ← get the RSA ciphertext
console.log(password)

TT = "<SOME_ID>"
NXNX = "1.0" === "3.0" ? t : window.btoa(JSON.stringify({
    certifyId: t,
    sceneId: this.config.SceneId,
    isSign: !0,
    securityToken: this.config.securityToken
}))
Enter fullscreen mode Exit fullscreen mode

Running node sign.js prints the ciphertext for NN.password to the console. BB("757026") is the webpack module loader, equivalent to require.

Note: in the repo NN.loginId / NN.password are demo placeholders, not real credentials; real accounts and passwords are private, so the repo uses <your-xxx> placeholders consistently.


5. Python Implementation

The Python side is fairly simple: the password ciphertext and the captcha ticket are inputs (first compute the ciphertext with node or this JS, then fill it in), and then POST with all headers.

# From py/spider.py (renamed)
import requests
import json

headers = {
    "accept": "application/json, text/plain, */*",
    "content-type": "application/json;charset=UTF-8",
    "gray-lease-code": "null",
    "gray-user-id": "null",
    "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/<idp-login-endpoint>"
params = {"access_token": ""}
data = {
    "loginId": "<your account>",
    "password": "<your password (ciphertext)>",   # ← RSA ciphertext
    "captchaVerifyParam": "<your captchaVerifyParam>",
    "sceneId": "<SCENE_ID>"
}
data = json.dumps(data, separators=(',', ':'))   # ★ compact JSON, no spaces
response = requests.post(url, headers=headers, params=params, data=data)

print(response.text)
print(response)
Enter fullscreen mode Exit fullscreen mode

You can also wire it into execjs so Python calls the JS directly for the ciphertext (note that the trailing console.log(password) in sign.js pollutes stdout, so it is best to change it into an entry function that only returns a value):

import execjs
Js = execjs.compile(open("js/sign.js", encoding="utf-8").read())
# Precondition: change the file tail into an exported function, e.g.
#   function get_pwd(p){ return BB("757026").default(p) }
# pwd = Js.call("get_pwd", "<your plaintext password>")
Enter fullscreen mode Exit fullscreen mode

6. Result

Once node sign.js runs successfully, the terminal produces a long hex ciphertext (the RSA result of NN.password), noticeably longer than the plaintext. Fill this ciphertext into password in spider.py, bring a valid captchaVerifyParam, and the server no longer returns "wrong password" but instead proceeds normally with the login flow:

{"success":true,"content":{"data":{...},"...":"..."}}   // or the post-login redirect state data
<Response [200]>
Enter fullscreen mode Exit fullscreen mode

If the captcha ticket is expired/invalid, you will see a verification-failure response instead (a provider-side error code).

📷 [Figure 3 — screenshot suggestion] The terminal printing the RSA ciphertext and the login response returning 200.


7. Pitfalls & Notes

  1. Encode first, then encrypt: encryptedString(u, encodeURIComponent(e)) — Chinese characters, +, @, etc. in the plaintext get URL-encoded before entering RSA; miss this step and the result is certainly wrong.
  2. setMaxDigits(130) cannot be omitted: it initializes the big-integer bit width; calling RSAKeyPair without it throws.
  3. The public key is chosen by environment: l[window.GLOBAL_CONFIG.env] uses the current environment's public key; when shimming the environment yourself, writing the wrong env (say pre) makes the ciphertext use a different modulus, and the server immediately rules the password wrong. In the repo, prod corresponds to the long hex in 949284.
  4. captchaVerifyParam is an environment-bound artifact: it is not pure algorithm — it correlates with browser fingerprint, behavior and timestamp, and is very hard to reproduce reliably with plain requests; you need a captcha-solving platform or dedicated captcha reverse-engineering. The repo likewise leaves it as <your captchaVerifyParam>.
  5. execjs stdout pollution: sign.js ships with a trailing console.log(password), whose output mixes into node stdout when called via execjs. The safe approach is to rewrite it as an exported function that only returns a string.
  6. JSON must be compact: json.dumps(..., separators=(',', ':')) — the IDaaS provider may validate the raw body (even whitespace).
  7. Placeholders: in the repo loginId / password / captchaVerifyParam are all placeholders; running as-is will fail, so replace them with your own.

8. Takeaways

Sites like this bundle the "key + RSA implementation" entirely into the webpack bundle. The key to reverse-engineering is not rewriting RSA but shimming window / localStorage / GLOBAL_CONFIG so the bundle runs, then taking hold of that already-wrapped encryption function. Remember the two hallmarks of an RSA ciphertext: hex, and a fixed length far greater than the plaintext; and remember the most easy-to-trip detail: "encodeURIComponent first, then encrypt".


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)