JS Reverse Engineering Case Study #6 — Decrypting an AES Response (JS Reverse / Environment Patching / execjs)
Part 6 of the "JS Reverse Engineering in Practice — 30 Case Studies" series.
Source code (this case): JS Reverse Engineering — 30 Case Studies · GitHub (Project6)Note on sanitization: This is a sanitized, educational write-up. Every real domain, endpoint and site-specific constant (including the AES key) has been replaced with a generic placeholder (
example.com,<AES_KEY>). 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 this time is an industry-data insights site (referred to as "Site F"), and my goal was to scrape its article-list API.
- Target site:
https://www.example.com/ - Target endpoint:
https://api.example.com/<article-list-endpoint> - Parameters to reconstruct: the response decryption. The request parameters are plaintext (
queryType/current/size), but the response body is not ordinary JSON — it is an encrypted string that must be decrypted before you can get the article list. - Difficulty: ⭐⭐ — the mildest kind of response decryption, a good warm-up for learning "response encryption".
The "reverse target" of this project is very pure: no signature, no cookie-based risk control — just one thing: turning the server's ciphertext back into plaintext JSON.
2. Traffic Analysis
Open https://www.example.com/, press F12 to switch to the Network panel, filter by Fetch/XHR, and then page / switch sections on the site. You will see the article-list endpoint being triggered.
Click it and look at the Payload; the request body is:
{"queryType":3,"current":2,"size":20} Completely plaintext, no obfuscation at all, and current is the page number. Now switch to the Response and you find that what comes back is not the {"code":0,"data":[...]} you expected, but an entirely encrypted string (something like a Base64 / hex mixture). At that point I concluded: what needs reconstructing is the response decryption, not a request signature.
Now look at the request headers: Content-Type: application/json;charset=UTF-8, Origin is https://www.example.com, while the API domain is a different subdomain (same site, cross-subdomain). This "front-end display domain + data-API subdomain" structure is very common.
📷 [Figure 1 — screenshot suggestion] In the Network panel: the Payload and Response of the article-list request.
The conclusion is clear: copy the request as-is; the focus is carving out the response-decryption function.
3. Locating the Signature Entry Point
Since this is response decryption, I do not need to guess request parameters; I go straight for "who decrypts the response".
The most effective method is a global keyword search in the Sources panel. Because the API returns a field, the front end must read it, so I search for some typical words:
- First search for part of the endpoint path (
article/client/page) to locate the ajax wrapper that fires the request; - Then follow its
.then()/successcallback inward to see what it does tores; - If the string is too compressed to read, search for the typical fingerprint of a decryption library:
AES,decrypt,CryptoJS,Pkcs7,ECB.
On this site, I eventually located a decryption block like this (the core is CryptoJS's AES):
// The decryption call located in the site front end (illustrative) var t = { data: res.encryptedString }; var n = CryptoJS.enc.Utf8.parse(key); var r = CryptoJS.AES.decrypt(t.data, n, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }); var i = r.toString(CryptoJS.enc.Utf8); var s = JSON.parse(i); The mode is AES / ECB / Pkcs7, and the key is a hard-coded 32-character string parsed directly with Utf8.parse (equivalent to AES-256). I carved this logic and the key out locally and rewrote it as js/<site>1.js in the repo:
// From the real repo JS: Project6/<site>/js/<site>1.js var cryptoJS = require('crypto-js'); function Get_result(Data){ u = cryptoJS var l = "<AES_KEY>" var t = { data: Data } var n = u.enc.Utf8.parse(l) var r = u.AES.decrypt(t.data, n, { mode: u.mode.ECB, padding: u.pad.Pkcs7 }) i = r.toString(u.enc.Utf8) s = JSON.parse(i); return s.data.data } Note the last line, return s.data.data: the decrypted JSON has yet another top-level data wrapper, and the real article list is inside data.data. You have to actually print one decryption result to confirm the level.
📷 [Figure 2 — screenshot suggestion] In the Sources panel: the
AES.decryptbreakpoint / its call stack.
4. Restoring the JS & Emulating the Environment
This project has almost no environment-patching cost — it is the least troublesome kind of "carving the code out":
- The site front end already uses CryptoJS, so I only need a Node environment that can run
require('crypto-js'); - I kept the original function signature
Get_result(Data), whose argument is the whole ciphertext returned by the server; - Because the key, IV and mode are all hard-coded inside the function, the caller does not need to pass any extra arguments; the Python side only feeds in the raw response.
A few things about the AES parameters that are easy to get wrong:
- ECB does not need an IV: so there is no
ivfield in the code; do not add one yourself, it will break decryption; - The key uses
Utf8.parse: the key is 32 visible characters, so after parsing it is 32 bytes, corresponding to AES-256; - The padding is Pkcs7: CryptoJS's default padding.
Before wiring this logic up with Python's execjs, I first ran it once in Node to verify the decryption result was readable:
// Local self-test (illustrative) var r = require('./<site>1.js'); // omitted // or verify incrementally with node -e Only after confirming it was correct did I hand it over to the Python side.
5. Python Implementation
The collector script py/<site>.py in the repo has a very direct structure: read the JS → execjs.compile → send the request → hand the response text to Get_result:
# From the real repo py: Project6/<site>/py/<site>.py import requests import json import os import execjs def Spider(page:int)->None: BASE_DIR = os.path.dirname(os.path.abspath(__file__)) JsPath = os.path.join(BASE_DIR,'..','js','<site>1.js') with open(JsPath, encoding="utf-8") as f: Js_code = f.read() Js = execjs.compile(Js_code) headers = { "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Content-Type": "application/json;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", "deviceType": "1", "dnt": "1", "sec-ch-ua": "\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\", \"Google Chrome\";v=\"138\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", "sec-gpc": "1" } url = "https://api.example.com/<article-list-endpoint>" data = { "queryType": 3, "current": page, "size": 20 } data = json.dumps(data, separators=(',', ':')) response = requests.post(url, headers=headers, data=data).text res = Js.call("Get_result", response) print(res) Spider(2) A few implementation details worth noting:
- Build the path with
os.path:JsPath = os.path.join(BASE_DIR,'..','js','<site>1.js')ensures the JS can be found no matter which directory the script is run from; - Specify
encoding="utf-8"for file I/O: the default on Windows is GBK; without it you get mojibake or an error; -
json.dumps(..., separators=(',', ':')): strips whitespace to match the compact JSON the browser actually sends, reducing the chance of being flagged; -
Js.call("Get_result", response): pass the.textfromrequestsdirectly; the return value is already a parsed Python object.
6. Result
Running python <site>.py prints the decrypted article-list data (a list/dict structure) with visible title, id, publish-time fields. It looks roughly like this:
[{'id': 123456, 'title': '<Article Title>', 'publishTime': '2024-xx-xx', ...}, {'id': 123457, 'title': '<Another Article>', 'publishTime': '2024-xx-xx', ...}] As long as the fields display correctly and are no longer garbage or ciphertext, the AES decryption chain is fully working.
📷 [Figure 3 — screenshot suggestion] The terminal output: the article-list data.
7. Pitfalls & Notes
The key length and parse method must match the site. At first I typed the 32-character key straight in as a byte array, and the length mismatch caused decryption to return an empty string. The right move is to run the front end's original
keystring throughCryptoJS.enc.Utf8.parseand not rewrite it.Do not add an IV for ECB. Many people habitually stuff an
ivinto every AES call; here, adding one actually breaks decryption.Confirm the return level
s.data.data. My first version wrotes.data, which still printed wrapped, and the actual data was atdata.data.The response may change. If decryption errors out one day, first check whether the response-body structure changed (e.g. another field was wrapped around it), then whether the key was swapped.
execjsencoding issues. The page returns UTF-8, so keep the encoding consistent when reading the JS file and passing arguments, or the Chinese titles will be garbled.Rate control. This is a public endpoint without explicit authorization, so be sure to control the request rate and respect the site's
robots.txtand terms of service.
8. Takeaways
- The core of response decryption is "finding the decryption call", not guessing parameters. Search the endpoint path to reach the callback, or search
AES/decrypt/CryptoJSfingerprints to locate it. - Copy the three AES ingredients: mode (ECB), padding (Pkcs7), and how the key is parsed (
Utf8.parse) — any one change fails. -
execjs+require('crypto-js')is the fastest way to land response decryption, with virtually zero environment-patching cost.
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.