JS Reverse Engineering Case Study #5 — Decrypting a Hex-Encoded AES Response (AES + Environment Patching + execjs)
Part 5 of the "JS Reverse Engineering in Practice — 30 Case Studies" series.
Source code (this case): JS Reverse Engineering — 30 Case Studies · GitHub (Project5)Note on sanitization: This is a sanitized, educational write-up. Every real domain, endpoint and site-specific constant (including the AES key and IV) has been replaced with a generic placeholder (
example.com,<AES_KEY>,<AES_IV>). 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 national construction-market regulatory public-service platform (referred to as "Site E"). I wanted its company-list API. The request is plaintext, but the response body is a single block of hex ciphertext that must be decrypted before you can get the JSON.
-
Target site:
https://www.example.com/data/company -
Target endpoint:
https://www.example.com/APi/webApi/dataservice/query/comp/list -
Parameters to reconstruct: the response handling (decryption). The request parameters
pg/pgsz/totalare all plaintext. -
Difficulty: ⭐⭐⭐ — the algorithm is standard AES-CBC/Pkcs7; the hard part is that "the ciphertext is hex and must be converted first", plus the encoding subtleties of
require('crypto-js').
This project is about response decryption, and it forms a pair with the next case (an industry-data insights site): that one is "Base64 ciphertext straight into AES", while this one is "hex ciphertext, converted hex→Base64 first, then AES". It looks like just one extra step, but that step is the first hurdle for a lot of people.
2. Traffic Analysis
Open https://www.example.com/data/company, press F12 → Network → Fetch/XHR, page through the company list, and find the request to the company-list endpoint.
Look at the Payload — it is a GET with plaintext query parameters:
pg=2
pgsz=15
total=450
pg is the page number and pgsz is the page size. The request side can be ignored entirely.
Now look at the Response: what comes back is not JSON but a big block of hex string (all 0-9a-f, with none of the Base64 signature characters such as + or /):
7a3b9c... (a long hex string) ...
That tells us the response body is encrypted. Although it looks like Base64, on closer inspection it only has 0-9a-f, so it is actually hex encoding — and that is exactly where the pitfall lies.
📷 [Figure 1 — screenshot suggestion] In the Network panel: the raw ciphertext in the Response of the company-list request.
Conclusion: copy the request as-is; the focus is to carve out the decryption function that turns "hex ciphertext → plaintext JSON", and to fix the require and encoding issues.
3. Locating the Signature Entry Point
Locating response decryption follows the same playbook as the next case: find who handles the response.
In the Sources panel, search for part of the endpoint path (comp/list) or a business field name to reach the ajax .then() callback; the callback usually contains a block that "hands res to some function to process". Follow that function in and you land on the actual decryption implementation.
What I carved out is js/<site>1.js in the repo. The full logic is:
// From the real repo JS: Project5/<site>/js/<site>1.js
var cryptoJS = require('crypto-js');
function get_res(Data){
var d = {
a:cryptoJS
};
function BB(t) {
f = d.a.enc.Utf8.parse("<AES_KEY>");
m = d.a.enc.Utf8.parse("<AES_IV>");
var e = d.a.enc.Hex.parse(t)
, n = d.a.enc.Base64.stringify(e)
var a = d.a.AES.decrypt(n, f, {
iv: m,
mode: d.a.mode.CBC,
padding: d.a.pad.Pkcs7
})
, r = a.toString(d.a.enc.Utf8);
return r.toString()
}
TT = {
data:Data
}
var EE = JSON.parse(BB(TT.data));
list = []
for (var i = 0;i<EE.data.list.length;i++){
list[i] = EE.data.list[i]
}
return list
}
Take it apart step by step:
-
f = Utf8.parse("<AES_KEY>")— the key. It is 16 visible characters, soUtf8.parseyields 16 bytes → AES-128; -
m = Utf8.parse("<AES_IV>")— the IV is also 16 bytes and is a string that looks very plain (0-9A-F); -
e = Hex.parse(t)— the crucial first step: parse the hex ciphertext returned by the server into a WordArray; -
n = Base64.stringify(e)— the crucial second step: re-encode the same WordArray into a Base64 string; -
a = AES.decrypt(n, f, {iv: m, mode: CBC, padding: Pkcs7})— AES-CBC decryption using the Base64 string; -
r = a.toString(Utf8)— convert the plaintext into a UTF-8 string; -
JSON.parsethenEE.data.list— the decrypted JSON has a top-leveldatawrapper, and the real company array is atdata.list.
Steps 3 and 4 are the essence of this project: the server sends the AES ciphertext hex-encoded, while CryptoJS.AES.decrypt interprets its input as Base64 by default, so you must Hex.parse and then Base64.stringify — equivalent to "converting hex to base64" — otherwise decrypt treats the hex string as base64 and decrypts to an empty string or garbage.
📷 [Figure 2 — screenshot suggestion] In the Sources panel: the decryption function, or the
AES.decryptbreakpoint call stack.
4. Restoring the JS & Emulating the Environment
The environment-patching cost for this file is essentially zero:
- It only depends on
crypto-js, so a local Node environment just needsrequire('crypto-js'); - It does not use
window/document/navigator; - The function signature
get_res(Data)is kept as-is, andDatais the raw string returned by the server.
One thing to be aware of: the author used some "implicit globals" here (f / m / e / n / a / r / TT / EE / list all omitted var/let) — fine for a one-shot call like get_res, but if you want to call it repeatedly in the same process, or simply to be rigorous, add var to them to avoid polluting the global scope.
Also: cryptoJS.enc.Utf8.parse uses CryptoJS's UTF-8 encoding, so the key/IV must be passed in as the literal strings, without converting them to hex or manually padding.
5. Python Implementation
py/<site>1.py has two steps: Spider() fetches the raw response, and js_fun() hands the raw text to JS for decryption:
# From the real repo py: Project5/<site>/py/<site>1.py
import requests
import os
import execjs
from typing import Union,Any
def js_fun(data:str)->list[dict[str,Union[str,int]]]:
base_path:str = os.path.dirname(os.path.abspath(__file__))
js_file:str = os.path.join(base_path,"..","js","<site>1.js")
js_dir:str = os.path.dirname(js_file)
os.chdir(js_dir)
with open(js_file,encoding="utf-8") as f:
js_code = f.read()
js:Any = execjs.compile(js_code)
res:list[dict[str,Union[str,int]]] = js.call("get_res",data)
return res
def Spider(page:str)->str:
headers:dict[str,str] = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Pragma": "no-cache",
"Referer": "https://www.example.com/data/company",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"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",
"accessToken;": "",
"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",
"timeout": "30000",
"v": "<API_VERSION>"
}
url:str = "https://www.example.com/APi/webApi/dataservice/query/comp/list"
params:dict[str,str] = {
"pg": page,
"pgsz": "15",
"total": "450"
}
response:str = requests.get(url, headers=headers, params=params).text
return response
def main(page:int):
Data:str = Spider(str(page))
res:list[dict[str,Union[str,int]]] = js_fun(Data)
print(res)
if __name__ == "__main__":
main(2)
Key points:
-
os.chdir(js_dir): keep this habit, so thatrequire('crypto-js')and relative paths resolve in the expected directory; -
A few "disguise" fields in the headers must be included:
v: "<API_VERSION>"(version),timeout: "30000",accessToken;: ""(note the header name carries a semicolon — copy it verbatim). This site is picky about headers; omitting them may return a risk-control page; -
Pass
.textdirectly: the argument tojs.call("get_res", data)is the raw response (the hex string) — do not do any decoding on the Python side.
6. Result
Running python <site>1.py prints the decrypted and parsed company list (a Python list/dict):
[{'QY_NAME': '<Company Name>', 'QY_ORG_CODE': '91...', 'QY_REG_ADDR': '...', 'QY_ID': '...'},
{'QY_NAME': '<Company Name>', 'QY_ORG_CODE': '91...', 'QY_REG_ADDR': '...', 'QY_ID': '...'}]
As long as fields such as QY_NAME display correctly and are no longer ciphertext, the entire "Hex → Base64 → AES-CBC → JSON" chain is working.
📷 [Figure 3 — screenshot suggestion] The terminal output: the company-list data.
7. Pitfalls & Notes
The ciphertext is hex, not Base64. This is the biggest pitfall:
AES.decryptparses its input as Base64 by default, and dropping the hex string in directly fails. The right move isHex.parse(t)followed byBase64.stringify— i.e. "convert to base64 first".Key/IV use
Utf8.parse. The key is 16 characters (AES-128) and the IV is 16 bytes; both must go throughUtf8.parseas-is — do not convert them to hex by hand.CBC requires the IV. Unlike ECB, here
{iv: m, mode: CBC, padding: Pkcs7}— none of the three may be omitted.The returned level is
data.list. The decrypted JSON has a top-leveldatawrapper; readdata.listdirectly. If you print a{...}instead of a list, you probably took the wrong level.require('crypto-js')must be loadable. You neednpm i crypto-jslocally, and theexecjsNode must be able to find it (place it in the js directory, or install it globally). If it reportsCannot find module 'crypto-js', install a copy underjs/.The site is sensitive to request headers. Fields like
v/timeout/accessToken;should be copied, not deleted; otherwise you may get an encrypted error page and misdiagnose it as an algorithm error.Use UTF-8 consistently. Read the JS, pass the response, and print the result all in UTF-8 to avoid mojibake in the Chinese company names.
Keep the request rate low and respect the terms of service.
8. Takeaways
-
The core of response decryption is to find "the function that handles the response" — search for the endpoint path to reach the callback, or search for
AES/decrypt/CryptoJSto locate it. -
The ciphertext's encoding determines success or failure: Base64 ciphertext decrypts directly, while hex ciphertext must first go
Hex.parse → Base64.stringifyand then decrypt — that step is the most common pitfall with "hex responses". -
Copy the three AES ingredients (Key/IV/mode+padding) exactly, and land it with
crypto-js+execjswith zero environment patching — the fastest route for response decryption.
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)