Project5_全国建筑市场监管
全国建筑市场监管公共服务平台响应解密 JS 逆向实战(AES + 补环境 + execjs)
本文是「JS 逆向实战 30 例」系列第 5 篇,项目源码:JS 逆向实战 30 例 · 开源仓库 (
Project5)
一、目标与背景
全国建筑市场监管公共服务平台(jzsc.mohurd.gov.cn),我要抓它的企业列表接口。这个接口的请求是明文的,但响应体是一整段十六进制密文,必须解密才能拿到 JSON。
- 站点:
https://jzsc.mohurd.gov.cn/data/company - 目标接口:
https://jzsc.mohurd.gov.cn/APi/webApi/dataservice/query/comp/list - 需还原字段:响应处理(解密)。请求参数
pg/pgsz/total都是明文。 - 难度:⭐⭐⭐(三星。算法是标准 AES-CBC/Pkcs7,难点在于「密文是十六进制、要先转再解」,以及
require('crypto-js')的编码细节)
这个项目属于响应解密,和 Project6(数位观察)是一对:那个是「Base64 密文直接 AES」,这个是「Hex 密文,先 Hex→Base64 再 AES」。看起来只差一步,但这一步是很多人的第一道坎。
二、抓包分析
打开 https://jzsc.mohurd.gov.cn/data/company,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": "https://jzsc.mohurd.gov.cn/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": "231012"
}
url:str = "https://jzsc.mohurd.gov.cn/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)
要点:
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零补环境落地,是响应解密最快的方案。
免责声明:本文仅供网络安全技术学习交流,请勿用于任何商业或非法用途。
浙公网安备 33010602011771号