国家医保局 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时间还会大幅度缩减)结果如下

相关推荐
Caco_D1 天前
一行代码抓遍全网 20 个热榜!Aneiang.Pa 4.0 发布 — 极简 .NET 爬虫库
爬虫·.net
ServBay2 天前
Laravel Herd MCP 的替代,多语言与跨平台的 AI 本地开发选择
后端·ai编程·mcp
码哥字节2 天前
我把整个代码库喂给 Claude Code,工具超 50 个就静默丢失,这个坑太阴了
mcp·claude code·ai编程工具
ServBay6 天前
打通 AI 编程本地运维边界,利用 MCP 协议简化环境与服务管理
后端·ai编程·mcp
太岁又沐风6 天前
复现并修掉ART hook框架 Pine 调用原方法时的偶发 SIGSEGV
爬虫
隔窗听雨眠7 天前
大模型加爬虫上篇:技术融合与架构革新
爬虫·架构
Super Scraper7 天前
如何批量抓取 TikTok 数据而不被封锁?完整指南
爬虫·ai·自动化·抖音·tiktok·ai agent
深蓝电商API7 天前
自动化录屏 + 截图:打造爬虫调试的上帝视角
爬虫
tang777897 天前
市场调研自动化采集架构:基于住宅IP轮换的APP数据抓取与反风控方案
爬虫·动态代理ip·爬虫代理ip·爬虫动态ip·住宅代理ip·动态住宅ip