JS逆向实战五:某海关公示平台分析(瑞数加密)

声明:本文仅供安全技术学习研究之用,请勿用于任何违法违规用途。爬取数据前请遵守相关法律法规及网站的 robots.txt 协议。


一、背景知识

1.1 什么是瑞数(Ruishu)

瑞数是国内主流的反爬防护产品之一,其核心特点是:

  • VMP(Virtual Machine Protection)虚拟化保护:将真实逻辑编译为私有字节码
  • 动态参数生成:每次请求附带动态变化的校验参数
  • 浏览器指纹检测:采集 Canvas、WebGL、字体等特征

1.2 什么是国密 SM4

SM4 是中国国家密码管理局发布的分组加密算法,特点:

  • 分组长度:128 位
  • 密钥长度:128 位
  • 加密模式:ECB、CBC、CTR 等
  • 标准 FK 常量:0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dd

二、技术架构分析

2.1 多层防护体系

层级 类型 表现 难度
反爬层1 瑞数6代 VMP 每次 AJAX 请求 URL 附带动态参数 cw2qEsfh ★★★★☆
反爬层2 滑块验证码 响应 errorCode=501 时弹出滑块 ★★☆☆☆
反爬层3 SM4-ECB 响应加密 所有 API 响应均为加密 Base64 字符串 ★★★☆☆
反爬层4 图片混淆字段 关键字段以 Base64 PNG 图片返回,需 OCR ★★☆☆☆
反爬层5 IP 频控封锁 高频访问触发 1-3 小时 IP 封锁 ★★☆☆☆

2.2 请求流程示意

复制代码
┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   页面加载   │ ──→ │  JS 初始化   │ ──→ │  动态参数   │
│             │     │  (VMP执行)  │     │   生成      │
└─────────────┘     └─────────────┘     └──────┬──────┘
                                               │
┌─────────────┐     ┌─────────────┐     ┌──────▼──────┐
│   数据展示   │ ←── │  SM4 解密   │ ←── │  API 请求   │
│             │     │  (前端执行)  │     │  (带Token)  │
└─────────────┘     └─────────────┘     └─────────────┘

三、VMP 防护原理

3.1 代码虚拟化技术

VMP 将原始 JavaScript 逻辑编译为自定义字节码:

javascript 复制代码
// 原始代码(开发者编写)
function calcToken() {
    return hash(fingerprint + timestamp);
}

// VMP 保护后(外部可见)
if($_ts.cd){(function(_$ca,_$kn){var _$Jy=_$ca(/* 字节码数据 */)...}

3.2 动态 Hook 机制

VMP 运行时会对原生 API 进行 Hook:

javascript 复制代码
// 示意:Hook XMLHttpRequest
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...args) {
    // 自动追加动态参数
    const dynamicToken = vmRuntime.generateToken();
    url = appendParam(url, 'token', dynamicToken);
    return originalOpen.call(this, method, url, ...args);
};

3.3 为什么难以逆向

难点 说明
字节码私有 指令集不公开,静态分析无法还原
运行时解密 关键逻辑在内存中动态解密执行
环境绑定 Token 与浏览器指纹强关联
频繁更新 脚本文件哈希和逻辑定期更换

四、SM4 加密分析

4.1 算法识别方法

通过查找标准常量确认算法身份:

javascript 复制代码
// SM4 FK 常量(国密标准)
const FK = [0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dd];

4.2 密钥提取思路

混淆代码常用的字符串数组保护:

字符串数组洗牌混淆的工作原理:

  1. 所有关键字符串放入一个数组
  2. 程序启动时对数组执行若干次 shift/unshift 操作
  3. 直到某个校验函数返回 true,数组位置稳定
  4. 之后所有字符串通过索引引用,静态看不到明文
javascript 复制代码
// 原始形式
var _0xabc = ['encrypt', 'decrypt', 'key123...'];

// 执行时动态重组
while (checksum !== targetValue) {
    _0xabc.push(_0xabc.shift());  // 数组轮转
}

// 稳定后通过索引引用
window['decryptFunc'] = _0xabc[0xb7];  // 获取解密函数名
window['encryptFunc'] = _0xabc[0xd1];  // 获取加密函数名

4.3 响应结构

加密响应通常为 Base64 编码的密文:

复制代码
原始响应:k/++tB6BUKnPxpG5ijoqgZHVsBc9JSk5ZCf7FaDVDLivFmOy80SE...
            ↓ Base64 解码
密文字节:[0xf5, 0x8a, 0x3f, 0xbe, ...]
            ↓ SM4-ECB 解密
明文 JSON:{"ok": true, "data": {...}}

五、方案演进历史(学习记录)

以下记录本人在学习过程中,从失败到理解原理的探索历程,供读者参考技术思路。

接口分析

目标网站:某企业进出口信用信息公示平台

目标接口
复制代码
POST http://credit.customs.gov.cn/ccppserver/ccpp/queryList?cw2qEsfh=<动态token>
Content-Type: application/json

{ "manaType": "C", "apanage": "", "depCodeChg": "", "curPage": 1, "pageSize": 20, "checkCode": ""}
baseUrl 构造方式

common.js 逆向得出:

javascript 复制代码
// webserver 页面 → ccppserver 接口
baseUrl = location.href.split('ccppwebserver')[0] + 'ccppserver/'

// 结果:http://credit.customs.gov.cn/ccppserver/
响应结构

响应体为 SM4-ECB 加密后的 Base64 字符串,解密后得到:

json 复制代码
{
  "ok": true,
  "data": {
    "totalCount": 1341,
    "totalPages": 1341,
    "curPage": 1,
    "copInfoList": [
      {
        "socialCreditCode": "b@se&64:<base64图片>",
        "fullName": "b@se&64:<base64图片>",
        "creditLvl": "C",
        "customsCodeName": "b@se&64:<base64图片>",
        "creditLvlModfTime": "b@se&64:<base64图片>"
      }
    ]
  }
}

注意totalPages 字段值实为总记录数(totalCount),实际总页数需自行计算:
实际总页数 = ceil(totalCount / pageSize)


5.1 方案一:纯 HTTP 请求(理解局限)

尝试思路

  • 直接构造 POST 请求,手动设置 headers 和 cookies
  • 期望通过静态参数获取数据

遇到的问题

  • ❌ 无法生成合法的动态 Token(VMP 实时计算)
  • ❌ 缺少服务端验证所需的 Cookie

学习收获

理解到 VMP 防护的核心在于"动态"和"环境绑定",静态参数无法复用。


5.2 方案二:自动化浏览器工具(被检测)

尝试思路

  • 使用 Playwright/Selenium 驱动 Chromium
  • 拦截页面响应,在 Python 端进行 SM4 解密
python 复制代码
# Playwright 方式(片段)
from playwright.async_api import async_playwright

async with async_playwright() as pw:
    browser = await pw.chromium.launch(headless=False)
    page = await browser.new_page()
    page.on('response', on_response)   # 拦截响应
    await page.goto(PAGE_URL, wait_until='networkidle')
    await page.evaluate('window.queryLostcredit()')

遇到的问题

  1. 被反爬检测 :瑞数检测到自动化工具特征
    • navigator.webdriver === true
    • CDP 注入留下的标记
  2. 解密失败 :Python 端 SM4 解密出现字节错误(首字节非预期值)
    • 可能原因:密钥与浏览器会话绑定
    • 可能原因:响应存在额外编码层
  3. IP 封锁:触发防护规则,临时限制访问

错误提示示例

复制代码
出错啦!该操作已触发系统访问防护规则,请于1-3小时后重试。

学习收获

  • 理解浏览器自动化检测的常见特征
  • 认识到客户端与服务端解密环境的差异
  • 了解 IP 频控策略的存在

5.3 方案三:更换自动化工具(部分进展)

尝试思路

  • 改用基于原生 CDP 协议的工具(如 DrissionPage)
  • 使用本地 Chrome,减少自动化特征注入
python 复制代码
from DrissionPage import ChromiumPage, ChromiumOptions

opts = ChromiumOptions()
opts.set_argument('--disable-blink-features=AutomationControlled')
page = ChromiumPage(addr_or_opts=opts)
page.listen.start('ccpp/queryList', method='POST')   # 监听接口
page.get(PAGE_URL)
page.run_js('window.queryLostcredit()')
packet = page.listen.wait(timeout=25)
body = packet.response.body   # 获取响应体

取得进展

  • ✅ 页面正常加载,绕过瑞数检测
  • ✅ 成功获取加密响应(Base64 格式)

仍有问题

  • ❌ Python 端 SM4 解密依然失败
  • 密文解密后首字节不符合预期,说明存在环境绑定

学习收获

  • 不同自动化工具的检测风险差异
  • 加密数据与执行环境的强关联性

SM4解密

用真实浏览器访问,接口正常返回,但 Body 是:

复制代码
k/++tB6BUKnPxpG5ijoqgZHVsBc9JSk5ZCf7FaDVDLivFmOy80SE...

75736 个字符的 Base64 字符串。不是 JSON,不是任何可读格式。

浏览器里的 JS 会解密这段数据,然后渲染到页面。分析其形成原因,该部分数据通过压缩到一行的 SM4 实现,带有字符串数组洗牌混淆。

模拟这段逻辑,用 Node.js 跑:

javascript 复制代码
// 模拟洗牌过程
var arr = [/* 原始字符串数组 */];
while (true) {
    var checksum = computeChecksum(arr);
    if (checksum === 0xa65f4) break;   // 目标校验值
    arr.push(arr.shift());             // 每次移动一个元素
}
// 共执行 122 次,数组稳定

稳定后,从数组中提取关键信息:

索引 内容
0xb7 解密函数名 MuData_KXC
0xd1 加密函数名 CaData_KXC
0x121 SM4 密钥:11HDESaAhiHHugDz
Python 端解密尝试(失败)
python 复制代码
from gmssl.sm4 import CryptSM4, SM4_DECRYPT
import base64
SM4_KEY = b'11HDESaAhiHHugDz'
def sm4_decrypt(raw: str) -> str:
    cipher_bytes = base64.b64decode(raw.strip())
    sm4 = CryptSM4()
    sm4.set_key(SM4_KEY, SM4_DECRYPT)
    plain = sm4.crypt_ecb(cipher_bytes)
    pad_len = plain[-1]
    if 1 <= pad_len <= 16:
        plain = plain[:-pad_len]
    return plain.decode('utf-8')   # ← 这里报错

运行报错:

复制代码
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf5 in position 0

解密后第一个字节是 0xf5,不是合法 JSON 的起始字符 {

说明 Python 拿到的响应体,和浏览器 JS 拿到的,不是同一份内容。可能存在额外编码层,或者密钥存在会话动态变化。


5.4 方案四:JS Hook 思路(理解原理)

核心思路转变

不再尝试"绕过"或"破解",而是:

"让浏览器自身完成加解密,通过合法方式观察结果"

原理示意图

复制代码
┌─────────────────────────────────────────┐
│            浏览器环境                    │
│  ┌─────────┐    ┌─────────┐            │
│  │  VMP    │───→│ 生成    │            │
│  │ 运行时  │    │ Token   │            │
│  └────┬────┘    └────┬────┘            │
│       │              │                  │
│       └──────────────┘                  │
│                      ↓                  │
│              ┌─────────────┐            │
│              │  API 请求   │            │
│              │ (自动带Token)│            │
│              └──────┬──────┘            │
│                     ↓                   │
│              ┌─────────────┐            │
│              │  SM4加密    │            │
│              │  响应数据   │            │
│              └──────┬──────┘            │
│                     ↓                   │
│  ┌─────────┐    ┌─────────────┐        │
│  │  Hook   │←───│ 原生解密    │        │
│  │ 注入点  │    │ 函数执行    │        │
│  └────┬────┘    └─────────────┘        │
│       ↓                                 │
│  ┌─────────────┐                       │
│  │ 记录明文    │ ← 学习观察点          │
│  │ (仅本地测试)│                       │
│  └─────────────┘                       │
└─────────────────────────────────────────┘
Hook前端注入解密
javascript 复制代码
// 在页面 JS 初始化完成后注入
var _orig = window['MuData_KXC'];
window['MuData_KXC'] = function() {
    var decrypted = _orig.apply(this, arguments);  // 原函数正常执行
    try {
        var parsed = JSON.parse(decrypted);
        if (parsed && parsed.hasOwnProperty('ok')) {
            window.__queryResult = parsed;   // 把结果存到全局变量
            window.__queryDone   = true;
        }
    } catch(e) {}
    return decrypted;   // 原样返回,页面渲染不受影响
};

Python 端等待 __queryDone 变为 true,然后读取 __queryResult

python 复制代码
# 等待 JS 解密完成
for _ in range(25):
    if page.run_js('return !!window.__queryDone'):
        break
    time.sleep(1)
# 读取已解密的数据
result_json = page.run_js('return JSON.stringify(window.__queryResult)')
result = json.loads(result_json)

不需要知道密钥,不需要理解 SM4,浏览器替你做了所有加解密工作。

技术理解

  • 浏览器环境已完成所有加解密工作
  • 解密函数存在于页面 JS 上下文中
  • 通过合法 Hook 技术,在数据渲染前观察结果

关键认知

  1. 不解密算法:复用浏览器已加载的 SM4 实现
  2. 不伪造 Token:让 VMP 自动生成合法参数
  3. 不绕过验证:在验证通过后的数据层观察

学习收获

  • 理解前端加密的边界
  • 掌握 JS Hook 技术的合法学习用途
  • 认识到"逆向"的本质是理解原理,而非破解利用

六、方案对比总结

维度 方案一 HTTP 方案二 Playwright 方案三 CDP工具 方案四 Hook
浏览器 有(带特征) 有(原生) 有(原生)
瑞数绕过 ❌ 被检测
解密位置 Python Python Python 浏览器
解密成功 ❌0xf5错误 ❌0xf5错误
核心问题 无Token 有特征+密钥错 密钥绑定 -

七、内容混淆识别

7.1 图片混淆原理

部分字段以 Base64 图片形式返回:

json 复制代码
{
    "fieldName": "b@se&64:iVBORw0KGgoAAAANSUhEUgAA..."
}

识别特征:

  • 前缀标识:b@se&64:
  • 编码格式:Base64
  • 图片类型:PNG

7.2 OCR 识别方案

python 复制代码
import ddddocr
import base64

def parse_image_field(value: str) -> str:
    """解析图片混淆字段"""
    if not value.startswith('b@se&64:'):
        return value

    img_bytes = base64.b64decode(value[8:])
    ocr = ddddocr.DdddOcr(show_ad=False)
    return ocr.classification(img_bytes)

总结

核心知识点

  1. VMP 防护:虚拟化技术保护核心逻辑,静态分析困难
  2. SM4 加密:国密算法,通过标准常量可识别
  3. 浏览器特征:自动化工具需关注指纹一致性
  4. Hook 技术:在合规前提下用于学习理解

学习历程的启示

阶段 认知 方法 结果
初期 以为参数固定 直接请求 ❌ 失败
中期 意识到动态生成 自动化工具 ❌ 被检测
后期 理解环境绑定 原生浏览器 ⚠️ 部分成功
最终 接受不可破解性 原理学习 ✅ 理解本质

重要认知:安全防护设计的目的是让攻击成本远高于收益。作为学习者,理解原理比"成功绕过"更有价值。

学习资源


其他参考学习文档

【JS逆向系列】某海关公示平台分析_loadaesdecryptstr-CSDN博客
实战13-瑞数6(补环境) - 导弹* - 博客园
瑞数实例小记------某海关失信企业网址小记_瑞数6 加密的网站-CSDN博客

相关推荐
Можно5 小时前
深入理解 ES6 Proxy:与 Object.defineProperty 的全面对比
前端·javascript·vue.js
天天向上10247 小时前
vue el-table实现拖拽排序
前端·javascript·vue.js
西西学代码7 小时前
Flutter---回调函数
开发语言·javascript·flutter
卷帘依旧7 小时前
JavaScript 闭包经典问题:为什么输出 10 次 i=10
javascript
柳杉8 小时前
Three.js × Blender:从建模到 Web 3D 的完整工作流深度解析
前端·javascript·数据可视化
用户806138166599 小时前
发布为一个 npm 包
前端·javascript
TT_哲哲10 小时前
小程序双模式(文件 / 照片)上传组件封装与解析
前端·javascript
从文处安10 小时前
「九九八十一难」从回调地狱到异步秩序:深入理解 JavaScript Promise
前端·javascript
进击的尘埃10 小时前
Node.js 子进程管理:child_process 模块的正确打开方式
javascript