DEV Community

FeiHua
FeiHua

Posted on

JS Reverse Engineering Case Study #9 — Reconstructing a Gateway Login sign Signature + RSA Password Encryption

JS Reverse Engineering Case Study #9 — Reconstructing a Gateway Login sign Signature + RSA Password Encryption

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

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, the module ids) have been replaced with generic placeholders (example.com, <WAF_COOKIE_1>, <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 mobile-number/password login of a technology news and startup-information platform (referred to as "Site I"). I wanted to reconstruct its login protocol.

  • Target site: https://www.example.com (a technology news platform)
  • Target endpoint: https://api.example.com/<login-endpoint>
  • Fields to reconstruct: sign, nonce, timestamp, mobileNo, password
  • Difficulty: ⭐⭐⭐⭐ — the mobile number and password are each RSA-encrypted, then wrapped in an MD5 signature that itself depends on a dynamic nonce handed down by the homepage.

The interesting bit is that the signature mixes in a random string nonce from the homepage, i.e. window.__GATEWAY_SIGN__. Digging out the JS alone is not enough: you must request the homepage once to fetch this value before you can compute the correct sign. This pattern is extremely common in gateway-style APIs.


2. Traffic Analysis

Open https://www.example.com, type the mobile number and password into the login box, click login, then F12 → Network and capture the login request.

First look at the Request URL query:

POST https://api.example.com/<login-endpoint>?sign=<32-hex string>
Enter fullscreen mode Exit fullscreen mode

sign lives on the query string.

Now look at the Payload (request body), which is a JSON:

{
  "nonce": "<base64 string>",
  "krtoken": "",
  "partner_id": "web",
  "timestamp": 1785212736314,
  "param": {
    "countryCode": "86",
    "mobileNo": "<base64 ciphertext>",
    "password": "<base64 ciphertext>"
  }
}
Enter fullscreen mode Exit fullscreen mode

The pattern is very clear:

  • mobileNo / password are both Base64 ciphertext (RSA-encrypted, then Base64-encoded);
  • nonce is a Base64 string;
  • timestamp is a millisecond timestamp;
  • sign is an MD5 string on the query.

📷 [Figure 1 — screenshot suggestion] The login request: sign in the query and the encrypted param in the body.

There is another key clue: before login the page contains window.__GATEWAY_SIGN__="...", which is exactly the nonce used here.


3. Locating the Encryption Entry Point

Login is sent by front-end JS, so my locating strategy was:

  1. XHR breakpoint: in Sources → XHR/Fetch Breakpoints, add the login endpoint substring, trigger login, and inspect the call stack when it pauses;
  2. Walk up the stack: once you find the request-wrapping function, look at how it assembles param and sign;
  3. Also globally search for setPublicKey, JSEncrypt, encrypt, MD5, sign.

The core function I finally extracted is get_arg (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(GATEWAY_SIGN) {
    var i = "<RSA_PUBLIC_KEY_BASE64>"   // the site's hard-coded RSA public key
        , a = function () {
            var e = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : "";
            if (true) {
                var t = BBB(491)
                    , r = new t.JSEncrypt;
                r.setPublicKey(i);
                var a = r.encrypt(e);
                return a
            }
        }
    countryCode = "86"
    mobileNo = a("your-mobile")
    password = a("your-password")
    var timestamp = Date.now();
    var OO = GATEWAY_SIGN
    Arg = '{"krtoken":"","partner_id":"web","timestamp":' + timestamp.toString() + ',"param":{"countryCode":"86","mobileNo":"' + mobileNo + '","password":"' + password + '"}}' + OO
    var sign = md5(Arg)
    li = {}
    li["countryCode"] = countryCode
    li["mobileNo"] = mobileNo
    li['password'] = password
    li["timestamp"] = timestamp
    li["nonce"] = OO
    li["sign"] = sign
    return li
}
Enter fullscreen mode Exit fullscreen mode

Let me break it down:

  • i is the site's hard-coded RSA public key;
  • a(...) is an inner function: it grabs the webpack JSEncrypt module via BBB(491), does setPublicKey(i) then encrypt(), encrypting the mobile number / password separately (note that RSA encryption uses random padding every time, so the same mobile number produces a different ciphertext each run);
  • md5 is the cryto_js.MD5(text).toString() defined at the top of the file;
  • sign = md5(JSON string + GATEWAY_SIGN) — this is the essence of the signature: take the login body's JSON as a verbatim string and concatenate the homepage-issued nonce, then MD5 the whole thing.

The top of the file is simple; it just requires crypto-js:

// From the real JS (top of the file)
window = global
self = window

var cryto_js = require('crypto-js');
function md5(text) {          // md5 hash
    return cryto_js.MD5(text).toString()
}
Enter fullscreen mode Exit fullscreen mode

📷 [Figure 2 — screenshot suggestion] The call stack after the XHR breakpoint hits, traced back to get_arg.


4. Restoring the JS & Emulating the Environment

The restoration focus of this case is the cooperation between two environment pieces:

1. Fetch nonce (GATEWAY_SIGN) first

nonce is not computed in JS — it is scraped from the homepage HTML. The repo's py/arg.py (renamed here to py/nonce.py) is dedicated to exactly this:

# From the real py (renamed to py/nonce.py)
import requests

def get_arg() -> str:
    headers = {
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
        "Accept-Language": "en-US,en;q=0.9",
        "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",
        "sec-ch-ua": "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\""
    }
    cookies = {
        "<WAF_COOKIE_1>": "<your-waf-cookie-1-value>",
        "<WAF_COOKIE_2>": "",
        "<SERVER_ID_COOKIE>": "<your-server-id>",
        "<WAF_CHALLENGE_COOKIE>": "<your-waf-challenge-id>",
        "<WAF_TOKEN_COOKIE>": "<your-waf-token-id>"
    }
    url = "https://www.example.com/"
    response = requests.get(url, headers=headers, cookies=cookies).text
    res = response.split('<script async>window.__GATEWAY_SIGN__="')[1].split('"')[0]
    return res
Enter fullscreen mode Exit fullscreen mode

The approach is simply: GET the homepage, then slice the Base64 string out of <script async>window.__GATEWAY_SIGN__="...". Note that nonce and the cookies are a matched pair — if WAF cookies like <WAF_COOKIE_1> become invalid, the homepage may not hand out this value.

2. Compute mobileNo / password / sign

Feed the nonce from the previous step into get_arg in sign.js as the GATEWAY_SIGN argument:

  • The only environment shim needed is window = global; self = window;
  • JSEncrypt comes from the webpack module BBB(491), so I kept the entire webpack runtime and did not delete that !function(e){...} block;
  • md5 uses crypto-js, so you need npm i crypto-js.

A few must-remember points about the RSA part:

  • It uses setPublicKey + encrypt, not encryptLong (the mobile number and password here are short);
  • Each encrypt call returns a different result (PKCS#1 v1.5 random padding), so the ciphertext inside sign and the ciphertext actually sent must come from the same call — you cannot compute it twice.

5. Python Implementation

The main script py/spider.py chains "fetch nonce → generate signature parameters → send the login request":

# From the real py (renamed to py/spider.py)
import requests
import json
from nonce import get_arg
import os
import execjs


GATEWAY_SIGN = get_arg()
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
JsPath = os.path.join(BASE_DIR, '..', 'js', 'sign.js')
with open(JsPath, encoding="utf-8") as f:
    Js_code = f.read()
Js = execjs.compile(Js_code)
arg = Js.call("get_arg", GATEWAY_SIGN)

headers = {
    "Content-Type": "application/json",
    "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",
    # ...remaining sec-ch-ua / dnt headers omitted...
}
cookies = {
    "<ANALYTICS_COOKIE>": "<your analytics cookie>",
    "<WAF_COOKIE_1>": "<your-waf-cookie-1-value>",
    "<WAF_COOKIE_2>": ""
}
url = "https://api.example.com/<login-endpoint>"
params = {
    "sign": arg["sign"]
}
data = {
    "nonce": arg["nonce"],
    "krtoken": "",
    "partner_id": "web",
    "timestamp": arg["timestamp"],
    "param": {
        "countryCode": arg["countryCode"],
        "mobileNo": arg["mobileNo"],
        "password": arg["password"]
    }
}
data = json.dumps(data, separators=(',', ':'))

response = requests.post(url, headers=headers, cookies=cookies, params=params, data=data)

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

The structure is very clean: get_arg() (network) fetches the nonce → Js.call(...) computes the signature parameters → sign goes into the query, the rest into the body.

⚠️ A small reminder: strictly speaking, the first argument of Js.call(...) here should be the function-name string ("get_arg"). The repo happened to pass the Python get_arg function object (because of the same-name from nonce import get_arg), which is an easy trap; in practice, write Js.call("get_arg", GATEWAY_SIGN) for clearer semantics.


6. Result

Running python spider.py, the terminal prints the login endpoint response. If the account/password are correct and sign matches nonce, you get something like:

{"code":0,"data":{"token":"...","userId":...},"msg":"success"}
Enter fullscreen mode Exit fullscreen mode

If sign is wrong, you get a signature-verification-failure message — which is exactly how you can validate whether your signature algorithm has been restored correctly.

📷 [Figure 3 — screenshot suggestion] The terminal printing the login endpoint response.


7. Pitfalls & Notes

  1. nonce and the cookies are a matched pair. GATEWAY_SIGN comes from the homepage, and the homepage in turn depends on WAF cookies (<WAF_COOKIE_1> etc.). After switching to a different cookie set, re-request the homepage for a fresh nonce, or the signature will be wrong for sure.

  2. The ciphertext inside sign must be the same as the ciphertext you send. RSA uses random padding on every encryption; if, for debugging, you call a("your-password") twice, the two ciphertexts differ and the resulting sign will not match.

  3. sign concatenates the body's compact JSON. In Arg, the field order is krtoken → partner_id → timestamp → param; both the order and the whitespace affect the MD5, so it must exactly match the JSON you finally send. The Python side uses json.dumps(..., separators=(',', ':')) to guarantee the compact format, but field order is determined by the dict, so make sure it matches the JS order.

  4. JSEncrypt comes from a webpack module. Do not delete the BBB(491) webpack runtime, or the module will not be found.

  5. self = window must be shimmed too. Some bundled code accesses self, which does not exist in Node; adding it is more robust.

  6. crypto-js dependency. The top-level require('crypto-js') requires npm i crypto-js.

  7. Anonymize credentials. In the repo, mobileNo = a("your-mobile") / password = a("your-password") are placeholders — replace them with your own usable test account before running.


8. Takeaways

  1. Gateway-style API signatures often mix in "a random nonce handed down by the homepage", so restoration = two cooperating pieces: "fetch nonce (Python) + compute signature (JS)".
  2. RSA encryption results differ every time, so the signature and the ciphertext must come from the same call, otherwise the front/back-end check will inevitably fail.
  3. The concatenation order and the compact JSON format of the signature are hard constraints; when reproducing the body in Python, the field order must align exactly with the JS.

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)