申万宏源证券新闻中心 —— AES/ECB 响应解密(摩斯电码派生密钥)

目录

  • 一、分析(目标+抓包+定位+结论)
  • [二、Python 实现](#二、Python 实现)
    • [2.1 最小验证版(扣 JS / 纯 Python 两条路)](#2.1 最小验证版(扣 JS / 纯 Python 两条路))
    • [2.2 封装版(OOP + 多线程 + loguru 日志)](#2.2 封装版(OOP + 多线程 + loguru 日志))
  • 三、总结

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

一、分析(目标+抓包+定位+结论)

需求:抓取申万宏源证券新闻中心的公司新闻,循环采集前 3 页,提取每条新闻的标题、发布时间,并拼出详情页链接。目标页面:

text 复制代码
https://www.swhysc.com/swhysc/news/company

抓包:F12 打开 DevTools,切到 Network,清空请求记录。这类列表数据通常是 Ajax 动态加载的,所以过滤 Fetch/XHR,然后翻页触发请求,定位数据包:

捕获到目标接口,是一个 GET 请求:

text 复制代码
GET https://www.swhysc.com/swhy/service/wscms/v1/cms/infobaselist

多次翻页对比 Query String 参数:

text 复制代码
topFlag=3&pageSize=10&status=2&pageNum=2&channelId=00010002000100030001
topFlag=3&pageSize=10&status=2&pageNum=3&channelId=00010002000100030001
topFlag=3&pageSize=10&status=2&pageNum=4&channelId=00010002000100030001

参数含义梳理:

参数 含义 说明
pageNum 页码 翻页时唯一变化的参数
pageSize 每页条数 固定 10
channelId 栏目频道 ID 决定抓哪个板块 ,公司新闻为 00010002000100030001
topFlag / status 置顶标记 / 发布状态 固定值,前端写死

关于 channelId :它唯一对应一个新闻栏目,这里写死成公司新闻的 00010002000100030001 即可。如果要抓新闻中心下的其他板块(如 媒体聚焦、研究报告 等),只需在对应页面抓包拿到那个板块的 channelId 替换即可,解密逻辑完全通用。

精简请求头 :观察请求头没有发现自定义校验字段。保险起见,右键接口 → Copy → Copy as cURL (bash),粘贴到 curlconverter.com 转成 Python 代码,在 PyCharm 里逐步删头测试。结果是:不带 Cookie 也能请求,服务端对请求头没有严格校验,只保留一个 User-Agent 即可。剩下的核心问题就只有一个------响应体是密文,需要解密。

定位解密函数:响应解密一般有两条思路:

  1. Hook JSON.parse,从「解密后必然会 parse 成对象」这个点反向回溯;
  2. 直接全局搜索 decrypt / AES 这类关键字。

对于这种业务体量较大的站点,JSON.parse 的调用点遍布各处,断点命中后噪声很大、回溯成本高。所以这里优先用第二种------直接搜 decrypt

点进去,命中的就是加解密工具对象 f(同时定义了 encryptdecrypt),解密函数如下:

javascript 复制代码
f = {
    // 加密:请求方向用(本案例用不到,但能佐证 Key 和模式)
    encrypt: function(e) {
        var t = u.a.enc.Utf8.parse(d)      // 密钥:把字符串 d 当作 UTF-8 字节
          , n = u.a.enc.Utf8.parse(e)
          , r = u.a.AES.encrypt(n, t, {
                mode: u.a.mode.ECB,
                padding: u.a.pad.Pkcs7
            });
        return r.toString()
    },
    // 解密:响应方向用 ------ 这就是我们要还原的入口
    decrypt: function(e) {                  // e = 服务端返回的 Base64 密文
        var t = u.a.enc.Utf8.parse(d)      // t = 密钥(来自字符串 d)
          , n = u.a.AES.decrypt(e, t, {     // CryptoJS 收到字符串密文时默认按 Base64 解析
                mode: u.a.mode.ECB,         // ECB 模式 → 无需 IV
                padding: u.a.pad.Pkcs7      // PKCS7 填充
            });
        return u.a.enc.Utf8.stringify(n).toString()   // 字节 → UTF-8 明文字符串
    }
}

u.a 即 CryptoJS(页面引入了 npm.crypto-js 这个 bundle,展开对象能看到 AES/MD5/enc 等完整算法集)。

断点验证 :在 decrypt 内打断点,翻页触发请求,看是否命中:

断点命中说明这里确实是解密入口。为了确认无误,做两重验证:

  1. 切到 Network 面板,对比该接口返回的密文,和断点处入参 e 是否一致(一致则确认解密就发生在此处。对比时留意首尾空格、引号等,避免误判);
  2. return u.a.enc.Utf8.stringify(n).toString() 处再下一个断点,F8 放行后在 Console 查看返回值是否就是页面上的新闻数据。

结果明显就是我们要的新闻列表,定位完成:

算法与密钥分析 :解密的算法参数已经很清楚了------AES / ECB / PKCS7 ,ECB 模式没有 IV,唯一的未知量就是密钥 d

javascript 复制代码
var t = u.a.enc.Utf8.parse(d)
, n = u.a.AES.decrypt(e, t, {
    mode: u.a.mode.ECB,
    padding: u.a.pad.Pkcs7
});
// 模式 ECB(无 IV)、填充 Pkcs7、密钥 = 字符串 d 的 UTF-8 字节
// 调试时直接在控制台打印 d,发现每次都是固定字符串 "rewin-swhysc1234"(16 字节,对应 AES-128)

直接在断点处打印 d 就能拿到密钥 "rewin-swhysc1234",到这一步逆向其实已经可以收工。但 d 并不是明文写死的,而是用一段「类摩斯电码映射」拼出来的------顺手往上追溯一下它的来历,这也是本案例唯一有点意思的地方:

javascript 复制代码
// p 是一张「码 → 字符」的映射表(形似摩斯电码,但映射关系是网站自定义的,并非标准摩斯)
p = new Map;
p.set("-.", "a"),  p.set(".---", "b"), p.set(".-.-", "c"), p.set(".--", "d"),
p.set("--..", "e"), p.set("--.-", "f"), p.set("..-", "g"),  p.set("----", "h"),
p.set("--", "i"),  p.set("-...", "j"), p.set(".-.", "k"),  p.set("-.--", "l"),
p.set("..", "m"),  p.set(".-", "n"),   p.set("...", "o"),  p.set("-..-", "p"),
p.set("..-.", "q"), p.set("-.-", "r"),  p.set("---", "s"),  p.set(".", "t"),
p.set("--.", "u"), p.set("---.", "v"), p.set("-..", "w"),  p.set(".--.", "x"),
p.set(".-..", "y"), p.set("..--", "z"),
p.set(".....", "0"), p.set("-....", "1"), p.set("--...", "2"), p.set("---..", "3"),
p.set("----.", "4"), p.set("-----", "5"), p.set(".----", "6"), p.set("..---", "7"),
p.set("...--", "8"), p.set("....-", "9");

// d:把下面这个码数组逐个查表,再拼接成最终的密钥字符串
var d = ["-.-", "--..", "-..", "--", ".-", "-", "---", "-..", "----", ".-..", "---", ".-.-", "-....", "--...", "---..", "----."]
    .map(function(e) {
        return p.get(e) || e   // 查表命中则取映射字符;查不到(如单独的 "-")则原样保留该码
    })
    .join("");

逐项还原这个映射过程,密钥就出来了(注意第 6 个码 "-" 不在表里,靠 p.get(e) || e 的兜底逻辑原样保留,正好充当中间的连字符):

text 复制代码
"-.-"→r  "--.."→e  "-.."→w  "--"→i  ".-"→n  "-"→-(兜底原样保留)  "---"→s
"-.."→w  "----"→h  ".-.."→y  "---"→s  ".-.-"→c  "-...."→1  "--..."→2  "---.."→3  "----."→4

拼接结果: r e w i n - s w h y s c 1 2 3 4  →  "rewin-swhysc1234"

和直接打印 d 得到的值完全一致,密钥确认无误。

结论:本站只做了响应加密,且是「标准 CryptoJS + 硬编码密钥」的最简形态,没有魔改、没有请求签名。汇总如下:

环节 结论
接口 GET /swhy/service/wscms/v1/cms/infobaselist,参数 pageNum 翻页、channelId 定栏目
请求头 无特殊校验,仅需 User-Agent
加密方向 仅响应加密(请求是明文 Query 参数)
算法 AES / ECB / PKCS7,密文为 Base64
密钥 固定字符串 "rewin-swhysc1234"(16 字节 AES-128),由自定义码表派生
解密入口 app.js 中工具对象 ff.decrypt(在 axios 响应拦截器中被调用)

算法标准、无魔改,复现有两条路:直接引 CryptoJS 用 execjs 调用,或用 Python(pycryptodome)整段改写。下一节给出 Python 实现。

二、Python 实现

2.1 最小验证版(扣 JS / 纯 Python 两条路)

逆向到手后,第一步不是写框架,而是用最少的代码把「请求 → 解密 → 明文」这条链路跑通,确认密钥、模式、数据结构都对得上。算法是标准 CryptoJS、无魔改,两条复现路线都可行:扣 JS 用 execjs 调用,或纯 Python 改写。

路线一:扣 JS + execjs 调用 。把 decrypt 连同密钥 d 的派生逻辑原样搬到本地 swhysc.js,只需把 u.a 替换成本地引入的 CryptoJS

javascript 复制代码
p = new Map;
p.set("-.", "a"),
    p.set(".---", "b"),
    p.set(".-.-", "c"),
    p.set(".--", "d"),
    p.set("--..", "e"),
    p.set("--.-", "f"),
    p.set("..-", "g"),
    p.set("----", "h"),
    p.set("--", "i"),
    p.set("-...", "j"),
    p.set(".-.", "k"),
    p.set("-.--", "l"),
    p.set("..", "m"),
    p.set(".-", "n"),
    p.set("...", "o"),
    p.set("-..-", "p"),
    p.set("..-.", "q"),
    p.set("-.-", "r"),
    p.set("---", "s"),
    p.set(".", "t"),
    p.set("--.", "u"),
    p.set("---.", "v"),
    p.set("-..", "w"),
    p.set(".--.", "x"),
    p.set(".-..", "y"),
    p.set("..--", "z"),
    p.set(".....", "0"),
    p.set("-....", "1"),
    p.set("--...", "2"),
    p.set("---..", "3"),
    p.set("----.", "4"),
    p.set("-----", "5"),
    p.set(".----", "6"),
    p.set("..---", "7"),
    p.set("...--", "8"),
    p.set("....-", "9");
var d = ["-.-", "--..", "-..", "--", ".-", "-", "---", "-..", "----", ".-..", "---", ".-.-", "-....", "--...", "---..", "----."].map((function (e) {
        return p.get(e) || e
    }
)).join("")
// console.log(d)

const CryptoJS = require('./CryptoJS')


function decryptRes(e) {
    // u.a刚刚说了就是 CryptoJS
    // var t = u.a.enc.Utf8.parse(d)
    var t = CryptoJS.enc.Utf8.parse(d)
      // , n = u.a.AES.decrypt(e, t, {
      , n = CryptoJS.AES.decrypt(e, t, {
        // mode: u.a.mode.ECB,
        mode: CryptoJS.mode.ECB,
        // padding: u.a.pad.Pkcs7
        padding: CryptoJS.pad.Pkcs7
    });
    // return u.a.enc.Utf8.stringify(n).toString()
    return CryptoJS.enc.Utf8.stringify(n).toString()
}

// 测试成功
console.log(decryptRes('K0FWJlJJ7hojwG3yOTA/NshaO9jUNRiTzZsxTWdF.....省略'));

Python 端用 execjs 加载 swhysc.js 并调用 decryptRes

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : swhysc.py
@Author  : XAMO Lab
@Date    : 2026/6/12 13:58
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 
"""

import requests
import subprocess
from functools import partial

subprocess.Popen = partial(subprocess.Popen, encoding='utf-8')

import execjs

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
                  ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
}

params = {
    'topFlag': '3',
    'pageSize': '10',
    'status': '2',
    'pageNum': '2',
    'channelId': '00010002000100030001',
}

response = requests.get(
    'https://www.swhysc.com/swhy/service/wscms/v1/cms/infobaselist',
    params=params,
    # cookies=cookies,
    headers=headers,
)
print(response.text)
ctx = execjs.compile(open('./swhysc.js', 'r', encoding='utf-8').read())
result = ctx.call('decryptRes', response.text)
print(result)

测试结果:

路线二:纯 Python(pycryptodome) 。既然确认是标准 AES/ECB/PKCS7、密钥也还原成了固定字符串,就没必要再带着 Node.js------直接用 pycryptodome 整段改写,密钥写死 rewin-swhysc1234 即可(省去码表派生那一步):

python 复制代码
# 我省略了生成 d 的过程
# -*- coding: utf-8 -*-
"""
@File    : swhysc_python_pure.py
@Author  : XAMO Lab
@Date    : 2026/6/12 15:40
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 
"""
import base64

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

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
                  ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
}

params = {
    'topFlag': '3',
    'pageSize': '10',
    'status': '2',
    'pageNum': '2',
    'channelId': '00010002000100030001',
}

response = requests.get(
    'https://www.swhysc.com/swhy/service/wscms/v1/cms/infobaselist',
    params=params,
    # cookies=cookies,
    headers=headers,
)
print(response.text)
aes = AES.new(key=b'rewin-swhysc1234', mode=AES.MODE_ECB)
print(unpad(aes.decrypt((base64.b64decode(response.content))), AES.block_size).decode('utf-8'))

测试结果,一样能解密成功:

上面两段都只是验证「思路成立」的一次性脚本,直接拿来交付并不合适,问题很明显:

  1. 密钥、URL、栏目 ID、请求头全是散落的魔法值,换个栏目就得改源码;
  2. 只解密、不提取------还没把标题、发布时间、详情链接结构化出来;
  3. 没有异常处理,某页超时或返回非 0 code 直接崩;
  4. print 调试、串行请求,跑批时既慢又无法追溯。

2.2 封装版(OOP + 多线程 + loguru 日志)

把逻辑收敛成一个类:配置项集中管理、解密独立成方法、ThreadPoolExecutor 并发翻页、loguru 做分级日志和异常追踪,并完成字段提取(标题 / 发布时间 / 详情链接)。换栏目只改 channel_id,换页数只改 pages

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : swhysc_news_spider.py
@Author  : XAMO Lab
@Date    : 2026/6/12 16:19
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 
"""

"""申万宏源证券新闻中心采集(AES/ECB 响应解密 + 多线程)

接口: GET https://www.swhysc.com/swhy/service/wscms/v1/cms/infobaselist
响应: Base64 密文,AES-128 / ECB / PKCS7,密钥固定为 "rewin-swhysc1234"
       (密钥在 app.js 中由自定义码表派生得到,运行时恒定,这里直接写死)
"""
import base64
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List

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


class SwhyscNewsSpider:
    """申万宏源证券新闻中心爬虫"""

    API_URL = "https://www.swhysc.com/swhy/service/wscms/v1/cms/infobaselist"
    DETAIL_PREFIX = "https://www.swhysc.com/swhysc/news/company/detail/"
    AES_KEY = b"rewin-swhysc1234"  # 逆向得到的固定密钥(AES-128)

    def __init__(self, channel_id: str = "00010002000100030001",
                 page_size: int = 10, max_workers: int = 3) -> None:
        """
        :param channel_id: 栏目频道 ID,默认公司新闻;换板块只需替换此值
        :param page_size:  每页条数
        :param max_workers: 并发线程数
        """
        self.channel_id = channel_id
        self.page_size = page_size
        self.max_workers = max_workers
        self.session = requests.Session()
        # 实测服务端无特殊请求头校验,仅需 User-Agent
        self.session.headers.update({
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/149.0.0.0 Safari/537.36"
            ),
        })

    def _decrypt(self, ciphertext: str) -> Dict[str, Any]:
        """AES/ECB/PKCS7 解密响应(Base64 密文)→ dict"""
        raw = base64.b64decode(ciphertext)
        plaintext = unpad(AES.new(self.AES_KEY, AES.MODE_ECB).decrypt(raw), AES.block_size)
        return json.loads(plaintext.decode("utf-8"))

    def fetch_page(self, page: int) -> List[Dict[str, str]]:
        """请求单页并解密,返回结构化新闻列表"""
        params = {
            "topFlag": 3,
            "pageSize": self.page_size,
            "status": 2,
            "pageNum": page,
            "channelId": self.channel_id,
        }
        resp = self.session.get(self.API_URL, params=params, timeout=15)
        resp.raise_for_status()

        result = self._decrypt(resp.text)
        # 接口约定:code == "0" 为成功(注意是字符串 "0",失败时形如 "-0801000")
        if str(result.get("code")) != "0":
            logger.warning("page={} 接口异常 code={} msg={}",
                           page, result.get("code"), result.get("msg"))
            return []

        rows = result.get("data", {}).get("data", [])  # 列表在 data.data 里
        news_list = [
            {
                "title": item.get("title"),
                "publish_time": item.get("originalTime"),  # 页面展示的发布时间
                "url": self.DETAIL_PREFIX + str(item.get("id")),  # 用 id 拼详情页
            }
            for item in rows
        ]
        logger.success("page={} 解密成功,获取 {} 条新闻", page, len(news_list))
        return news_list

    def run(self, pages: int = 3) -> List[Dict[str, str]]:
        """并发采集前 pages 页"""
        logger.info("开始采集 | 栏目={} | 共 {} 页 | 并发 {}",
                    self.channel_id, pages, self.max_workers)
        all_news: List[Dict[str, str]] = []
        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            future_map = {executor.submit(self.fetch_page, p): p for p in range(1, pages + 1)}
            for future in as_completed(future_map):
                page = future_map[future]
                try:
                    all_news.extend(future.result())
                except Exception as exc:  # 单页异常隔离,不影响其他页
                    logger.error("page={} 请求失败: {}", page, exc)
        logger.info("采集完成,共 {} 条", len(all_news))
        return all_news


if __name__ == "__main__":
    spider = SwhyscNewsSpider()
    news = spider.run(pages=3)

    for idx, item in enumerate(news, 1):
        logger.info("[{}] {} | {} | {}",
                    idx, item["publish_time"], item["title"], item["url"])

运行效果(loguru 自带彩色分级日志 + 结构化结果):

封装版相比验证版的改进点

维度 验证版(9.2.1) 封装版(9.2.2)
配置 魔法值散落各处 集中为类属性 / 构造参数,换栏目改一处
解密 裸函数 独立 _decrypt 方法,职责单一
提取 只打印整段明文 结构化为 标题 / 发布时间 / 详情链接
并发 串行单页 ThreadPoolExecutor 并发翻页
日志 print loguru 分级日志(INFO/SUCCESS/WARNING/ERROR),自带定位与时间戳
健壮性 出错即崩 raise_for_status + code 校验 + 单页异常隔离,不影响其他页

关于 originalTime 字段 :解密后每条记录有 originalTimecreateTimeupdateTimeverifiedTime 等多个时间。originalTime 对应页面展示的「发布时间」,故取它;需要其他口径换字段即可。关于并发数 :申万宏源是券商官网,max_workers=3 偏保守。前 3 页只有 3 个请求,并发收益不明显;但采集页数多(几十页)时能显著缩短总耗时。无论如何不建议开太高,避免给目标站点造成压力。

三、总结

环节 要点
抓包 GET 请求,响应体为 Base64 密文;请求头无校验,仅需 User-Agent
定位 全局搜 decrypt 直接命中工具对象 f,比 Hook JSON.parse 噪声小
算法 AES / ECB / PKCS7,密文 Base64(CryptoJS 收字符串默认按 Base64 解析)
密钥 固定 "rewin-swhysc1234",由自定义码表(`p.get(e)
复现 标准 CryptoJS 无魔改,pycryptodome 直接还原;AES.MODE_ECB 无需 IV
翻页 pageNum 控制页码,channelId 控制栏目,二者解耦

本案例的核心收获

  1. ECB 模式是对称加密里最简单的形态------没有 IV,密钥即全部。看到 mode.ECB 时,Python 端 AES.new(key, AES.MODE_ECB) 不要传 IV。
  2. 密钥即使经过「码表派生 / 字符变换」等花样包装,只要它在运行时是确定值,直接在断点处打印拿到结果即可,不必死磕派生过程;但理解派生逻辑能帮你判断它会不会动态变化。
  3. 业务体量大的站点优先搜 decrypt/AES 等关键字定位,比 Hook JSON.parse 精准;后者更适合搜不到关键字、或函数名被混淆的场景。
  4. 逆向脚本和交付代码是两个阶段:先用最小脚本验证结论,再封装成带配置、日志、并发、异常处理的可维护版本。
相关推荐
冰履踏青云9 小时前
某音x-tt-session-dtrait 算法逆向复盘
js逆向·session-dtrait
如烟花的信页1 天前
*花顺cookie逆向分析
javascript·爬虫·python·js逆向
Amo Xiang1 天前
SpiderDemo 第1题:请求头检测挑战 —— Disable cache 缓存头与请求特征差异
js逆向·爬虫逆向·spiderdemo·tls指纹·请求头检测·disable cache
Amo Xiang2 天前
全国新书网 —— AES/CBC 双向加解密
js逆向·python爬虫·cryptojs·前端加密·aes加解密·pycryptodome
如烟花的信页2 天前
加速乐cookie逆向分析
javascript·爬虫·python·js逆向
Amo Xiang2 天前
福建公共资源交易平台 —— MD5 签名 + AES 响应解密
js逆向·python爬虫·md5·cryptojs·前端加密·axios拦截器·aes解密
忧云2 天前
x64dbg 反汇编逆向入门到实操:从安装到动手调试零基础完整教程
逆向工程·反汇编·x64dbg·windows 调试·软件逆向入门
Amo Xiang3 天前
JS 逆向系统进阶路线:专栏总纲与文章导航
javascript·js逆向·前端加密·爬虫逆向·反爬虫
Amo Xiang3 天前
新华社客户端 —— 3DES 双向加解密
js逆向·python爬虫·cryptojs·3des·前端加密