本文基于前端 webpack 源码逆向分析,仅供学习交流。代码中的凭据信息来自前端公开的 JS 文件,均为客户端固定值。
一、背景
爬虫古法炮制似乎有点走下坡了(当然这个是个人见解),大模型的快速发展对各行各业冲击都很大,本次对国医保进行纯大模型(Qoder+DeepSeek-V4-Pro),用MCP和skills协调处理,本地纯协议实现
本文适合以下读者:
- 对国密(SM2/SM3/SM4)实战感兴趣
- 需要做 Python 与 Node.js 国密跨语言对接
- 对 Web API 逆向工程有学习需求
- AI + 大模型逆向
二、目标接口
POST https://fuwu.nhsa.gov.cn/ebus/fuwu/api/nthl/api/CommQuery/queryFixedHospital
查询参数示例:
{ "pageNum": 1, "pageSize": 10, "queryDataSource": "es", "regnCode": "110000", "medinsName": "北京协和医院" }
请求头需要携带:
| Header | 说明 |
|---|---|
x-tif-paasid |
固定值 nthl |
x-tif-signature |
SHA256 签名 |
x-tif-timestamp |
Unix 秒级时间戳 |
x-tif-nonce |
8位随机字符串 |
请求体是一个嵌套结构,外层是元数据 + 签名,内层是 SM4 加密的密文:
javascript
{ "data": { "appCode": "T98HPCGN5ZVVQBS8LZQNOAEXVI9GYHKQ", "version": "1.0.0", "encType": "SM4", "signType": "SM2", "timestamp": "1749123456", "signData": "<SM2签名 Base64>", "data": { "encData": "<SM4加密 hex 大写>" } } }
三、发现两套拦截器(逆向核心突破)
3.1 webpack 模块架构
通过搜索和符号分析,前端 webpack bundle 中相关的关键模块:
| 模块 | 作用 |
|---|---|
module_365c |
API 定义 + axios 实例 + 响应拦截器 |
module_0d5e |
个人端 axios 实例 + 请求拦截器 |
module_7d92 |
个人端(公共API) 加密/签名拦截器 |
module_796e |
单位端(认证API) 加密/签名拦截器 |
module_68b2 |
{sm2, sm3, sm4} 密码库入口 |
module_94f8 |
SHA256 实现 |
module_365c中queryFixedHospital的实现:
// module_365c.js queryFixedHospital: function(e) { return d.a.post("/nthl/api/CommQuery/queryFixedHospital", e) }
d.a是module_0d5e导出的个人端 axios 实例,走的是module_7d92拦截器------而非module_796e。
3.2 两套拦截器对比
| 属性 | Module 7d92 (公共API) | Module 796e (认证API) |
|---|---|---|
| appCode | T98HPCGN5ZVVQBS8LZQNOAEXVI9GYHKQ |
无(使用 appId) |
| appSecret | NMVFVILMKT13GEMD3BKPKCTBOQBPZR2P |
D91CEB11EE62219CD91CEB11EE62219C |
| SM2 密钥对 | 固定(见下文) | 另一对固定密钥 |
| 请求体格式 | {data: {appCode, signData, data: {encData}}} |
{appId, data: {encData}} |
| SM4 明文处理 | Unicode 转义 | 直接 UTF-8 |
| 适用场景 | queryFixedHospital 等公开查询 | 需要登录态的接口 |
如果选错拦截器,用 module_796e 的凭据和格式去请求 module_7d92 的接口,服务端永远返回 code -2 "参数错误"。这是整个逆向过程中最关键的一道分岔。
四、SM4 密钥派生
SM4 加密所用的密钥并非直接使用 appSecret,而是通过一个派生函数(前端变量名 A)计算得出。
4.1 派生算法
SM4_key = A(appCode, appSecret):
1. 取 appCode 前 16 字符 → UTF-8 编码 → 16 字节 "密钥模板"
2. appSecret → UTF-8 编码 → SM4-ECB 加密(密钥为步骤1的模板)
3. 密文 hex 大写 → 取前 16 字符 4. 这 16 个 hex 字符 → UTF-8 编码 → 最终 16 字节 SM4 密钥
关键点:16 个 hex 字符作为 ASCII 字符串再编码,每个 hex 字符 = 1 字节,共 16 字节,恰好是 SM4 的密钥长度。
4.2 验证计算
输入:
appCode=T98HPCGN5ZVVQBS8LZQNOAEXVI9GYHKQappSecret=NMVFVILMKT13GEMD3BKPKCTBOQBPZR2P
步骤:
- 密钥模板 = UTF8("T98HPCGN5ZVVQBS8") =
543938485043474E355A565651425338(hex) - SM4-ECB(appSecret_bytes, 密钥模板) → hex 大写
- 取前 16 字符 →
4333414535383733(即 ASCIIC3AE5873) - UTF8("C3AE5873") → 16 字节密钥
步骤 3-4 可能看起来多此一举(为什么不直接用密文的前 16 字节?),但实际上这个设计使得密钥可读、可调试,且 16 hex 字符 → UTF8 → 恰好 16 字节(SM4 密钥长度)。
4.3 Python 实现
python
from gmssl.sm4 import CryptSM4, SM4_ENCRYPT
def derive_sm4_key(app_code: str, app_secret: str) -> bytes:
"""SM4 密钥派生"""
key_tmpl = app_code[:16].encode("utf-8") # 16 bytes
data_bytes = app_secret.encode("utf-8")
sm4 = CryptSM4()
sm4.set_key(key_tmpl, SM4_ENCRYPT)
# PKCS7 填充
pad_len = 16 - len(data_bytes) % 16
padded = data_bytes + bytes([pad_len] * pad_len)
enc = sm4.crypt_ecb(padded)
enc_hex = enc.hex().upper()
return enc_hex[:16].encode("utf-8") # 16 bytes
五、SM4 数据加密与 Unicode 转义
5.1 Module 7d92 特有的 Unicode 转义
这是两个拦截器模块最关键的差异。Module 7d92(公共API)在 SM4 加密前会执行以下处理:
javascript
// 前端源码 core logic
var t = JSON.stringify(queryParams);
var n = "";
for (var i = 0; i < t.length; i++) {
var r = t.charAt(i), o = t.charCodeAt(i);
n += o > 127 ? "\\u" + o.toString(16).padStart(4, "0") : r;
}
// n = '{"regnCode":"110000","medinsName":"\\u5317\\u4eac\\u534f\\u548c"}'
// 不转义 ^^^^^^^^^^^^^^^^^^^^^^^^^
var plainBytes = utf8Encode(n);
// SM4-ECB encrypt with PKCS7
而 Module 796e 没有这一步 ,直接对 JSON.stringify 结果做 UTF-8 编码后加密。
5.2 完整 SM4 加密流程
1. JSON.stringify(查询参数) → JSON 字符串 2. Unicode 转义(仅 7d92):charCode > 127 → \uXXXX 例:"北京" → "\u5317\u4eac" 3. UTF-8 编码 4. PKCS7 填充到 16 字节对齐 5. SM4-ECB 加密 6. 输出 hex 大写
5.3 Python 实现
javascript
def sm4_encrypt_data(json_str: str, key_bytes: bytes) -> str:
"""SM4 加密数据(含 Unicode 转义)"""
# Unicode 转义
escaped = ""
for ch in json_str:
code = ord(ch)
if code > 127:
escaped += "\\u" + format(code, "04x")
else:
escaped += ch
plain_bytes = escaped.encode("utf-8")
# PKCS7 填充 + SM4-ECB 加密
sm4 = CryptSM4()
sm4.set_key(key_bytes, SM4_ENCRYPT)
pad_len = 16 - len(plain_bytes) % 16
padded = plain_bytes + bytes([pad_len] * pad_len)
cipher = sm4.crypt_ecb(padded)
return cipher.hex().upper()
六、SM2 数字签名(含 gmssl 的两个坑)
6.1 签名全流程
1. 构建签名对象: signObj = { data: <查询参数>, appCode: "...", version: "1.0.0", encType: "SM4", signType: "SM2", timestamp: "..." } 2. extractSignable(signObj): 剔除 signData、encData、extra 字段 剔除值为 null 的字段 3. sortObjKeys(signable): 所有 key 按字母顺序排序 data 内部的 key 也按字母顺序排序 4. buildSignString(sortedObj): - data 字段特殊处理:数字/布尔 → 字符串,空数组删除,再 JSON.stringify - 普通字段:key=value - 末尾追加 &key=<appSecret> - 所有部分用 & 连接 5. SM2_sign(SHA256(Z_A + signString_bytes), privateKey) Z_A = SM3(ENTLA || ID || a || b || xG || yG || xA || yA) 其中 xA || yA 是公钥坐标 6. 签名结果 raw r||s (64 bytes) → Base64 编码
6.2 SM2 密钥对
| 项目 | Base64 |
|---|---|
| 私钥 | AJxKNdmspMaPGj+onJNoQ0cgWk2E3CYFWKBJhpcJrAtC |
| 公钥 | BEKaw3Qtc31LG/hTPHFPlriKuAn/nzTWl8LiRxLw4iQiSUIyuglptFxNkdCiNXcXvkqTH79Rh/A2sEFU6hjeK3k= |
6.3 坑一:gmssl 不会自动推导公钥
SM2 签名规范要求计算 Z_A 值,其中包含公钥坐标 xA || yA:
Z_A = SM3(ENTLA || ID || a || b || xG || yG || xA || yA)
- Node.js 的
sm-crypto库:sm2.doSignature(signStr, privateKey)自动从私钥推导公钥 → Z_A 正确 - Python 的
gmssl库:CryptSM2(public_key="", private_key=PK)公钥为空 → Z_A 使用空公钥坐标计算 → Z_A 错误 → 签名被服务端拒绝
解决方法:显式传入正确的公钥。
# 错误:公钥为空 sm2_crypt = CryptSM2(public_key="", private_key=PRIVATE_KEY_HEX) # 正确:传入公钥 sm2_crypt = CryptSM2(public_key=PUBLIC_KEY_HEX_NO_PREFIX, private_key=PRIVATE_KEY_HEX)
6.4 坑二:gmssl 的 .lstrip("04") 行为陷阱
gmssl 内部源码中:
# gmssl sm2.py 源码(简化) self.public_key = public_key.lstrip("04")
公钥 hex 通常以 04 开头(未压缩格式标识)。这条代码的本意是去掉这个前缀。
但 Python 的 str.lstrip("04") 不是去掉前缀 "04" ,而是移除所有前导的字符 '0' 和 '4'!
示例:
>>> "04429ae0de".lstrip("04") '29ae0de' # '4' 和 '2' 之间的 '4' 也被删了!
我们公钥 hex 开头是 04429a...,第三个字符恰好也是 4,被多截断了。
解决方法:手动去掉 "04" 前缀再传入。
PUBLIC_KEY_HEX = base64_to_hex(PUBLIC_KEY_BASE64) PUBLIC_KEY_HEX_NO_PREFIX = PUBLIC_KEY_HEX[2:] # 手动去掉 "04"
6.5 Python 完整签名实现
javascript
import base64
from gmssl import sm2 as gm_sm2
def sm2_sign(sign_string: str) -> str:
"""SM2 签名,返回 Base64 编码的签名"""
sm2_crypt = gm_sm2.CryptSM2(
public_key=PUBLIC_KEY_HEX_NO_PREFIX, # 手动去掉了 "04"
private_key=PRIVATE_KEY_HEX
)
sign_hex = sm2_crypt.sign_with_sm3(sign_string.encode("utf-8"))
return base64.b64encode(bytes.fromhex(sign_hex)).decode()
七、签名字符串中的 JSON 序列化陷阱
7.1 ensure_ascii 问题
构造签名串时,data 字段需要 JSON.stringify:
parts.append(f"data={json.dumps(sorted_data, separators=(',', ':'), ensure_ascii=False)}")
ensure_ascii=False 是关键。如果设为 True(默认值),中文会被转义:
| ensure_ascii | 效果 |
|---|---|
True |
{"medinsName":"\u5317\u4eac\u534f\u548c"} |
False |
{"medinsName":"北京协和医院"} |
Node.js 的 JSON.stringify 默认不转义 Unicode,行为等价于 ensure_ascii=False。如果 Python 端用 ensure_ascii=True,签名串不同 → SM2 签名不同 → 服务端验签失败,返回 173370 "权限校验异常"。
7.2 签名字符串完整示例
data={"medinsName":"北京协和医院","pageNum":"1","pageSize":"10","queryDataSource":"es"}&appCode=T98HPCGN5ZVVQBS8LZQNOAEXVI9GYHKQ&version=1.0.0&encType=SM4&signType=SM2×tamp=1749123456&key=NMVFVILMKT13GEMD3BKPKCTBOQBPZR2P
注意:这里的 data JSON 不做 Unicode 转义,跟 SM4 加密时不同。
八、SHA256 请求头签名
三层的最后一层,在 HTTP 头中加入签名:
javascript
// module_7d92.js, function f
var timestamp = Math.ceil(Date.now() / 1000);
var nonce = generateNonce(); // 1字母+1数字+6字母数字 = 8字符
var signRaw = timestamp + nonce + timestamp;
headers["x-tif-signature"] = sha256(signRaw);
headers["x-tif-timestamp"] = timestamp;
headers["x-tif-nonce"] = nonce;
规则非常简单:SHA256(timestamp + nonce + timestamp),即时间戳夹着随机数。
Python 实现:
python
import hashlib, time, random, string
def generate_nonce():
alpha = string.ascii_letters
digit = string.digits
chars = alpha + digit
return (random.choice(alpha) + random.choice(digit) +
''.join(random.choice(chars) for _ in range(6)))
timestamp = str(int(time.time()))
nonce = generate_nonce()
signature = hashlib.sha256((timestamp + nonce + timestamp).encode()).hexdigest()
九、请求头与请求体完整构建
将前三层拼装起来:
javascript
def build_request(query_params):
import math
timestamp = str(math.ceil(time.time()))
nonce = generate_nonce()
# Layer 1: SM4 密钥派生 + 数据加密
sm4_key = derive_sm4_key(APP_CODE, APP_SECRET)
query_json = json.dumps(query_params, separators=(",", ":"), ensure_ascii=False)
enc_data = sm4_encrypt_data(query_json, sm4_key)
# Layer 2: SM2 签名
sign_obj = {
"data": query_params,
"appCode": APP_CODE,
"version": "1.0.0",
"encType": "SM4",
"signType": "SM2",
"timestamp": timestamp,
}
# extractSignable → sortObjKeys → buildSignString → sign
signable = {k: v for k, v in sign_obj.items()
if v is not None and k not in ("signData", "encData", "extra")}
sorted_obj = {k: signable[k] for k in sorted(signable.keys())}
sorted_obj["data"] = {k: sorted_obj["data"][k]
for k in sorted(sorted_obj["data"].keys())}
sign_string = build_sign_string(sorted_obj, APP_SECRET)
sign_data = sm2_sign(sign_string)
# 组装请求体
body = {
"data": {
"appCode": APP_CODE,
"version": "1.0.0",
"encType": "SM4",
"signType": "SM2",
"timestamp": timestamp,
"signData": sign_data,
"data": {"encData": enc_data},
}
}
# Layer 3: 请求头签名
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"x-tif-paasid": "nthl",
"x-tif-signature": hashlib.sha256(
(timestamp + nonce + timestamp).encode()
).hexdigest(),
"x-tif-timestamp": timestamp,
"x-tif-nonce": nonce,
"channel": "web",
"Referer": "https://fuwu.nhsa.gov.cn/nationalHallSt/",
"Origin": "https://fuwu.nhsa.gov.cn",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
return body, headers, sm4_key
十、响应解密
服务端返回的数据也被 SM4 加密:
{
"code": 0,
"message": "成功",
"data": {
"appCode": "T98HPCGN5ZVVQBS8LZQNOAEXVI9GYHKQ",
"signData": "...",
"data": {
"encData": "<SM4加密的响应 hex>"
}
}
}
解密流程:
python
def decrypt_response(response_json, sm4_key):
enc_data = response_json["data"]["data"]["encData"]
sm4 = CryptSM4()
sm4.set_key(sm4_key, SM4_DECRYPT)
cipher_bytes = bytes.fromhex(enc_data)
plain = sm4.crypt_ecb(cipher_bytes)
# 去除 PKCS7 填充
pad_len = plain[-1]
if 0 < pad_len <= 16:
plain = plain[:-pad_len]
return json.loads(plain.decode("utf-8"))
十一、完整逆向流程总结
┌─────────────────────────────────────────────────┐
│ webpack bundle 分析 │
│ module_365c → d.a.post(queryFixedHospital) │
│ ↓ │
│ module_0d5e → axios 个人端实例 │
│ ↓ │
│ module_7d92 → 请求拦截器 (加密+签名) │
│ ↓ │
│ 两套拦截器 7d92 vs 796e → 公共API vs 认证API │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 三层安全机制 │
│ │
│ ① SM4-ECB 数据加密 │
│ ├─ 密钥派生 A(appCode, appSecret) │
│ ├─ Unicode 转义 (仅 7d92) │
│ └─ PKCS7 填充 │
│ │
│ ② SM2 数字签名 │
│ ├─ Z_A 计算(含公钥坐标) │
│ ├─ extractSignable → sortObj → buildSignString│
│ └─ 签名结果 Base64 编码 │
│ │
│ ③ SHA256 请求头签名 │
│ └─ SHA256(timestamp + nonce + timestamp) │
└─────────────────────────────────────────────────┘
十二、关键踩坑记录
| 序号 | 现象 | 根因 | 解决 |
|---|---|---|---|
| 1 | Node.js 返回 -2 |
选错拦截器模块(796e 的格式请求 7d92 的接口) | 切换到 module 7d92 凭据和请求体格式 |
| 2 | Python SM2 签名 ≠ Node.js | gmssl 未传入公钥 → Z_A 错误 | 显式传入公钥 |
| 3 | 公钥 hex 被多截断 | lstrip("04") 删除了所有前导的 '0' 和 '4' |
手动 [2:] 切片 |
| 4 | 中文查询返回 173370 |
json.dumps 默认转义中文 → 签名字符串不同 |
加 ensure_ascii=False |
| 5 | SM4 密文不对齐 | 密钥长度理解错误 | 确认 16 hex chars → UTF8 → 16 bytes |
总结
本次逆向的几点体会:
-
在 webpack bundle 中,同一个项目可能存在多套身份认证体系。不能看到 SM2+SM4 就直接套用,需要从调用链上追溯到具体的 axios 实例和拦截器。
-
国密跨语言对接时,库的行为差异是主要坑 。
sm-crypto(Node.js)自动推导公钥,gmssl(Python)不会;lstrip和[2:]的区别更是容易被忽略。 -
字符串编码细节毁人不倦 。
ensure_ascii、Unicode 转义、UTF-8 → hex → ASCII 这种多步转换,每一步都要和前端 JS 的行为严格对齐。 -
签名字符串和加密明文是两个独立的处理通道,容易混淆。签名字符串中的 data 不做 Unicode 转义,而 SM4 加密的 payload 要做------这个差异来自前端两个拦截器的不同设计。
测试后完全可以获取到数据差不多3个小时(其中包含了很多不必要的提问,如果有更好的模型和skills时间还会大幅度缩减)结果如下
