免责声明: 本文所有分析均基于公开可访问的前端 JS 代码及研究过程中所接触网站的正常访问流量,仅用于安全研究、学习与了解 Web 前端防护机制。请勿将本文技术用于任何未授权的系统,违者后果自负。
研究对象: 国家医疗保障局公共服务平台(fuwu.nhsa.gov.cn),该网站使用 Webpack 打包,接口存在全链路加密机制:请求体用 SM4 加密、请求体用 SM2 签名、请求头用 SHA256 哈希、响应体用 SM4 解密。
一、Webpack 是什么
Webpack 是目前最主流的前端模块打包工具,广泛应用于 Vue、React 等现代 Web 应用的构建流程中。它将项目中所有模块(JS、CSS、图片等)打包为一个或多个 bundle 文件,最终部署到服务端供浏览器加载。
对于安全研究而言,Webpack 打包产物具有如下特点:
- 模块封装 :每个原始模块被包裹在函数中,通过
module id(数字或字符串)进行标识和索引 - 统一加载器 :打包文件头部存在一个标准的模块加载函数(通常命名为
__webpack_require__或混淆后的短名),负责按需加载和缓存所有模块 - 代码混淆 :变量名、函数名均被替换为短随机字符串(如
_7d92、e、o),增加可读性难度
二、Webpack Bundle 结构分析
2.1 整体结构
一个典型的 Webpack bundle 结构如下:
javascript
!function(e) { // e 是所有模块的集合对象(moduleFactory)
var n = {} // n 是模块缓存
function o(t) { // o 就是 __webpack_require__,模块加载器核心
if (n[t])
return n[t].exports; // 命中缓存直接返回
var i = n[t] = { i: t, l: false, exports: {} };
e[t].call(i.exports, i, i.exports, o); // 执行模块工厂函数
i.l = true;
return i.exports;
}
// 【逆向改动】手动添加:将加载器暴露到全局,原始 bundle 中没有这行
window.load_func = o
// ... 其他辅助函数(异步加载、公共路径等)
o('app') // 入口模块启动
}({
"7d92": function(module, exports, require) { /* 模块7d92的代码 */ },
"a1b2": function(module, exports, require) { /* 模块a1b2的代码 */ },
// ...
})
2.2 核心组件说明
| 组件 | 变量名(混淆后) | 作用 |
|---|---|---|
| 模块集合 | e |
以 module id 为键,模块工厂函数为值的对象 |
| 模块缓存 | n |
已执行过的模块的 exports 缓存,避免重复执行 |
| 模块加载器 | o / __webpack_require__ |
按 id 查找并执行模块,返回其 exports |
| 已加载标志 | i.l |
标记模块是否已完成加载 |
2.3 模块加载器工作原理
o(t) 函数(即 __webpack_require__)是整个 Webpack bundle 的核心,其执行逻辑:
调用 o("7d92")
↓
查缓存 n["7d92"]:是否已执行过
↓(未命中)
创建空模块对象:{ i: "7d92", l: false, exports: {} }
存入缓存 n["7d92"]
↓
执行模块工厂函数:e["7d92"].call(exports, module, exports, o)
(工厂函数内可通过第三参数 o 继续 require 其他模块)
↓
标记 l = true,返回 module.exports
三、快速定位目标模块
3.1 手动暴露加载器
原始 bundle 中加载器函数(__webpack_require__)通常不会主动暴露到外部。逆向时需要手动在 bundle 内加载器定义处添加一行 ,将其挂载到 window,供外部调用:
javascript
// 在 bundle 中找到加载器定义处(即 window.load_func = o 的上方),手动添加:
window.load_func = o // 手动添加:将加载器暴露到全局,供 Node.js 中调用
添加这行后,就可以在 02_main.js 中通过 window.load_func(moduleId) 按需加载任意模块。
如何定位加载器函数的位置?
在 bundle 源码中搜索以下特征模式,即可定位加载器函数:
javascript
// 典型特征:函数参数为 t,内部创建模块缓存对象 n[t]
function o(t) {
if (n[t]) return n[t].exports; // 命中缓存直接返回
var i = n[t] = { i: t, l: false, exports: {} };
e[t].call(i.exports, i, i.exports, o); // 执行模块工厂函数
i.l = true;
return i.exports;
}
// 在这行之后添加:
window.load_func = o
3.2 注释入口调用(避免补环境的关键技巧)
Webpack bundle 在最后会有一行入口调用,启动整个应用:
javascript
// bundle 末尾的入口(未注释的原始状态)
o(o.s = 0) // 加载模块 0,触发整个应用的启动链
这行代码会递归加载所有依赖模块,包括路由、状态管理、环境检测等应用层逻辑。在 Node.js 中执行时,这些模块会访问 window、document、navigator 等浏览器环境,如果不补就会抛错。
解决办法:注释入口行
javascript
// 将 bundle 末尾的入口调用注释掉:
// o(o.s = 0) ← 加 // 注释
注释后效果:
| 未注释 | 注释后 | |
|---|---|---|
require('./01_source') 行为 |
注册模块 + 立即执行入口,触发环境检测 | 仅注册所有模块到加载器,不执行任何业务代码 |
| 是否需要补环境 | 是 | 否 |
| 模块是否可用 | 是 | 是,只要等到 load_func 注册完成 |
适用条件 :当目标模块(如加密模块
7d92)并不依赖入口模块的初始化状态时,该方案完全有效。若目标模块内部依赖入口模块设置的某些全局变量,则可能返回错误结果。
3.2 定位加密/解密模块
方法一:抓包分析请求头
观察接口请求,找到动态变化的加密参数(如 x-tif-signature、x-tif-nonce、x-tif-timestamp 等),在 DevTools 中搜索这些字段名,定位到赋值代码,往上跟栈找到模块 id。
方法二:Hook 关键函数
在 DevTools Console 中 Hook 常见加密函数,如:
javascript
// Hook CryptoJS / SM4 / AES 等
const _encrypt = CryptoJS.AES.encrypt;
CryptoJS.AES.encrypt = function(...args) {
console.trace('AES encrypt called', args);
return _encrypt.apply(this, args);
};
方法三:搜索加密算法关键字
在 Sources 面板搜索以下关键字,快速定位加密相关模块:
SM4 SM2 AES RSA MD5 SHA hmac CryptoJS encrypt sign
3.3 验证模块可用性
在浏览器断点中,通过堆栈内的加载器引用执行目标模块:
javascript
// 在 bundle 内部断点停下后,控制台中 o 就是当前作用域内的加载器引用
let mod = o("7d92") // 按 id 加载目标模块
console.log(mod) // 查看模块导出了哪些函数
console.log(mod.a) // 查看加密函数
console.log(mod.b) // 查看解密函数
注意 :
window.load_func是本地 bundle 中手动添加的,线上原始 bundle 不存在。在浏览器中调试时,应在断点堆栈内直接使用局部的加载器变量(如o)进行验证。
四、本站加密机制分析
4.1 加密方案识别
通过抓包分析,该站点(fuwu.nhsa.gov.cn)的接口存在请求体加密 + 请求头动态签名 + 响应体解密的全链路加密机制:
- 请求体加密 :业务数据用 SM4 加密,密钥由
appCode和appSecret通过自定义函数动态派生(非标准 SM4,密钥派生逐辑需逆向确认) - 请求体签名 :
signData字段用 SM2 对排序后的请求字段做非对称签名(hash: true) - 请求头动态签名 :
x-tif-signature= SHA256 (timestamp + nonce + timestamp),x-tif-timestamp和x-tif-nonce由加密模块动态生成 - 响应体解密:服务端返回的密文用相同派生密钥做 SM4 解密
- 涉及算法 :SM4(加解密)、SM2(签名)、SHA256(请求头哈希),均由模块
7d92统一实现
4.2 关键请求头
| 请求头字段 | 说明 |
|---|---|
x-tif-signature |
请求签名,由加密模块动态生成 |
x-tif-timestamp |
时间戳 |
x-tif-nonce |
随机字符串,防重放 |
x-tif-paasid |
应用标识 |
4.3 模块 7d92 接口
javascript
// 加载目标模块
_7d92 = window.load_func("7d92")
// 加密:传入请求数据对象,返回加密后的 {data, headers} 结构
_7d92.a(requestData)
// 返回示例:{ data: "加密密文...", headers: { "x-tif-signature": "...", ... } }
// 解密:传入算法名 + 响应密文,返回明文 JSON 字符串
_7d92.b("SM4", cipherText)
五、Node.js 本地还原方案
5.1 整体思路
直接将 Webpack bundle(01_source.js)在 Node.js 中执行,利用手动暴露的 window.load_func 调用目标模块,实现与浏览器端完全一致的加密/解密。
Python (nhas.py)
└─ subprocess 调用 node 02_main.js encrypt_data <json>
└─ 02_main.js
├─ window = global # 将 Node.js global 作为 window
├─ require('./01_source') # 执行 Webpack bundle,注册所有模块
├─ _7d92 = window.load_func("7d92") # 加载目标模块
└─ encrypt_data(data) / decrypt_data(data) # 调用加解密
5.2 02_main.js 入口代码
javascript
window = global // 关键:让 bundle 中的 window.xxx 赋值生效
require("./01_source") // 执行 bundle,触发 window.load_func = o
_7d92 = window.load_func("7d92") // 按模块 id 加载加密模块
function encrypt_data(data) {
return JSON.stringify(_7d92.a(data))
}
function decrypt_data(data) {
return JSON.stringify(_7d92.b("SM4", data))
}
// 命令行参数分发
const args = process.argv.slice(2);
const functionName = args[0];
const data = args[1];
if (functionName === 'encrypt_data') {
console.log(encrypt_data(JSON.parse(data)));
}
if (functionName === 'decrypt_data') {
console.log(decrypt_data(JSON.parse(data)));
}
5.3 Python 调用代码
python
import json
import os
import subprocess
import requests
_DIR = os.path.dirname(os.path.abspath(__file__))
def call_js_function(js_file_path, function_name, arg):
result = subprocess.run(
['node', js_file_path, function_name, arg],
capture_output=True, text=True, check=True, encoding='utf-8'
)
return result.stdout.strip()
# 1. 加密请求数据,同时获取动态请求头
js_data = call_js_function(os.path.join(_DIR, "02_main.js"), "encrypt_data", json.dumps(data))
js_data = json.loads(js_data)
encrypted_body = js_data["data"] # 加密后的请求体
dynamic_headers = js_data["headers"] # 动态签名等请求头
# 更新请求头中的动态字段
for key in dynamic_headers:
if key in headers:
headers[key] = str(dynamic_headers[key])
# 2. 发送请求
response = requests.post(url, headers=headers, cookies=cookies, data=encrypted_body)
# 3. 解密响应
plaintext = call_js_function(os.path.join(_DIR, "02_main.js"), "decrypt_data", response.text)
print(json.loads(plaintext))
六、Webpack 逆向通用方法论
6.1 快速入手流程
1. 抓包:找到加密参数 → 确定哪些字段需要逆向
↓
2. 搜索:DevTools Sources 搜索字段名或加密关键字(SM4/SM2/SHA/encrypt/sign等)
↓
3. 跟栈:往上追调用链 → 找到 require(moduleId) 调用和目标模块 id
↓
4. 确认:在断点处通过堆栈内的加载器引用执行模块,
观察导出内容并确认目标函数的参数和返回值格式
↓
5. 保存:在 DevTools Sources 面板右键目标 bundle → Save as,
将完整 JS 文件保存到本地(即 01_source.js)
↓
6. 改动 bundle:
① 在加载器函数定义后手动添加 window.load_func = o(暴露加载器)
② 将 bundle 末尾的入口行注释:// o(o.s = 0)(避免执行应用层与补环境)
↓
7. 封装:写 02_main.js,window = global 后 require bundle,用 window.load_func(moduleId)
加载目标模块,封装加解密函数供 Python subprocess 调用
6.2 常见坑点
| 问题 | 原因 | 解决方案 |
|---|---|---|
window is not defined |
Node.js 中无 window 对象 | 在 02_main.js 顶部加 window = global |
document is not defined |
bundle 内部访问了 DOM | 补充 document = { cookie: '', ... } |
load_func is not exposed |
网站未将加载器挂载到 window | 手动修改 bundle,在加载器定义处加 global.load_func = o |
| 模块 id 不稳定 | 每次构建 id 可能变化 | 用加密函数特征(算法名、关键字符串)搜索,而非硬编码 id |
| 请求体格式不对 | 加密后返回的 data 格式需特殊处理 | 观察浏览器实际发送的 Content-Type 和 body 格式 |
6.3 模块 id 查找技巧
当网站加载器没有暴露到 window 时,可在 bundle 中搜索以下模式手动提取:
javascript
// 搜索 SM4 / AES / 签名相关关键字,找到模块定义
// 典型结构:
"7d92": function(module, exports, require) {
// ... 包含 SM4 或加密逻辑
module.exports = { a: encryptFn, b: decryptFn }
}
找到后,在加载器函数定义结束处(window.load_func = o 附近)添加一行导出:
javascript
window.load_func = o
// 手动添加:
global.load_func = o // 兼容 Node.js 环境
七、依赖安装
bash
# 仅需 Node.js 运行环境(无需额外 npm 包)
node --version # 建议 v16+
# Python 依赖
pip install requests
本文技术仅供安全研究与学习,切勿用于任何未授权系统,违者后果自负。