目录
免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、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') 的输出确认无魔改。
常量确认:
- 签名盐值
r["a"]="B3978D054A72A7002063637CCDF6B2E5"(固定) - AES 密钥
r["e"]="EB444973714E4A40876CE66BE45D5930"(32字节,AES-256) - 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.parse 或 Base64.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.headers 或 session.cookies 上。要么通过 headers= 参数单独传,要么每个线程用独立的 session 实例。
五、总结
| 环节 | 要点 |
|---|---|
| 抓包 | POST 请求,请求体明文但带动态时间戳,响应 Data 字段为 Base64 密文 |
| 定位 | 搜索 portal-sign 直接定位到 axios 请求拦截器,解密函数就在旁边的响应拦截器里 |
| 签名 | 盐值 + 参数按 key 排序拼接 → MD5 → 小写。排序规则是 key 转大写后比较 |
| 解密 | AES-256 / CBC / PKCS7,密文为 Base64 格式(不是 Hex) |
| 常量 | 盐值、AES Key、IV 都在同一个 webpack 模块(a078)中硬编码 |
本案例的核心收获:
- 这是第一个涉及 请求签名 的案例------不是加密请求体,而是对参数做 MD5 摘要放在请求头里。服务端用同样的逻辑验证签名是否匹配。
- axios 拦截器是 Vue 项目中加密/签名的高频位置。搜到请求拦截器后,响应拦截器(解密)通常就在下面几行。
- 参数排序拼接时注意 JS 的
sort比较规则(转大写后比较),Python 端要用key=lambda x: x.upper()保持一致。 - 响应密文格式是 Base64(不是 Hex),因为 CryptoJS 的
decrypt接收字符串时默认按 Base64 解析。