DEV Community

FeiHua
FeiHua

Posted on

JS Reverse Engineering Case Study #7 — RSA Request Encryption + AES Response Decryption (JSEncrypt / Environment Patching / execjs)

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>
Enter fullscreen mode Exit fullscreen mode

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 data in 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:

  1. Globally search the parameter names: in Sources, search requestId, sign, encryptLong, setPublicKey. Very quickly I hit a very familiar pattern — JSEncrypt.
  2. Search JSEncrypt: sure enough, the site uses the JSEncrypt library (version 3.1.2) and has rewritten encryptLong (native JSEncrypt does not support long text, so the site added segmented encryption itself).
  3. Look at response decryption: search decrypt / AES, which locates a SITE_APIJS object that first sets a key / iv and then calls its own decode()!

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
}
Enter fullscreen mode Exit fullscreen mode

The logic is very clear:

  • data is the plaintext JSON {"limit":"20","page":"N"}, run through RSA long-encryption by encrypt.encryptLong();
  • sign = MD5(data + requestId + timestamp) — note that it concatenates the pre-encryption plaintext data;
  • requestId is generated by getUuid().

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
}
Enter fullscreen mode Exit fullscreen mode

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 / encryptLong to locate the encryption function, or the SITE_APIJS.decode call 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
};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.js is self-contained and has no complex environment dependencies;
  • The response side (<site>3_res.js) also uses execjs, but it does require('crypto-js'), so the machine needs npm 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)
Enter fullscreen mode Exit fullscreen mode

Key points:

  • arg = Js.call("get_arg", page) returns the four-piece set requestId / sign / timestamp / data;
  • Put requestId / sign / timestamp into the request headers and send arg["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 to get_res for 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}}
Enter fullscreen mode Exit fullscreen mode

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

  1. sign concatenates the plaintext data, 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 is MD5(data + requestId + timestamp).

  2. encryptLong is 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.

  3. window = global must be patched. The response-decryption script uses window, which does not exist in Node; without patching, it immediately reports window is not defined.

  4. The data in the response must be cut out by hand. The response is {"data":"...","timestamp":...}; a direct json.loads fails (because data is a string), so you must cut it out first and then decrypt.

  5. The console is rewritten by the SDK. The site SDK fiddles with console, so save/restore console's methods before and after the run; otherwise execjs debug output mysteriously disappears (a form of anti-debugging).

  6. crypto-js dependency. The response script does require('crypto-js'), so remember to npm i crypto-js in the JS directory, or execjs reports a missing module.

  7. requestId must be recomputed on every request. It is a random UUID; reusing an old value will be flagged by risk control.

8. Takeaways

  1. "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.
  2. The site's patch to JSEncrypt's encryptLong must be kept verbatim — the only reliable way to stay consistent with the site.
  3. Environment patching often only needs a single window, but watch out for the SDK's anti-debugging (rewriting console, 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)