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
noncehanded 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>
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>"
}
}
The pattern is very clear:
-
mobileNo/passwordare both Base64 ciphertext (RSA-encrypted, then Base64-encoded); -
nonceis a Base64 string; -
timestampis a millisecond timestamp; -
signis an MD5 string on the query.
📷 [Figure 1 — screenshot suggestion] The login request:
signin the query and the encryptedparamin 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:
- XHR breakpoint: in Sources → XHR/Fetch Breakpoints, add the login endpoint substring, trigger login, and inspect the call stack when it pauses;
-
Walk up the stack: once you find the request-wrapping function, look at how it assembles
paramandsign; - 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
}
Let me break it down:
-
iis the site's hard-coded RSA public key; -
a(...)is an inner function: it grabs the webpackJSEncryptmodule viaBBB(491), doessetPublicKey(i)thenencrypt(), 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); -
md5is thecryto_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-issuednonce, 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()
}
📷 [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
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; -
JSEncryptcomes from the webpack moduleBBB(491), so I kept the entire webpack runtime and did not delete that!function(e){...}block; -
md5usescrypto-js, so you neednpm i crypto-js.
A few must-remember points about the RSA part:
- It uses
setPublicKey+encrypt, notencryptLong(the mobile number and password here are short); - Each
encryptcall returns a different result (PKCS#1 v1.5 random padding), so the ciphertext insidesignand 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)
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 Pythonget_argfunction object (because of the same-namefrom nonce import get_arg), which is an easy trap; in practice, writeJs.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"}
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
nonceand the cookies are a matched pair.GATEWAY_SIGNcomes 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 freshnonce, or the signature will be wrong for sure.The ciphertext inside
signmust be the same as the ciphertext you send. RSA uses random padding on every encryption; if, for debugging, you calla("your-password")twice, the two ciphertexts differ and the resultingsignwill not match.signconcatenates the body's compact JSON. InArg, the field order iskrtoken → 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 usesjson.dumps(..., separators=(',', ':'))to guarantee the compact format, but field order is determined by the dict, so make sure it matches the JS order.JSEncryptcomes from a webpack module. Do not delete theBBB(491)webpack runtime, or the module will not be found.self = windowmust be shimmed too. Some bundled code accessesself, which does not exist in Node; adding it is more robust.crypto-jsdependency. The top-levelrequire('crypto-js')requiresnpm i crypto-js.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
- 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)".
- 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.
- 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)