福建公共资源交易平台 —— MD5 签名 + AES 响应解密

目录

免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、robots 协议、用户协议以及获得合法授权的前提下进行学习和实验。请勿将本文中的方法、脚本或思路用于未授权访问、批量采集、账号撞库、绕过风控、破坏验证码体系、规避平台限制、侵犯数据权益、商业化滥用或影响线上系统稳定性的行为。对于真实网站案例,读者不应直接复制代码对线上服务进行高频请求或非授权调用。若相关网站、产品方、权利方或平台认为本文内容存在不适宜公开展示之处,可通过评论区、私信或作者主页提供的联系方式联系我;核实后将及时删除、替换或调整相关内容。读者因不当使用本文内容造成的任何法律责任、业务风险或经济损失,均由使用者自行承担,与作者无关。

一、分析

目标地址:https://ggzyfw.fujian.gov.cn/index/newList?type=12

抓取通知公告的标题和发布时间。

F12 打开 DevTools,切到 Network → Fetch/XHR,翻页捕获数据接口:

text 复制代码
POST https://ggzyfw.fujian.gov.cn/FwPortalApi/Article/PageList

观察请求体

json 复制代码
{"pageNo":2,"pageSize":10,"total":896,"type":"12","timeType":"0","ts":1778878239848}
{"pageNo":4,"pageSize":10,"total":896,"type":"12","timeType":"0","ts":1778884716697}
{"pageNo":5,"pageSize":10,"total":896,"type":"12","timeType":"0","ts":1778884717559}

多次翻页对比,pageNo 是页码,ts 是毫秒级时间戳每次都变。

观察响应Data 字段是一大段密文(Base64 格式)。如下:

json 复制代码
{
    "State": "200",
    "Success": true,
    "Data": "MZphJmFlelDpw2aSCfdFbxDDVxNfNhzjYsseMqcm8f4btjXSpZOqfRHmDLBtjuuR+Mf.....zRq8BUsp.......",
    "Msg": "操作成功"
}

观察请求头 :有一个自定义头 portal-sign,每次请求值都不同(32 位十六进制,看着像 MD5)。

定位Ctrl+Shift+F 全局搜索 portal-sign,直接定位到 app.xxx.js 中的 axios 请求拦截器:

javascript 复制代码
t.headers["portal-sign"] = f.getSign(e)

顺便往下看几行,发现响应拦截器里有解密逻辑:

javascript 复制代码
m.interceptors.response.use(function(t) {
    var e = t.data;
    return "200" === e.State ? JSON.parse(b(e.Data)) : ...
})

加密和解密在同一个文件的相邻位置------这是 axios 拦截器的典型结构,找到一个另一个就在旁边。

签名逻辑分析(getSign 函数):

javascript 复制代码
function d(t) {
    // 删除空值和 undefined 的属性
    for (var e in t)
        "" !== t[e] && void 0 !== t[e] || delete t[e];
    // 盐值 + 参数排序拼接 → MD5 → 转小写
    var n = r["a"] + u(t);
    return s(n).toLocaleLowerCase()
}

其中 u(t) 是参数排序拼接函数:

javascript 复制代码
function u(t) {
    // key 按字母升序排列(大写比较)
    for (var e = Object.keys(t).sort(l), n = "", a = 0; a < e.length; a++)
        if (void 0 !== t[e[a]])
            if (t[e[a]] instanceof Object || t[e[a]] instanceof Array)
                n += e[a] + JSON.stringify(t[e[a]]);
            else
                n += e[a] + t[e[a]];
    return n
}

断点验证拼接结果:B3978D054A72A7002063637CCDF6B2E5pageNo1pageSize10timeType0total896ts1778879269088type12

s 函数进去看是标准 MD5,验证 s('1') 的输出确认无魔改。

常量确认

  1. 签名盐值 r["a"] = "B3978D054A72A7002063637CCDF6B2E5"(固定)
  2. AES 密钥 r["e"] = "EB444973714E4A40876CE66BE45D5930"(32字节,AES-256)
  3. AES IV r["i"] = "B5A8904209931867"(16字节)

解密函数

javascript 复制代码
function b(t) {
    var e = CryptoJS.enc.Utf8.parse(r["e"]),
        n = CryptoJS.enc.Utf8.parse(r["i"]),
        a = CryptoJS.AES.decrypt(t, e, {
            iv: n, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7
        });
    return a.toString(CryptoJS.enc.Utf8)
}

注意这里密文直接传入 AES.decrypt,不需要 Hex.parseBase64.parse------CryptoJS 的 decrypt 方法接收字符串时默认按 Base64 解析。

二、JS 复现与验证

javascript 复制代码
// ggzyfw_fujian.js
let CryptoJS = require('../../CryptoJS')

let signSecret = "B3978D054A72A7002063637CCDF6B2E5"
let key = 'EB444973714E4A40876CE66BE45D5930'
let iv = 'B5A8904209931867'

function l(t, e) {
    return t.toString().toUpperCase() > e.toString().toUpperCase() ? 1
         : t.toString().toUpperCase() == e.toString().toUpperCase() ? 0 : -1
}

function u(t) {
    for (var e = Object.keys(t).sort(l), n = "", a = 0; a < e.length; a++)
        if (void 0 !== t[e[a]])
            if (t[e[a]] && t[e[a]] instanceof Object || t[e[a]] instanceof Array)
                n += e[a] + JSON.stringify(t[e[a]]);
            else
                n += e[a] + t[e[a]];
    return n
}

function getSign(t) {
    for (var e in t)
        "" !== t[e] && void 0 !== t[e] || delete t[e];
    var n = signSecret + u(t);
    return CryptoJS.MD5(n).toString().toLocaleLowerCase()
}

function decrypt(t) {
    let e = CryptoJS.enc.Utf8.parse(key)
    let n = CryptoJS.enc.Utf8.parse(iv)
    let a = CryptoJS.AES.decrypt(t, e, {
        iv: n, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7
    });
    return a.toString(CryptoJS.enc.Utf8)
}

签名结果与浏览器一致,解密输出正确的 JSON 数据。

三、Python 实现

文件名:ggzyfw_fujian_spider.py

python 复制代码
import hashlib
import json
import time
import base64
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad


class GgzyfwSpider:
    """福建公共资源交易平台爬虫"""

    API_URL = 'https://ggzyfw.fujian.gov.cn/FwPortalApi/Article/PageList'
    SIGN_SECRET = 'B3978D054A72A7002063637CCDF6B2E5'
    AES_KEY = b'EB444973714E4A40876CE66BE45D5930'
    AES_IV = b'B5A8904209931867'

    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'Content-Type': 'application/json;charset=UTF-8',
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
                          '(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36',
        })

    def _get_sign(self, params: dict) -> str:
        """生成 portal-sign:盐值 + 参数排序拼接 → MD5 → 小写"""
        # 过滤空值和 None
        filtered = {k: v for k, v in params.items() if v != "" and v is not None}
        # key 按字母升序排列(大写比较,与 JS 的 sort 逻辑一致)
        sorted_keys = sorted(filtered.keys(), key=lambda x: x.upper())
        # 拼接 key+value
        concat = ""
        for k in sorted_keys:
            v = filtered[k]
            if isinstance(v, (dict, list)):
                concat += k + json.dumps(v, separators=(',', ':'))
            else:
                concat += k + str(v)
        raw = self.SIGN_SECRET + concat
        return hashlib.md5(raw.encode('utf-8')).hexdigest().lower()

    def _decrypt(self, ciphertext: str) -> str:
        """AES/CBC 解密响应数据(密文为 Base64 格式)"""
        cipher = AES.new(self.AES_KEY, AES.MODE_CBC, self.AES_IV)
        ct_bytes = base64.b64decode(ciphertext)
        pt = unpad(cipher.decrypt(ct_bytes), AES.block_size)
        return pt.decode('utf-8')

    def fetch_page(self, page: int) -> list:
        """请求单页数据"""
        ts = int(time.time() * 1000)
        data = {
            "pageNo": page,
            "pageSize": 10,
            "total": 896,
            "type": "12",
            "timeType": "0",
            "ts": ts
        }
        # 生成签名(签名参数包含 ts)
        sign = self._get_sign(data)
        # 注意:不能写 self.session.headers['portal-sign'] = sign
        # 多线程下共享 session.headers 会互相覆盖,导致签名与参数不匹配
        # 必须在每次请求中单独传 headers
        resp = self.session.post(
            self.API_URL,
            data=json.dumps(data, separators=(',', ':')),
            headers={'portal-sign': sign}
        )
        enc_data = resp.json().get('Data', '')
        if not enc_data:
            return []

        decrypted = json.loads(self._decrypt(enc_data))
        return [
            {
                '标题': item.get('TITLE'),
                '发布时间': item.get('TM'),
            }
            for item in decrypted.get('Table', [])
        ]

    def run(self, pages=3, max_workers=3):
        """并发采集多页"""
        all_data = []
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {executor.submit(self.fetch_page, p): p for p in range(1, pages + 1)}
            for future in as_completed(futures):
                page = futures[future]
                try:
                    result = future.result()
                    all_data.extend(result)
                    print(f'[Page {page}] 获取 {len(result)} 条公告')
                except Exception as e:
                    print(f'[Page {page}] 请求失败: {e}')
        return all_data


if __name__ == '__main__':
    spider = GgzyfwSpider()
    data = spider.run(pages=3)
    for item in data:
        print(item)

四、踩坑记录

坑:多线程下共享 session.headers 导致签名错乱

python 复制代码
# 错误写法:把动态签名写到共享的 session headers 上
self.session.headers['portal-sign'] = sign
resp = self.session.post(url, data=payload)
# 多线程时,线程A算好签名写进去,线程B立刻覆盖了
# 线程A带着线程B的签名发请求 → 签名与参数不匹配 → 服务端返回空数据

# 正确写法:每次请求单独传 headers
resp = self.session.post(url, data=payload, headers={'portal-sign': sign})
# requests 会把这个头合并到 session 默认头上,但只对本次请求生效

现象:并发 3 页,只有最后一个线程的请求成功(因为它的签名没被覆盖),其他页返回空数据。

规律 :凡是每次请求都不同的值(签名、动态 token、一次性 nonce),都不能放在共享的 session.headerssession.cookies 上。要么通过 headers= 参数单独传,要么每个线程用独立的 session 实例。

五、总结

环节 要点
抓包 POST 请求,请求体明文但带动态时间戳,响应 Data 字段为 Base64 密文
定位 搜索 portal-sign 直接定位到 axios 请求拦截器,解密函数就在旁边的响应拦截器里
签名 盐值 + 参数按 key 排序拼接 → MD5 → 小写。排序规则是 key 转大写后比较
解密 AES-256 / CBC / PKCS7,密文为 Base64 格式(不是 Hex)
常量 盐值、AES Key、IV 都在同一个 webpack 模块(a078)中硬编码

本案例的核心收获

  1. 这是第一个涉及 请求签名 的案例------不是加密请求体,而是对参数做 MD5 摘要放在请求头里。服务端用同样的逻辑验证签名是否匹配。
  2. axios 拦截器是 Vue 项目中加密/签名的高频位置。搜到请求拦截器后,响应拦截器(解密)通常就在下面几行。
  3. 参数排序拼接时注意 JS 的 sort 比较规则(转大写后比较),Python 端要用 key=lambda x: x.upper() 保持一致。
  4. 响应密文格式是 Base64(不是 Hex),因为 CryptoJS 的 decrypt 接收字符串时默认按 Base64 解析。
相关推荐
Amo Xiang21 小时前
JS 逆向系统进阶路线:专栏总纲与文章导航
javascript·js逆向·前端加密·爬虫逆向·反爬虫
Amo Xiang1 天前
新华社客户端 —— 3DES 双向加解密
js逆向·python爬虫·cryptojs·3des·前端加密
10WTW011 天前
QQ本地缓存机制初步探寻
缓存·视频·md5
冰履踏青云1 天前
十年饮冰,热血难凉 | JS逆向为爱发电
js逆向
嫂子的姐夫2 天前
047-MD5:飞卢网
爬虫·python·js逆向·逆向
如烟花的信页2 天前
某管理服务平台点选逆向分析
javascript·爬虫·python·js逆向
winfredzhang3 天前
Python 实战:用 wxPython 写一个 MD5 文件查重清理工具
python·sqlite·json·wxpython·md5·预览·查重
如烟花的信页5 天前
易盾点选逆向分析
javascript·爬虫·python·js逆向
如烟花的信页6 天前
易盾滑块逆向分析
javascript·爬虫·python·js逆向