国税接口加密报文分析:从前端 JavaScript 到 AES、SM2、SM3、SM4

张 张站长 · 2026-09-14 11:43 · 技术-逆向 · 44 次浏览 · 0 条评论

补充虚拟测试密钥、环境准备、AES/SM4 离线 Demo、运行命令和常见错误的逐步说明。

国税接口加密报文分析:从前端 JavaScript 到 AES、SM2、SM3、SM4

这篇文章记录我整理国税业务接口加密报文的过程。项目的重点不是“把密文直接丢给某个解密函数”,而是先判断请求和响应分别处于哪一层保护,再按照前端代码的执行顺序还原数据流。

我把整个过程拆成四部分:

  1. 从页面加载和前端 JavaScript 中确认加密入口。
  2. 区分业务报文加密、会话初始化和签名参数。
  3. 在本地用固定测试数据验证 AES、SM2、SM3、SM4 的实现。
  4. 将响应统一转换为 JSON,并保存脱敏后的调试结果。

本文示例针对已经获得授权的测试环境和本地样例数据。示例代码不会包含真实账号、Token、Cookie、私钥、目标地址、验证码处理或反爬绕过参数。

加密报文分析总览

一、先看项目结构

我把项目中与解密直接相关的文件分成三组:

国税解密decrypt/
├── runtime.js                 # 前端打包后的加密、解密入口
├── runtime1.js                # 本地调用 runtime 解密函数的测试脚本
├── decrypt.py                 # Python 调用 JavaScript 解密函数
├── curl_request.py            # 脱敏后的请求解析和 AES 结构测试
├── query_dkjsp.py             # 业务查询流程与多层密码学封装
├── SM3.py                     # 另一套前端登录封装的调试代码
├── chunk.js / apps.js         # 前端依赖和业务代码
└── jmbw解密/                  # Jmbw 报文的独立测试目录

项目里有两种明显不同的前端实现:

  • 一套前端把 datagram 交给打包后的 JavaScript 函数处理,Python 通过 execjs 调用;
  • 另一套业务查询流程在 Python 中复现了 AES、SM2、SM3、SM4 和摘要链,同时保留 JavaScript 计算盐值的部分。

因此,第一步不是猜算法,而是确认“哪个字段由哪个函数产生”。

二、如何确认加密入口

我先从页面脚本中找出导出的函数。项目里的 runtime.js 最后暴露了一个解密入口,调用方只需要传入会话阶段生成的短期密钥和响应中的 datagram:

function get_decrypt(sessionKey, datagram) {
    const fixedPart = buildFixedPart();
    const decryptKey = sessionKey.substring(0, 8) + fixedPart;
    return decryptPayload(datagram, deriveKey(decryptKey));
}

module.exports = {
    get_decrypt
};

这里的函数名和常量已经抽象化。真正分析时,我关注的是调用关系:

响应 datagram
      ↓
get_decrypt(sessionKey, datagram)
      ↓
拼接派生材料
      ↓
对称解密
      ↓
JSON 字符串

如果只在 Python 中搜索 AES.new,很容易漏掉浏览器端实际完成的关键步骤。我的做法是先定位:

grep -nE "get_decrypt|get_data|get_secret|encryptCode|datagram" runtime.js

然后继续向上追踪 loader(...) 返回的模块,直到看到真正的加密、解密或摘要函数。打包代码很长时,我会优先记录以下信息:

输入字段:datagram、sessionKey、timestamp
输出字段:明文 JSON、signature、加密 datagram
编码方式:Hex、Base64、UTF-8
分组方式:ECB、CBC、SM2 C1C3C2 等
填充方式:PKCS#7 或协议自定义前缀

三、先区分编码、摘要、签名和加密

这几个概念在抓包数据里经常混在一起。

1. Base64 和 Hex 只是编码

Base64 和 Hex 都不提供保密性,只是把二进制数据变成便于传输的文本。

import base64

raw = b"local-test-payload"

base64_text = base64.b64encode(raw).decode("ascii")
hex_text = raw.hex()

assert base64.b64decode(base64_text) == raw
assert bytes.fromhex(hex_text) == raw

判断方式很简单:

  • Base64 通常包含字母、数字、+、/ 和末尾的 =;
  • Hex 通常只包含 0-9a-f;
  • 解码后如果仍然是 JSON,说明外面可能只有编码;
  • 解码后是不可读的定长二进制,才需要继续判断是否为密文。

2. MD5、SHA-1、SM3 是摘要

摘要算法把任意长度输入映射为固定长度输出,不能当作“可逆加密”。

import hashlib


def digest_chain(text: str) -> dict:
    raw = text.encode("utf-8")
    return {
        "md5": hashlib.md5(raw).hexdigest(),
        "sha1": hashlib.sha1(raw).hexdigest(),
    }

项目里的签名链会把时间戳、路径、前置摘要和一段页面初始化材料拼在一起,再经过多次摘要。分析时我不把每一层都叫“加密”,而是明确记录:

原始字符串
  -> MD5
  -> SM3
  -> SHA-1
  -> 再次组合
  -> 作为后续对称加密或签名的输入

3. AES、SM4、SM2 才是加密算法

AES 和 SM4 是对称加密,需要同一把密钥进行加密和解密;SM2 是公钥密码体系,常用于密钥交换、签名或小数据加密。

密码学分层

四、Jmbw 报文的 AES 结构

项目中有一类业务报文使用 AES 包装。它的结构可以概括为:

随机前缀 + JSON 文本
          ↓
PKCS#7 填充
          ↓
AES 对称加密
          ↓
Base64 或 Hex 表示

随机前缀的作用是让相同业务数据每次产生不同的密文。它不是密钥,也不是安全认证机制。解密以后先去掉填充,再去掉固定长度的前缀,剩余内容才是 JSON。

下面是一个完全离线的 AES 示例,使用测试密钥和测试明文:

import base64
import json
import secrets
import string

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad


def random_prefix(length=32):
    alphabet = string.ascii_letters + string.digits
    return "".join(secrets.choice(alphabet) for _ in range(length))


def encrypt_demo(payload: dict, key_hex: str) -> str:
    key = bytes.fromhex(key_hex)
    prefix = random_prefix()
    plain = prefix + json.dumps(
        payload, ensure_ascii=False, separators=(",", ":")
    )
    encrypted = AES.new(key, AES.MODE_ECB).encrypt(
        pad(plain.encode("utf-8"), AES.block_size)
    )
    return base64.b64encode(encrypted).decode("ascii")


def decrypt_demo(ciphertext: str, key_hex: str) -> dict:
    key = bytes.fromhex(key_hex)
    encrypted = base64.b64decode(ciphertext)
    plain = AES.new(key, AES.MODE_ECB).decrypt(encrypted)
    plain = unpad(plain, AES.block_size).decode("utf-8")
    payload = plain[32:]
    return json.loads(payload)


TEST_KEY = "00112233445566778899aabbccddeeff"
message = {"name": "local-test", "page": 1}
encoded = encrypt_demo(message, TEST_KEY)
assert decrypt_demo(encoded, TEST_KEY) == message

这里使用 ECB 只是为了演示项目中已经存在的报文结构。新系统设计不应该默认选择 ECB,因为相同明文块会产生相同密文块,缺少现代协议常用的随机 nonce 和认证标签。

五、如何推导业务报文密钥

项目中的 AES 密钥不是直接写成一把固定 AES key,而是由会话材料和请求路径经过字符串处理、摘要得到。抽象后可以表示为:

import hashlib


def derive_demo_key(session_material: str, raw_path: str) -> bytes:
    seed = f"{session_material[:20]}+{raw_path}?"
    return hashlib.md5(seed.encode("utf-8")).hexdigest().encode("ascii")

真正调试时,我会把每一步拆出来打印长度和摘要,不打印原始 Token:

def debug_key_derivation(session_material, raw_path):
    key = derive_demo_key(session_material, raw_path)
    print("session_material length:", len(session_material))
    print("raw_path:", raw_path)
    print("derived key length:", len(key))
    print("derived key preview:", key[:6].decode("ascii") + "...")
    return key

路径必须保持一致。以下内容任何一个发生变化,都可能导致最终密钥不同:

  • 是否包含开头的 /;
  • 是否包含查询字符串;
  • 查询参数的顺序;
  • 时间戳是否参与派生;
  • URL 是否经过编码;
  • 使用的是响应路径还是请求路径。

所以我在本地测试时会把 raw_path 固定为测试值,并为每一个样例保存“输入、派生 key 长度、密文编码、明文 JSON”四项记录。

六、SM2 响应解密

另一类响应是椭圆曲线公钥体系产生的密文。项目中通过密文前缀判断是否进入 SM2 分支,之后把 Hex 文本转换为二进制,再按库支持的密文布局解密。

抽象流程如下:

响应 data
   ↓
判断是否为 SM2 密文格式
   ↓
Hex 解码
   ↓
按协议布局解析 C1、C3、C2
   ↓
使用受保护的私钥解密
   ↓
UTF-8 文本

SM2 常见的密文布局有 C1C3C2 和 C1C2C3。顺序不同,直接调用同一个解密函数会得到错误结果。排查时我会先确认:

  1. 密文是否带未参与计算的格式前缀;
  2. 使用的库默认是哪种布局;
  3. 明文是否有额外编码;
  4. 私钥是否来自当前会话;
  5. 解密后是否还要做 JSON 解析。

下面只展示接口层的抽象写法,不放真实私钥:

from gmssl.sm2 import CryptSM2


def decrypt_sm2_demo(cipher_hex: str, private_key_hex: str) -> str:
    if cipher_hex.startswith("04"):
        cipher_hex = cipher_hex[2:]

    crypt = CryptSM2(
        public_key="",
        private_key=private_key_hex
    )
    crypt.mode = 1  # 由项目协议确认对应的密文布局
    plaintext = crypt.decrypt(bytes.fromhex(cipher_hex))
    return plaintext.decode("utf-8")

私钥不能写入代码仓库、文章、日志或截图。实际系统中应使用密钥管理服务或进程级安全存储,并在解密完成后尽量缩短明文和私钥的生命周期。

七、SM4 在会话初始化中的位置

项目的会话初始化阶段使用 SM4 保护一段短期会话数据。前端 JavaScript 先生成临时材料,再结合服务端返回的公钥或会话随机数构造后续参数。

这部分的关键不是把某个固定字符串当成永久密钥,而是确认每个阶段的输入输出:

阶段 A:获取初始化材料
阶段 B:生成临时会话密钥
阶段 C:用公钥保护临时密钥
阶段 D:使用 SM4 保护下一阶段数据
阶段 E:服务端返回新的会话上下文

本地只做算法自测时,可以使用随机 16 字节密钥验证 SM4 的基本能力:

from gmssl.sm4 import CryptSM4, SM4_ENCRYPT, SM4_DECRYPT


def sm4_roundtrip_demo():
    key = bytes.fromhex("00112233445566778899aabbccddeeff")
    plaintext = b"local-sm4-test!!"

    enc = CryptSM4()
    enc.set_key(key, SM4_ENCRYPT)
    ciphertext = enc.crypt_ecb(plaintext)

    dec = CryptSM4()
    dec.set_key(key, SM4_DECRYPT)
    recovered = dec.crypt_ecb(ciphertext)

    assert recovered == plaintext
    return ciphertext.hex()

如果测试失败,我先检查填充方式和库的加解密模式,再检查密钥长度。SM4 的密钥固定为 128 bit,业务协议是否额外做填充则要以实际前端实现为准。

八、签名链的复现方法

项目中还存在一个签名器。它不是简单的 md5(body),而是把路径、时间戳、浏览器环境摘要、页面初始化材料和报文摘要组合在一起。

可以用下面的安全抽象表示:

import hashlib


def md5_hex(value: str) -> str:
    return hashlib.md5(value.encode("utf-8")).hexdigest()


def sha1_hex(value: str) -> str:
    return hashlib.sha1(value.encode("utf-8")).hexdigest()


def sm3_hex(value: str) -> str:
    from gmssl import sm3, func
    return sm3.sm3_hash(func.bytes_to_list(value.encode("utf-8")))


def build_signature_material(
    timestamp: str,
    fingerprint_hash: str,
    raw_path: str,
    body_json: str,
    salt: str,
) -> str:
    base = f"{timestamp}-{fingerprint_hash}-{raw_path}"
    first = md5_hex(sm3_hex(md5_hex(base)))
    chain = f"{fingerprint_hash}{raw_path}&check={first}{timestamp}{salt}"
    second = sm3_hex(md5_hex(sha1_hex(chain)))

    body_hash = sm3_hex(md5_hex(body_json)) if body_json else second
    return md5_hex(sha1_hex(f"{first}.{body_hash}.{timestamp}{salt}"))

这段代码用于说明如何拆分摘要链,不包含平台请求头、Cookie 或真实签名参数。调试时我会对每一层使用固定的本地输入,并把中间值写入单元测试的期望结果:

def test_signature_is_stable():
    result = build_signature_material(
        timestamp="20260101000000",
        fingerprint_hash="test-fingerprint",
        raw_path="/local/test",
        body_json='{"page":1}',
        salt="test-salt",
    )
    assert len(result) == 40

这里的测试目标是“相同输入得到相同输出”,不是把测试值拿去访问线上服务。

九、前端 JavaScript 与 Python 的协作

当加密实现藏在打包 JavaScript 里时,我没有直接把整份前端代码翻译成 Python,而是先保留 JavaScript 作为参考实现,再用 execjs 调用最小函数。

import execjs
from pathlib import Path


def load_runtime():
    source = Path("runtime.js").read_text(encoding="utf-8")
    return execjs.compile(source)


def decrypt_with_runtime(runtime, session_key, datagram):
    result = runtime.call("get_decrypt", session_key, datagram)
    if not isinstance(result, str):
        raise TypeError("解密函数返回值不是字符串")
    return result

这样做可以避免两个实现同时变化。等本地测试样例全部通过以后,再考虑把稳定的纯算法部分迁移到 Python。

使用 JavaScript 运行时有几个注意点:

  • Node.js 版本要与本地测试环境一致;
  • 打包代码依赖的全局对象要提前补齐;
  • 不要把生产 Token 写入 runtime1.js;
  • 测试密文使用单独生成的样例;
  • 每次升级前端脚本后重新跑固定测试向量。

十、统一的解密调度器

我最后把不同格式的响应统一成一个调度器。它只接受已经经过授权的本地响应样本和显式传入的密钥材料:

import json


def decode_response(data, context):
    if not isinstance(data, dict):
        raise ValueError("响应不是 JSON 对象")

    encrypted = data.get("data") or data.get("datagram")
    if not encrypted:
        return data

    mode = data.get("encryptCode")
    if mode == "aes":
        plain = decrypt_demo(encrypted, context["aes_key"])
    elif mode == "sm2":
        plain = decrypt_sm2_demo(
            encrypted,
            private_key_hex=context["private_key"],
        )
    elif mode == "runtime":
        plain = decrypt_with_runtime(
            context["runtime"],
            context["session_key"],
            encrypted,
        )
    else:
        raise ValueError(f"未知加密模式:{mode}")

    return json.loads(plain)

实际代码中我会把 decrypt_demo、decrypt_sm2_demo 和 JavaScript 调用分别测试,不让调度器承担算法细节。这样遇到解密失败时,可以很快判断是分支判断、编码方式、密钥派生还是 JSON 解析出了问题。

十一、错误排查清单

1. AES 解密报数据长度错误

优先检查密文编码。Base64 解码和 Hex 解码不能混用;AES 分组密文长度必须是 16 字节的整数倍。

2. AES 解密后出现乱码

检查密钥是否为 ASCII Hex 字符串,还是已经转换成二进制;再检查 UTF-8 解码和 PKCS#7 去填充。

3. 解密后前面有一段不可读字符

有些业务报文会在 JSON 前放固定长度或随机前缀。应根据本地样例确认前缀长度,不能见到乱码就随意截断。

4. SM2 解密失败

检查 Hex 前缀、C1C3C2/C1C2C3 顺序、私钥长度和曲线参数。错误的密文布局会导致看似“密钥不对”的报错。

5. 同一份数据每次签名不同

检查时间戳、随机数、页面初始化材料和报文 JSON 序列化顺序。签名链只要有一项输入变化,结果就会变化。

6. 浏览器能打开,脚本不能复现

这通常不等于“少了一个请求头”。应把浏览器流程拆成页面初始化、会话建立、业务请求和响应解析四段,逐段确认状态。遇到访问控制或人工验证时停止脚本,使用正常浏览器流程完成验证。

十二、日志和样例数据如何脱敏

我在调试日志中只保留下面这些信息:

def safe_preview(value, limit=12):
    if not value:
        return ""
    value = str(value)
    return value[:limit] + "..."


def log_context(context):
    print("algorithm:", context.get("algorithm"))
    print("cipher length:", len(context.get("ciphertext", "")))
    print("key preview:", safe_preview(context.get("key")))

不记录以下内容:

  • 完整 Cookie;
  • 完整 access token;
  • 完整 Authorization;
  • 私钥和会话密钥;
  • 企业税号、账号手机号和个人身份信息;
  • 能直接恢复线上会话的完整请求。

保存样例时,我会把业务字段替换成虚构值,把密文重新用测试密钥生成,并在文件名中标明 sample 或 fixture,防止误把线上数据提交到代码仓库。

十三、一个完整的本地验证顺序

我本地验证时按下面顺序执行:

  1. 先确认 Python、Node.js 和密码学依赖可以导入。
  2. 用固定测试向量验证 Base64、Hex、MD5、SHA-1、SM3。
  3. 用随机生成的本地密钥验证 AES 和 SM4 往返。
  4. 用测试密钥对验证 SM2 加解密。
  5. 再调用 runtime.js 的最小导出函数。
  6. 最后测试统一调度器和 JSON 解析。
python3 -m py_compile decrypt.py curl_request.py query_dkjsp.py SM3.py
node runtime1.js

如果测试只依赖本地样例,就不需要访问线上服务。线上联调必须使用正式授权账号和官方允许的访问方式,且不把响应中的敏感字段写进日志。

总结

这个项目的解密过程可以归纳为三条线:

前端运行时:JavaScript 导出函数负责会话阶段数据处理
业务报文:AES 加密,外层可能使用 Base64 或 Hex
安全上下文:SM2、SM3、SM4、SHA-1、MD5 共同参与会话和签名链

真正还原协议时,我先确定字段流向,再确认编码、填充、分组模式和密钥来源,最后用固定样例做本地验证。只要把“编码”和“加密”、“摘要”和“签名”、“会话材料”和“业务数据”分开,整个过程就能按层定位。

文章里的代码全部用于本地算法测试和协议结构说明。真实密钥、Token、Cookie、账号信息以及平台访问控制参数不进入公开文章。

十四、小白从零开始:运行离线密码学 Demo

这篇文章能完整复现的是算法层,不是线上登录和业务接口。第一次操作时,先只验证 AES 和 SM4 的本地往返。

进入项目目录:

cd "/Users/liuweifeng/bnballobj/web_nixiang/国税解密decrypt"
python3 --version
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install pycryptodome gmssl
python3 beginner_crypto_demo.py

Windows PowerShell 激活虚拟环境的命令是:

.\.venv\Scripts\Activate.ps1

正常输出会类似这样:

AES Base64 密文: <一段本地生成的密文>
SM4 Hex 密文: <一段本地生成的密文>
离线往返校验通过

每次 AES 密文可能不同,是因为这个 Demo 使用了固定明文但加密结果本身经过重新编码;真正重要的是解密后仍然等于原始 JSON。SM4 Demo 使用固定测试密钥和固定 16 字节明文,输出只用于确认加解密方向正确。

如果安装依赖失败,先确认当前命令使用的是虚拟环境中的 Python:

which python3
python3 -m pip show pycryptodome
python3 -m pip show gmssl

如果 AES 报 Data must be aligned,通常是没有做 PKCS#7 填充,或者把 Base64 文本误当成密文字节;如果 SM4 报长度错误,先确认明文长度是分组长度的整数倍。

十五、把 Demo 和原项目对应起来

我会按下面的顺序阅读原项目,不直接运行带有线上配置的入口:

beginner_crypto_demo.py  →  AES/SM4 基本往返
decrypt.py               →  Python 调用 JavaScript 运行时
runtime.js               →  前端加密函数和导出入口
curl_request.py          →  报文编码、填充和 JSON 解析
query_dkjsp.py           →  多层摘要和业务流程
SM3.py                   →  另一套前端会话封装

本地排查时只检查四件事:

  1. 输入是 Base64 还是 Hex。
  2. 解码后长度是否满足分组要求。
  3. 是否存在随机前缀或 PKCS#7 填充。
  4. 解密结果是否能被 UTF-8 和 JSON 正常解析。

线上部分不能使用文章中的测试密钥直接复现。真实业务还依赖实时会话、服务端下发材料和权限范围,必须走正常登录和平台授权流程。文章中的 Demo 证明的是“算法实现可以正确往返”,不是“可以脱离授权访问线上数据”。

评论 0

还没有评论
← 全部文章

找站长

站长微信二维码 扫码加微信
售后群二维码 扫码进售后群
站长收款码 建站不易,感谢打赏
微信 18581469390
本站不支持自助充值;需要充值或开通额度,请直接联系站长。
加微信可以找我重置密码、充值、问工具怎么用