全国建筑市场监管公共服务平台响应解密 JS 逆向实战(AES + 补环境 + execjs)
本文是「JS 逆向实战 30 例」系列第 5 篇,项目源码:596K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6K6j5$3!0G2L8o6p5I4x3W2)9J5c8X3!0H3k6h3&6v1f1#2u0W2N6X3g2J5M7$3f1`. (Project5)
一、目标与背景
全国建筑市场监管公共服务平台(jzsc.mohurd.gov.cn),我要抓它的企业列表接口。这个接口的请求是明文的,但响应体是一整段十六进制密文,必须解密才能拿到 JSON。
- 站点:
b64K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7P5Y4y4U0i4K6u0W2L8h3!0Z5N6i4u0V1i4K6u0W2k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6V1j5i4c8S2i4K6u0r3j5$3!0E0M7r3q4F1P5b7`.`.
- 目标接口:
516K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7P5Y4y4U0i4K6u0W2L8h3!0Z5N6i4u0V1i4K6u0W2k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6m8f1r3W2Q4x3V1k6%4k6h3u0m8M7r3W2Q4x3V1k6V1j5i4c8S2M7$3g2J5N6X3W2U0k6g2)9J5c8Y4q4#2k6i4u0&6i4K6u0r3j5$3!0E0M7q4)9J5c8X3I4A6M7%4b7`.
- 需还原字段:响应处理(解密)。请求参数
pg / pgsz / total 都是明文。
- 难度:⭐⭐⭐(三星。算法是标准 AES-CBC/Pkcs7,难点在于「密文是十六进制、要先转再解」,以及
require('crypto-js') 的编码细节)
这个项目属于响应解密,和 Project6(数位观察)是一对:那个是「Base64 密文直接 AES」,这个是「Hex 密文,先 Hex→Base64 再 AES」。看起来只差一步,但这一步是很多人的第一道坎。
二、抓包分析
打开 2ebK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7P5Y4y4U0i4K6u0W2L8h3!0Z5N6i4u0V1i4K6u0W2k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6V1j5i4c8S2i4K6u0r3j5$3!0E0M7r3q4F1P5b7`.`.,F12 → Network → 筛 Fetch/XHR,翻一下企业列表,找到对 query/comp/list 的请求。
看 Payload,请求是 GET,query 明文:
pg=2
pgsz=15
total=450
pg 是页码,pgsz 是每页条数。请求这边完全不用管。
再看 Response,返回的不是 JSON,而是一大段十六进制字符串(全是 0-9a-f,没有 + / 这些 Base64 特征字符):
7a3b9c...(一长串 hex)...
这就说明是响应体加密。虽然它长得像 Base64,但细看只有 0-9a-f,其实是 Hex 编码——这正是坑所在。
【图1:Network 面板里 query/comp/list 请求的 Response 原始密文 —— 建议截图】
结论:请求照抄,重点是把「hex 密文 → 明文 JSON」的解密函数抠出来,并解决 require 与编码问题。
三、定位加密入口
响应解密的定位套路和 Project6 一样:去找谁在处理响应。
在 Sources 面板里搜接口路径的一部分(query/comp/list)或业务字段名,顺到 ajax 的 .then() 回调;回调里通常有一段「把 res 交给某个函数处理」的代码。继续跟进那个函数,就会落到真正的解密实现。
我抠出来的是仓库里的 js/mohurd1.js,完整逻辑如下:
// 取自仓库真实 js:Project5/mohurd/js/mohurd1.js
var cryptoJS = require('crypto-js');
function get_res(Data){
var d = {
a:cryptoJS
};
function BB(t) {
f = d.a.enc.Utf8.parse("Dt8j9wGw%6HbxfFn");
m = d.a.enc.Utf8.parse("0123456789ABCDEF");
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
}
一步步拆开看:
f = Utf8.parse("Dt8j9wGw%6HbxfFn") —— 密钥。这是 16 个可见字符,Utf8.parse 后是 16 字节 → AES-128;
m = Utf8.parse("0123456789ABCDEF") —— IV 也是 16 字节,且是一段看起来很朴素的字符串(0-9A-F);
e = Hex.parse(t) —— 关键第一步:把服务端返回的十六进制密文解析成 WordArray;
n = Base64.stringify(e) —— 关键第二步:把同一个 WordArray 重新编码成 Base64 字符串;
a = AES.decrypt(n, f, {iv: m, mode: CBC, padding: Pkcs7}) —— 用 Base64 串做 AES-CBC 解密;
r = a.toString(Utf8) —— 明文转成 UTF-8 字符串;
JSON.parse 后再取 EE.data.list —— 解密出的 JSON 顶层有 data 包装,真正的企业数组在 data.list。
第 3、4 步就是本项目的精髓:服务端把 AES 密文用 Hex 编码下发,而 CryptoJS.AES.decrypt 默认按 Base64 解读入参,所以必须先 Hex.parse 再 Base64.stringify,等价于「把 hex 转成 base64」,否则 decrypt 会把 hex 字符串当成 base64 解析,直接解出空串或乱码。
【图2:Sources 面板里解密函数 / AES.decrypt 断点调用栈 —— 建议截图】
四、JS 还原与补环境
这个文件的补环境成本几乎为零:
- 它只依赖
crypto-js,所以本地 Node 环境 require('crypto-js') 即可;
- 没有用到
window / document / navigator;
- 函数签名
get_res(Data) 保持原样,Data 就是服务端返回的原始字符串。
需要留意的是作者在这里用了一些「隐式全局变量」(f / m / e / n / a / r / TT / EE / list 都没写 var/let)——在 get_res 这种一次性调用里没问题,但如果要在同一进程里多次调用、或追求严谨,建议给它们补上 var,避免污染全局。
另外:cryptoJS.enc.Utf8.parse 用的是 CryptoJS 的 UTF-8 编码,密钥/IV 必须原样字符串传进去,不要自己转十六进制或手动补位。
五、Python 实现
py/mohurd1.py 分两步:Spider() 拿响应原文,js_fun() 把原文交给 JS 解密:
# 取自仓库真实 py:Project5/mohurd/py/mohurd1.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","mohurd1.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": "095K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7P5Y4y4U0i4K6u0W2L8h3!0Z5N6i4u0V1i4K6u0W2k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6V1j5i4c8S2i4K6u0r3j5$3!0E0M7r3q4F1P5b7`.`.",
"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": "231012"
}
url:str = "f10K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6B7P5Y4y4U0i4K6u0W2L8h3!0Z5N6i4u0V1i4K6u0W2k6$3!0$3i4K6u0W2j5$3&6Q4x3V1k6m8f1r3W2Q4x3V1k6%4k6h3u0m8M7r3W2Q4x3V1k6V1j5i4c8S2M7$3g2J5N6X3W2U0k6g2)9J5c8Y4q4#2k6i4u0&6i4K6u0r3j5$3!0E0M7q4)9J5c8X3I4A6M7%4b7`."
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)
要点:
os.chdir(js_dir):保留这个习惯,保证 require('crypto-js') 和相对路径都在预期目录下解析;
- 请求头里几个"伪装"字段要带上:
v: "231012"(版本)、timeout: "30000"、accessToken;: ""(注意这个头名带分号,原样照抄)。这个站点对头比较敏感,缺了可能返回风控页;
- 直接传
.text:js.call("get_res", data) 的入参是响应原文(hex 字符串),不要在 Python 里做任何解码。
六、运行结果
执行 python mohurd1.py,终端会打印解密并解析后的企业列表(Python 列表/字典):
[{'QY_NAME': '某某建筑工程有限公司', 'QY_ORG_CODE': '91...', 'QY_REG_ADDR': '...', 'QY_ID': '...'},
{'QY_NAME': '某某建设集团有限公司', 'QY_ORG_CODE': '91...', 'QY_REG_ADDR': '...', 'QY_ID': '...'}]
只要 QY_NAME 等中文字段正常显示、不再是密文,就说明「Hex→Base64→AES-CBC→JSON」这条链路完全打通。
【图3:终端打印出的企业列表数据 —— 建议截图】
七、踩坑与注意
密文是 Hex 不是 Base64。 这是最大坑:AES.decrypt 默认按 Base64 解析入参,直接把 hex 串丢进去会解密失败。正确姿势是 Hex.parse(t) 后再 Base64.stringify,即「先转成 base64」。
密钥/IV 用 Utf8.parse。 "Dt8j9wGw%6HbxfFn" 是 16 字符(AES-128),"0123456789ABCDEF" 是 16 字节 IV,都要原样 Utf8.parse,别手动转 hex。
CBC 必须带 IV。 和 ECB 不同,这里 {iv: m, mode: CBC, padding: Pkcs7} 三件套缺一不可。
返回层级是 data.list。 解密出的 JSON 顶层有 data 包装,直接取 data.list;如果打印出来是 {...} 而不是列表,多半是层级取错。
require('crypto-js') 要能加载。 本地需 npm i crypto-js,且 execjs 的 Node 要能找到它(放在 js 目录或全局)。若报 Cannot find module 'crypto-js',就在 js/ 下装一份。
站点对请求头敏感。 v / timeout / accessToken; 这些字段照抄,别删;否则可能返回加密的错误页,让你误判成算法错。
编码统一 UTF-8。 读取 JS、传递响应、打印结果都用 UTF-8,避免中文企业名乱码。
低频请求、遵守条款。
八、总结
- 响应解密的核心是找「处理响应的那个函数」,搜接口路径顺回调 / 搜
AES/decrypt/CryptoJS 即可定位。
- 密文的编码方式决定成败:Base64 密文直接解,Hex 密文要先
Hex.parse → Base64.stringify 再解——这一步是「Hex 响应」最常见的坑。
- AES-CBC 三要素(Key/IV/模式+填充)照抄,
crypto-js + execjs 零补环境落地,是响应解密最快的方案。
免责声明:本文仅供网络安全技术学习交流,请勿用于任何商业或非法用途。