DEV Community

FeiHua
FeiHua

Posted on

JS Reverse Engineering Case Study #5 — Decrypting a Hex-Encoded AES Response (AES + Environment Patching + execjs)

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

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

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

Take it apart step by step:

  1. f = Utf8.parse("<AES_KEY>") — the key. It is 16 visible characters, so Utf8.parse yields 16 bytes → AES-128;
  2. m = Utf8.parse("<AES_IV>") — the IV is also 16 bytes and is a string that looks very plain (0-9A-F);
  3. e = Hex.parse(t) — the crucial first step: parse the hex ciphertext returned by the server into a WordArray;
  4. n = Base64.stringify(e) — the crucial second step: re-encode the same WordArray into a Base64 string;
  5. a = AES.decrypt(n, f, {iv: m, mode: CBC, padding: Pkcs7}) — AES-CBC decryption using the Base64 string;
  6. r = a.toString(Utf8) — convert the plaintext into a UTF-8 string;
  7. JSON.parse then EE.data.list — the decrypted JSON has a top-level data wrapper, and the real company array is at data.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.decrypt breakpoint 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 needs require('crypto-js');
  • It does not use window / document / navigator;
  • The function signature get_res(Data) is kept as-is, and Data is 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)
Enter fullscreen mode Exit fullscreen mode

Key points:

  • os.chdir(js_dir): keep this habit, so that require('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 .text directly: the argument to js.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': '...'}]
Enter fullscreen mode Exit fullscreen mode

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

  1. The ciphertext is hex, not Base64. This is the biggest pitfall: AES.decrypt parses its input as Base64 by default, and dropping the hex string in directly fails. The right move is Hex.parse(t) followed by Base64.stringify — i.e. "convert to base64 first".

  2. Key/IV use Utf8.parse. The key is 16 characters (AES-128) and the IV is 16 bytes; both must go through Utf8.parse as-is — do not convert them to hex by hand.

  3. CBC requires the IV. Unlike ECB, here {iv: m, mode: CBC, padding: Pkcs7} — none of the three may be omitted.

  4. The returned level is data.list. The decrypted JSON has a top-level data wrapper; read data.list directly. If you print a {...} instead of a list, you probably took the wrong level.

  5. require('crypto-js') must be loadable. You need npm i crypto-js locally, and the execjs Node must be able to find it (place it in the js directory, or install it globally). If it reports Cannot find module 'crypto-js', install a copy under js/.

  6. 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.

  7. 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.

  8. Keep the request rate low and respect the terms of service.

8. Takeaways

  1. 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 / CryptoJS to locate it.
  2. The ciphertext's encoding determines success or failure: Base64 ciphertext decrypts directly, while hex ciphertext must first go Hex.parse → Base64.stringify and then decrypt — that step is the most common pitfall with "hex responses".
  3. Copy the three AES ingredients (Key/IV/mode+padding) exactly, and land it with crypto-js + execjs with 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)