国家医保局 API 加密体系逆向全记录——SM2签名 + SM4加解密 + SHA256 头签名

本文基于前端 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_365cqueryFixedHospital 的实现:

// module_365c.js queryFixedHospital: function(e) { return d.a.post("/nthl/api/CommQuery/queryFixedHospital", e) }

d.amodule_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 = T98HPCGN5ZVVQBS8LZQNOAEXVI9GYHKQ
  • appSecret = NMVFVILMKT13GEMD3BKPKCTBOQBPZR2P

步骤:

  1. 密钥模板 = UTF8("T98HPCGN5ZVVQBS8") = 543938485043474E355A565651425338 (hex)
  2. SM4-ECB(appSecret_bytes, 密钥模板) → hex 大写
  3. 取前 16 字符 → 4333414535383733 (即 ASCII C3AE5873)
  4. 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&timestamp=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

总结

本次逆向的几点体会:

  1. 在 webpack bundle 中,同一个项目可能存在多套身份认证体系。不能看到 SM2+SM4 就直接套用,需要从调用链上追溯到具体的 axios 实例和拦截器。

  2. 国密跨语言对接时,库的行为差异是主要坑sm-crypto(Node.js)自动推导公钥,gmssl(Python)不会;lstrip[2:] 的区别更是容易被忽略。

  3. 字符串编码细节毁人不倦ensure_ascii、Unicode 转义、UTF-8 → hex → ASCII 这种多步转换,每一步都要和前端 JS 的行为严格对齐。

  4. 签名字符串和加密明文是两个独立的处理通道,容易混淆。签名字符串中的 data 不做 Unicode 转义,而 SM4 加密的 payload 要做------这个差异来自前端两个拦截器的不同设计。


测试后完全可以获取到数据差不多3个小时(其中包含了很多不必要的提问,如果有更好的模型和skills时间还会大幅度缩减)结果如下

相关推荐
跨境数据猎手1 小时前
复刻Cssbuy跨境淘宝代购集运系统搭建方案
爬虫·架构·系统架构
AINative软件工程6 小时前
把 MCP Server 推上生产:5 个没人告诉你的工程陷阱
mcp
郑洁文6 小时前
基于网络爬虫的XSS漏洞检测系统的设计与实现
网络·爬虫·网络安全·xss
Super Scraper7 小时前
如何将赋予千问(Qwen Code)网络检索功能:集成MCP服务器
人工智能·爬虫·ai·自动化·千问·mcp·qwen code
SilentSamsara8 小时前
爬虫工程化:Playwright + 反反爬 + 数据清洗管道实战
开发语言·爬虫·python·青少年编程·playwright
winlife_8 小时前
让 AI 写敌人状态机,并用脚本化场景验证状态转换正确:funplay-unity-mcp 实战
人工智能·unity·游戏引擎·ai编程·状态机·mcp
专注VB编程开发20年10 小时前
Python爬虫、提取网页内容,免费调用谷歌翻译接口
爬虫·python·信息可视化
Data 实验室10 小时前
TaskPyro爬虫管理平台 v2.3.4:脚本即接口,调度即编排
爬虫
战族狼魂10 小时前
Prompt新手第一课,从写好一句指令开始
prompt工程·大模型应用·ai提示词·零基础教程