欢迎光临
我们一直在努力

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

目录

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

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

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

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

https://www.swhysc.com/swhysc/news/company

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

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

GET https://www.swhysc.com/swhy/service/wscms/v1/cms/infobaselist

多次翻页对比 Query String 参数:

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 即可。剩下的核心问题就只有一个——响应体是密文,需要解密。

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

  • Hook JSON.parse,从「解密后必然会 parse 成对象」这个点反向回溯;
  • 直接全局搜索 decrypt / AES 这类关键字。
  • 对于这种业务体量较大的站点,JSON.parse 的调用点遍布各处,断点命中后噪声很大、回溯成本高。所以这里优先用第二种——直接搜 decrypt:

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

    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 内打断点,翻页触发请求,看是否命中:

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

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

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

    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 并不是明文写死的,而是用一段「类摩斯电码映射」拼出来的——顺手往上追溯一下它的来历,这也是本案例唯一有点意思的地方:

    // 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 的兜底逻辑原样保留,正好充当中间的连字符):

    "-.-"→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 中工具对象 f 的 f.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:

    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:

    # -*- 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 即可(省去码表派生那一步):

    # 我省略了生成 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'))

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

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

  • 密钥、URL、栏目 ID、请求头全是散落的魔法值,换个栏目就得改源码;
  • 只解密、不提取——还没把标题、发布时间、详情链接结构化出来;
  • 没有异常处理,某页超时或返回非 0 code 直接崩;
  • print 调试、串行请求,跑批时既慢又无法追溯。
  • 2.2 封装版(OOP + 多线程 + loguru 日志)

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

    # -*- 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 字段:解密后每条记录有 originalTime、createTime、updateTime、verifiedTime 等多个时间。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 控制栏目,二者解耦

    本案例的核心收获:

  • ECB 模式是对称加密里最简单的形态——没有 IV,密钥即全部。看到 mode.ECB 时,Python 端 AES.new(key, AES.MODE_ECB) 不要传 IV。
  • 密钥即使经过「码表派生 / 字符变换」等花样包装,只要它在运行时是确定值,直接在断点处打印拿到结果即可,不必死磕派生过程;但理解派生逻辑能帮你判断它会不会动态变化。
  • 业务体量大的站点优先搜 decrypt/AES 等关键字定位,比 Hook JSON.parse 精准;后者更适合搜不到关键字、或函数名被混淆的场景。
  • 逆向脚本和交付代码是两个阶段:先用最小脚本验证结论,再封装成带配置、日志、并发、异常处理的可维护版本。
  • 赞(0)
    未经允许不得转载:171主机测评 » 申万宏源证券新闻中心 —— AES/ECB 响应解密(摩斯电码派生密钥)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址