JS Reverse Engineering Case Study #7 — RSA Request Encryption + AES Response Decryption (JSEncrypt / Environment Patching / execjs)
Part 7 of the "JS Reverse Engineering in Practice — 30 Case Studies" series.
Source code (this case): JS Reverse Engineering — 30 Case Studies · GitHub (Project7)Note on sanitization: This is a sanitized, educational write-up. Every real domain, endpoint, site-specific constant, public key and SDK object name has been replaced with a generic placeholder (
example.com,<RSA_PUBLIC_KEY>,<AES_KEY>,<AES_IV>,SITE_APIJS). No production endpoint or secret is disclosed. The purpose is to explain the technique, not to enable abuse of any specific service.
1. Goal & Background
The target is a birdwatching-records platform (referred to as "Site G"), and I wanted its activity / record list.
-
Target site:
https://www.example.com/ -
Target endpoint:
https://api.example.com/<activity-search-endpoint> -
Parameters to reconstruct:
requestId,sign,timestamp,data(RSA request-parameter encryption) plus response decryption - Difficulty: ⭐⭐⭐⭐ — there is long-text RSA encryption + an MD5 signature on the request side, and another layer of AES decryption on the response side; both directions must be reconstructed.
This project is a textbook two-way encryption case: the outgoing body is RSA ciphertext with three custom headers, and what comes back is an encrypted string that must be decrypted again. Once you get through this whole set, you have essentially mastered the "request encryption + response decryption" combo.
2. Traffic Analysis
Log in to https://www.example.com/, open Network, filter by Fetch/XHR, and switch activity lists / page through on the site to capture the activity-search request.
Look first at the Request Headers; I noticed three very conspicuous custom headers:
requestId: <32-char hex string>
sign: <32-char hex string>
timestamp: <ms timestamp>
Now look at the Payload: the body is application/x-www-form-urlencoded formatted data, but the content is not plaintext JSON — it is one big Base64 string. That tells us the request body is encrypted.
Then look at the Response: what comes back is a structure like {"data":"<ciphertext>","timestamp":...}, where the data field itself is another block of ciphertext. So:
-
requestId: a random UUID, changing on every request; -
timestamp: the current millisecond timestamp; -
data: the request JSON encrypted with RSA; -
sign: MD5 over (plaintext JSON + requestId + timestamp); - the
datain the response: AES ciphertext that needs decryption.
📷 [Figure 1 — screenshot suggestion] The Headers and encrypted Payload of the activity-search request.
3. Locating the Signature Entry Point
The entry point of this API is not in ordinary business code; it hides inside the site's own encryption/decryption SDK (a compressed and obfuscated SITE_APIJS). My locating steps were:
-
Globally search the parameter names: in Sources, search
requestId,sign,encryptLong,setPublicKey. Very quickly I hit a very familiar pattern —JSEncrypt. -
Search
JSEncrypt: sure enough, the site uses theJSEncryptlibrary (version3.1.2) and has rewrittenencryptLong(native JSEncrypt does not support long text, so the site added segmented encryption itself). -
Look at response decryption: search
decrypt/AES, which locates aSITE_APIJSobject that first sets akey/ivand then calls its owndecode()!
The request-side entry function get_arg, carved out, looks like this (from the real file js/<site>3_req.js):
// From the real repo JS: Project7/<site>/js/<site>3_req.js
var encrypt = new JSEncrypt;
var paramPublicKey = "<RSA_PUBLIC_KEY>";
encrypt.setPublicKey(paramPublicKey);
function get_arg(page){
var res = {}
var timestamp = Date.parse(new Date);
var requestId = getUuid();
var data = '{"limit":"20","page":"' + page.toString() + '"}'
options.data = encrypt.encryptLong(data)
var sign = MD5(data + requestId + timestamp);
res["timestamp"] = timestamp.toString()
res["requestId"] = requestId
res["data"] = options.data
res["sign"] = sign
return res
}
The logic is very clear:
-
datais the plaintext JSON{"limit":"20","page":"N"}, run through RSA long-encryption byencrypt.encryptLong(); -
sign = MD5(data + requestId + timestamp)— note that it concatenates the pre-encryption plaintextdata; -
requestIdis generated bygetUuid().
The response-side decryption entry is in js/<site>3_res.js:
// From the real repo JS: Project7/<site>/js/<site>3_res.js
var CryptoJS = require('crypto-js');
// ...(the site's bundled obfuscated encryption/decryption object SITE_APIJS)...
SITE_APIJS.iv = "<AES_IV>"
SITE_APIJS.key = "<AES_KEY>"
function get_res(Data){
resp = { data:Data }
var decryptData = SITE_APIJS.decode(resp.data);
return decryptData
}
In other words, the site stuffs all its encryption/decryption into one SITE_APIJS object; we only need to feed it the key / iv and call decode() to decrypt the response.
📷 [Figure 2 — screenshot suggestion] Searching
JSEncrypt/encryptLongto locate the encryption function, or theSITE_APIJS.decodecall stack.
4. Restoring the JS & Emulating the Environment
The restoration of this project has three parts:
1. RSA long-encryption (request)
Native JSEncrypt can only encrypt very short strings (limited by the modulus length), while the data here may be longer, so the site added an encryptLong to JSEncrypt.prototype. The repo keeps this rewrite:
// From the real repo JS: Project7/<site>/js/<site>3_req.js
JSEncrypt.prototype.encryptLong = function(str) {
var encrypted = this.getKey().encryptLong(str) || "";
var uncrypted = this.getKey().decryptLong(encrypted) || "";
var count = 0;
var reg = /null$/g;
while (reg.test(uncrypted)) {
count++;
encrypted = this.getKey().encryptLong(str) || "";
uncrypted = this.getKey().decryptLong(encrypted) || "";
if (count > 10) { break }
}
return encrypted
};
What it does: the underlying RSAKey.encryptLong splits the long text into segments by length, RSA-encrypts each segment, and concatenates them into one Base64 string. The while loop above handles null bytes that appear at the end of segmented encryption — a site-side patch to JSEncrypt. This block must be kept verbatim; rewriting it yourself easily diverges from the site.
2. MD5 signature (request)
sign = MD5(data + requestId + timestamp). This MD5 is a whole module bundled in by the site's webpack build (var MD5 = function(r){...}), which I carved out in one piece.
3. AES decryption (response) + environment patching
Response decryption depends on SITE_APIJS, a compressed and obfuscated object. To make it run in Node / execjs, I patched a minimal environment at the top of the file: point window at global. At the same time, the site SDK contains anti-debugging / console detection (it walks console's methods and checks toString), so I also had to disable it — the real file does exactly that:
// From the real repo JS: Project7/<site>/js/<site>3_res.js (top environment patching)
window = global
const __console = {};
['log','info','warn','error','debug','table','trace'].forEach(m => {
__console[m] = console[m].bind(console);
});
// ...the middle is the obfuscated SITE_APIJS definition...
function get_res(Data){
resp = { data:Data }
var decryptData = SITE_APIJS.decode(resp.data);
Object.keys(__console).forEach(m => {
console[m] = __console[m];
});
return decryptData
}
Here is a little trick: save console's methods first, let the SDK run, then restore them. Because the SDK may rewrite / mute console while running, and if you do not restore it, all the debug output you printed earlier vanishes.
On tooling choices:
- The request side (including RSA) can use
execjs, because<site>3_req.jsis self-contained and has no complex environment dependencies; - The response side (
<site>3_res.js) also usesexecjs, but it doesrequire('crypto-js'), so the machine needsnpm i crypto-js.
5. Python Implementation
py/<site>.py in the repo strings together "get parameters → send request → cut out ciphertext → decrypt" in one line:
# From the real repo py: Project7/<site>/py/<site>.py
import requests
import os
import execjs
def Spider(page:int):
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
JsPath = os.path.join(BASE_DIR,'..','js','<site>3_req.js')
JsResPath = os.path.join(BASE_DIR,'..','js','<site>3_res.js')
with open(JsPath, encoding="utf-8") as f:
Js_code = f.read()
with open(JsResPath, encoding="utf-8") as f:
JsRes_code = f.read()
Js = execjs.compile(Js_code)
arg = Js.call("get_arg",page)
JsRes = execjs.compile(JsRes_code)
headers:dict[str,str] = {
"Accept": "application/json, text/javascript, */*; q=0.01",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"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",
"requestId": arg["requestId"],
"sign": arg["sign"],
"timestamp": arg["timestamp"]
}
url:str = "https://api.example.com/<activity-search-endpoint>"
data:str = arg["data"]
response:str = requests.post(url, headers=headers, data=data).text
Data:str = response.split('"data":"')[1].split('","timestamp')[0]
result:str = JsRes.call("get_res",Data)
print(result)
Spider(7)
Key points:
-
arg = Js.call("get_arg", page)returns the four-piece setrequestId / sign / timestamp / data; - Put
requestId / sign / timestampinto the request headers and sendarg["data"]as the body; - After the response returns, use
response.split('"data":"')[1].split('","timestamp')[0]to cut out the encrypted string first, and then hand it toget_resfor decryption.
6. Result
Running python <site>.py prints the decrypted activity-list JSON (record title, time, location, number of bird species, etc.). Roughly:
{'code': 0, 'data': {'list': [{'title': '<Activity Title>', 'startTime': '2024-xx-xx', ...}], 'total': 100}}
If data expands normally into a list, the whole chain of RSA encryption + MD5 signature + AES decryption is correct.
📷 [Figure 3 — screenshot suggestion] The terminal output: the birdwatching activity list.
7. Pitfalls & Notes
signconcatenates the plaintextdata, not the encrypted ciphertext. My first version mistakenly took the RSA ciphertext into MD5, and the server rejected the signature outright. Re-checking the code confirmed it isMD5(data + requestId + timestamp).encryptLongis a site modification and cannot be swapped for native JSEncrypt. The native library throws or truncates on long text; you must keep the site's segmented +null-handling patch.window = globalmust be patched. The response-decryption script useswindow, which does not exist in Node; without patching, it immediately reportswindow is not defined.The
datain the response must be cut out by hand. The response is{"data":"...","timestamp":...}; a directjson.loadsfails (becausedatais a string), so you must cut it out first and then decrypt.The console is rewritten by the SDK. The site SDK fiddles with
console, so save/restoreconsole's methods before and after the run; otherwiseexecjsdebug output mysteriously disappears (a form of anti-debugging).crypto-jsdependency. The response script doesrequire('crypto-js'), so remember tonpm i crypto-jsin the JS directory, orexecjsreports a missing module.requestIdmust be recomputed on every request. It is a random UUID; reusing an old value will be flagged by risk control.
8. Takeaways
-
"RSA request encryption + MD5 signature + AES response decryption" is the standard structure of a typical two-way-encryption site, and the locating method is to search
JSEncrypt/encryptLong/decrypt. -
The site's patch to JSEncrypt's
encryptLongmust be kept verbatim — the only reliable way to stay consistent with the site. -
Environment patching often only needs a single
window, but watch out for the SDK's anti-debugging (rewritingconsole, etc.), and remember to save / restore around the run.
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)