目录
- 一、分析
- 二、JS 复现与验证
- 三、Python 实现
- 四、总结
免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、robots 协议、用户协议以及获得合法授权的前提下进行学习和实验。请勿将本文中的方法、脚本或思路用于未授权访问、批量采集、账号撞库、绕过风控、破坏验证码体系、规避平台限制、侵犯数据权益、商业化滥用或影响线上系统稳定性的行为。对于真实网站案例,读者不应直接复制代码对线上服务进行高频请求或非授权调用。若相关网站、产品方、权利方或平台认为本文内容存在不适宜公开展示之处,可通过评论区、私信或作者主页提供的联系方式联系我;核实后将及时删除、替换或调整相关内容。读者因不当使用本文内容造成的任何法律责任、业务风险或经济损失,均由使用者自行承担,与作者无关。
一、分析
目标地址:http://www.qingdao.gov.cn/mailboxpage-qd/city/index.html
抓取信箱列表数据,提取标题、回复单位、类型、回复时间。
F12 抓包找到数据接口:
POST http://www.qingdao.gov.cn/mailboxpage-qd/mailboxBase/getMailListPage
请求体大部分字段是固定的,但有一个 encrypted 字段是动态生成的密文。响应体的 data 字段也是密文(Base64 格式)。
定位 encrypted 生成逻辑:搜索 encrypted,定位到 index.js:
mailListDTO.encrypted = getEncrypted();
注意:mailListDTO.encrypted 在第一页时是空的,翻页时才有值(保留了上一次的值)。每次翻页会重新调用 getEncrypted() 覆盖。
进入 getEncrypted 函数:
function getEncrypted() {
while (!sun) {} // 等待密钥初始化完成
const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);
return encrypt.encrypt(String(new Date().getTime())); // RSA 加密当前时间戳
}
这是 RSA 非对称加密——用公钥加密当前毫秒时间戳。JSEncrypt 是一个标准的 RSA 加密库。
密钥来源:页面加载时请求 /encipher/selectPublicKey 接口获取:
$.ajax({
url: requestPrefix + "/encipher/selectPublicKey",
success: function (result) {
publicKey = data.publicKey; // RSA 公钥
refreshAesKeyAndIv(data); // 设置 AES 密钥和 IV
}
})
返回数据:
{
"aesKey": "eWF3ZWlzb2Z0YnBtMjAyMQ==",
"aesIv": "MjAyMWJwbXlhd2Vpc29mdA==",
"publicKey": "MIGfMA0GCSqGSIb3DQEBA…"
}
aesKey 和 aesIv 是 Base64 编码的,解码后:
经测试这三个值目前是固定的,但代码设计上是动态获取的,建议每次都请求一次以防服务端轮换。
响应解密:在设置 encrypted 的代码下方几行就能看到:
success: function (result) {
data = JSON.parse(aesDecrypt(data)); // 解密响应
}
进入 aesDecrypt:
function aesDecrypt(data) {
if (!AES_SECRET_KEY || !AES_SECRET_IV) return data;
const encryptedHexStr = CryptoJS.enc.Base64.parse(data);
const str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
// 上面两行是冗余操作,Base64 parse 再 stringify 回来等于没做
const decrypt = CryptoJS.AES.decrypt(str, AES_SECRET_KEY, {
iv: AES_SECRET_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7
});
return decrypt.toString(CryptoJS.enc.Utf8);
}
标准 AES/CBC/PKCS7,密文是 Base64 格式。
二、JS 复现与验证
// qd_mailboxpage.js
const JSEncrypt = require('./jsencrypt').default;
const CryptoJS = require('../../CryptoJS')
// RSA 加密时间戳 → encrypted 字段
function getEncrypted(publicKey) {
const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);
return encrypt.encrypt(String(new Date().getTime()));
}
// AES 解密响应
function decrypt(str, aesKey, aesIv) {
let AES_SECRET_KEY = CryptoJS.enc.Utf8.parse(aesKey);
let AES_SECRET_IV = CryptoJS.enc.Utf8.parse(aesIv);
const _decrypt = CryptoJS.AES.decrypt(str, AES_SECRET_KEY, {
iv: AES_SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return _decrypt.toString(CryptoJS.enc.Utf8);
}
注意:jsencrypt.js 从浏览器复制过来后,需要在文件顶部补 let window = globalThis;,因为 JSEncrypt 内部引用了 window 对象。
三、Python 实现
方式一:execjs 调用 JS
import json
import base64
import subprocess
from functools import partial
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
subprocess.Popen = partial(subprocess.Popen, encoding='utf-8')
import execjs
class QingdaoMailboxSpider:
"""青岛市政府信箱爬虫(execjs 版)"""
KEY_URL = 'http://www.qingdao.gov.cn/mailboxpage-qd/encipher/selectPublicKey'
API_URL = 'http://www.qingdao.gov.cn/mailboxpage-qd/mailboxBase/getMailListPage'
def __init__(self, js_path='qd_mailboxpage.js'):
self.session = requests.Session()
self.session.headers.update({
'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',
})
self.session.verify = False
self.ctx = execjs.compile(open(js_path, 'r', encoding='utf-8').read())
# 初始化密钥
self._init_keys()
def _init_keys(self):
"""请求服务端获取 RSA 公钥和 AES 密钥"""
resp = self.session.get(self.KEY_URL)
data = resp.json()['data']
self.public_key = data['publicKey']
self.aes_key = base64.b64decode(data['aesKey']).decode('utf-8')
self.aes_iv = base64.b64decode(data['aesIv']).decode('utf-8')
def fetch_page(self, page: int) –> list:
"""请求单页数据"""
encrypted = self.ctx.call('getEncrypted', self.public_key)
payload = {
'seekCode': '', 'title': '', 'pageSize': 15,
'pageNo': str(page), 'appSign': 'szxx', 'userGuid': '',
'questionType': '', 'startTime': '', 'endTime': '',
'encrypted': encrypted,
}
resp = self.session.post(self.API_URL, json=payload)
enc_data = resp.json().get('data', '')
if not enc_data:
return []
decrypted = json.loads(self.ctx.call('decrypt', enc_data, self.aes_key, self.aes_iv))
return [
{
'标题': r.get('title'),
'回复单位': r.get('origUser'),
'回复时间': r.get('mailContent', {}).get('respDate', '').replace('T', ' '),
}
for r in decrypted.get('records', [])
]
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__':
import urllib3
urllib3.disable_warnings()
spider = QingdaoMailboxSpider()
data = spider.run(pages=3)
for item in data:
print(item)
方式二:纯 Python(pycryptodome + rsa)
文件名:qingdao_mailbox_pure_crypto.py
import json
import time
import base64
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import unpad
class QingdaoMailboxSpider:
"""青岛市政府信箱爬虫(纯 Python 版)"""
KEY_URL = 'http://www.qingdao.gov.cn/mailboxpage-qd/encipher/selectPublicKey'
API_URL = 'http://www.qingdao.gov.cn/mailboxpage-qd/mailboxBase/getMailListPage'
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'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',
})
self.session.verify = False
self._init_keys()
def _init_keys(self):
"""请求服务端获取密钥"""
resp = self.session.get(self.KEY_URL)
data = resp.json()['data']
self.aes_key = base64.b64decode(data['aesKey'])
self.aes_iv = base64.b64decode(data['aesIv'])
# 构造 RSA 公钥对象
pub_key_der = base64.b64decode(data['publicKey'])
self.rsa_key = RSA.import_key(pub_key_der)
def _rsa_encrypt_timestamp(self) –> str:
"""RSA 公钥加密当前时间戳 → encrypted 字段"""
cipher = PKCS1_v1_5.new(self.rsa_key)
plaintext = str(int(time.time() * 1000)).encode('utf-8')
encrypted = cipher.encrypt(plaintext)
return base64.b64encode(encrypted).decode('utf-8')
def _aes_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:
"""请求单页数据"""
payload = {
'seekCode': '', 'title': '', 'pageSize': 15,
'pageNo': str(page), 'appSign': 'szxx', 'userGuid': '',
'questionType': '', 'startTime': '', 'endTime': '',
'encrypted': self._rsa_encrypt_timestamp(),
}
resp = self.session.post(self.API_URL, json=payload)
enc_data = resp.json().get('data', '')
if not enc_data:
return []
decrypted = json.loads(self._aes_decrypt(enc_data))
return [
{
'标题': r.get('title'),
'回复单位': r.get('origUser'),
'回复时间': r.get('mailContent', {}).get('respDate', '').replace('T', ' '),
}
for r in decrypted.get('records', [])
]
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__':
import urllib3
urllib3.disable_warnings()
spider = QingdaoMailboxSpider()
data = spider.run(pages=3)
for item in data:
print(item)
四、总结
| 抓包 | POST 请求,请求体 encrypted 字段动态生成,响应 data 为 Base64 密文 |
| 定位 | 搜索 encrypted 直接找到赋值位置,解密函数在同一文件的 ajax success 回调里 |
| 加密 | RSA 公钥加密当前时间戳(JSEncrypt 库),用于请求体的 encrypted 字段 |
| 解密 | AES-128 / CBC / PKCS7,密钥和 IV 从服务端接口动态获取(Base64 编码) |
| 密钥 | 通过 /encipher/selectPublicKey 接口获取,目前固定但建议每次请求 |
| 补环境 | JSEncrypt 库需要 let window = globalThis |
本案例的核心收获:
