新华社客户端 —— 3DES 双向加解密

目录

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

一、分析(目标+抓包+定位+参数分析合并)

需求:抓取新华社时政新闻频道的数据,提取新闻的标题、发布时间和评论数量

目标地址:https://xhpfmapi.xinhuaxmt.com/vh512/account/25295

F12 打开开发者工具,向下滚动触发翻页,在 Network 面板的 Fetch/XHR 过滤中找到数据接口:

text 复制代码
POST https://xhpfmapi.xinhuaxmt.com/v600/core/columnnewslist

观察请求体,是一个 JSON 但值是密文:

json 复制代码
{"param":"5sXqBDO69b5TT0a7L7xiUJdbRZNoUSCsfPVxCjDxf.....Ms198hb7ETuo2IkXpkVGkgUhEdL6Vw=="}

响应同样返回密文。右键请求 → Copy as cURL(bash) → 粘贴到 curlconverter.com 转成 Python 代码执行,确认能拿到响应(虽然是密文)。

精简请求头 :逐步删除 Cookie 和其他请求头字段,发现只保留 User-Agent 就能正常请求。说明服务端没有做严格的请求头校验,我们只需要关注两件事:加密请求体、解密响应体。

Ctrl+Shift+F 全局搜索。直接搜 param 结果太多(几百个匹配),根据经验改为搜索 param: 精确匹配赋值位置

定位到 JS 文件:

python 复制代码
app.bb596773ec5533767418.js
# 完整链接
https://xhpfmapi.xinhuaxmt.com/vh512static/static/js/app.bb596773ec5533767418.js

找到关键代码段:

javascript 复制代码
r.a.post(t, {
    param: (n = i,
            s = p.TripleDES.encrypt(n, v, {
        mode: p.mode.ECB,
        padding: p.pad.Pkcs7
    }),
            c = p.enc.Base64.stringify(s.ciphertext),
            // 开发者留下的调试日志,直接告诉我们这是加密过程
            window.xyJSBridge && window.xyJSBridge.output && window.xyJSBridge.output("原文:" + n + ";加密后:" + c),
            c)
})
// t的值: "https://xhpfmapi.xinhuaxmt.com/v600/core/columnnewslist"

在此处打断点,翻页触发请求,断点命中。观察调用栈中的接口路径确实是 /v600/core/columnnewslist,确认定位正确。

明文内容(变量 n):

json 复制代码
{"cid":"25295","pn":1,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}
{"cid":"25295","pn":5,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}
{"cid":"25295","pn":6,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}
{"cid":"25295","pn":7,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}

多次翻页对比:只有 pn(页码)在变化,其他字段固定不变。

密钥来源 (变量 v):当前函数作用域内找不到 v 的定义,往上层闭包查找,在当前函数上方发现:

javascript 复制代码
v = p.enc.Utf8.parse(p.MD5("Xinhuamm@2018").toString())

密钥是固定字符串 "Xinhuamm@2018" 经 MD5 哈希后转为 UTF-8 字节序列。

算法识别 :变量 p 就是 CryptoJS 库(展开对象能看到 AES、DES、TripleDES、MD5、SHA 等完整的加密算法集合)。加密方式确认为 3DES + ECB 模式 + PKCS7 填充

解密函数:在加密代码正下方找到对应的解密逻辑:

javascript 复制代码
.then(function(t) {
    return null == t.data || 1 == t.data || "number" == typeof t.data ? t : (t.data = (e = t.data,
                                                                                       p.TripleDES.decrypt({
        ciphertext: p.enc.Base64.parse(e)
    }, v, {
        mode: p.mode.ECB,
        padding: p.pad.Pkcs7
    }).toString(p.enc.Utf8))

同密钥、同模式,方向相反。

二、复现与验证(JS 复现)

标准 CryptoJS 无魔改,直接引库复现。验证时需要对比本地生成的密钥 vwords 数组和浏览器中的是否一致,排除魔改可能:

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

// 密钥:固定字符串 MD5 后转 UTF-8 字节
let v = CryptoJS.enc.Utf8.parse(CryptoJS.MD5("Xinhuamm@2018").toString())
// 验证:在浏览器控制台打印 v.words 和本地对比,确认一致

// 加密:构造请求体
function encrypt(n) {
    let s = CryptoJS.TripleDES.encrypt(n, v, {
        mode: CryptoJS.mode.ECB,
        padding: CryptoJS.pad.Pkcs7
    })
    return CryptoJS.enc.Base64.stringify(s.ciphertext)
}

// 解密:解析响应体
function decrypt(resData) {
    return CryptoJS.TripleDES.decrypt({
        ciphertext: CryptoJS.enc.Base64.parse(resData)
    }, v, {
        mode: CryptoJS.mode.ECB,
        padding: CryptoJS.pad.Pkcs7
    }).toString(CryptoJS.enc.Utf8)
}

验证结果

javascript 复制代码
console.log(encrypt('{"cid":"25295","pn":4,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}'));
// 本地加密: 5sXqBDO69b5TT0a7L7xiUKi4bBT5kPjkfPVxCjDxfO5jvVL+d4qPHBkytYh+1OwPNYlfqRSL3kuMgZ9tXc3nsJIbMs198hb7ETuo2IkXpkVGkgUhEdL6Vw==
// 浏览器: 5sXqBDO69b5TT0a7L7xiUKi4bBT5kPjkfPVxCjDxfO5jvVL+d4qPHBkytYh+1OwPNYlfqRSL3kuMgZ9tXc3nsJIbMs198hb7ETuo2IkXpkVGkgUhEdL6Vw==
// 完全一致 ✓

// 解密调用 decrypt 函数即可

三、Python实现(execjs版 + 纯Python版合并)

采用面向对象封装 + 线程池并发的方式,方便后续复用和扩展:

python 复制代码
import json
import subprocess
from functools import partial
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests

# execjs 在 Windows 下的编码修复
# 原因: execjs 内部用 subprocess.Popen 调用 Node.js,但不指定 encoding 参数
# Windows 默认用 GBK 解码 Node.js 的 UTF-8 输出,导致中文乱码或解析失败
# 这行代码让所有 Popen 调用默认使用 UTF-8 编码
subprocess.Popen = partial(subprocess.Popen, encoding='utf-8')
import execjs


class XinhuaSpider:
    """新华社客户端爬虫"""

    API_URL = 'https://xhpfmapi.xinhuaxmt.com/v600/core/columnnewslist'

    def __init__(self, cid='25295', js_path='xinhuaxmt.js'):
        self.cid = cid
        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',
        })
        # 加载 JS 加解密模块
        with open(js_path, 'r', encoding='utf-8') as f:
            self.ctx = execjs.compile(f.read())

    def _build_plaintext(self, page):
        """构造加密前的明文 JSON"""
        return json.dumps({
            "cid": self.cid,
            "pn": page,
            "clientVer": "8.8.2",
            "clientLable": "h5",
            "source": 0,
            "userID": ""
        })

    def fetch_page(self, page):
        """请求单页数据并解密"""
        plaintext = self._build_plaintext(page)
        param = self.ctx.call('encrypt', plaintext)

        resp = self.session.post(self.API_URL, json={'param': param})
        encrypted_data = resp.json().get('data', '')
        if not encrypted_data:
            return []

        decrypted = self.ctx.call('decrypt', encrypted_data.strip())
        news_list = json.loads(decrypted).get('newsList', [])

        return [
            {
                '标题': item.get('topic'),
                '发布日期': item.get('releasedate'),
                '评论数量': item.get('commentCount'),
            }
            for item in news_list
        ]

    def run(self, pages=5, max_workers=3):
        """并发采集多页数据"""
        all_news = []
        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_news.extend(result)
                    print(f'[Page {page}] 获取 {len(result)} 条新闻')
                except Exception as e:
                    print(f'[Page {page}] 请求失败: {e}')
        return all_news


if __name__ == '__main__':
    spider = XinhuaSpider()
    news = spider.run(pages=3, max_workers=3)
    for _ in news:
        print(_)

关于并发数的说明max_workers=3 是比较保守的设置。新华社这个接口没有明显的频率限制,但作为官方媒体平台,不建议开太高的并发,3-5 个线程足够用。

既然已经确认是标准 3DES 且无魔改,完全可以用 Python 的 pycryptodome 库直接实现,不需要调用 Node.js。性能更好,部署也更简单。

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

import requests
from Crypto.Cipher import DES3
from Crypto.Util.Padding import pad, unpad


class XinhuaCrypto:
    """新华社 3DES 加解密器"""

    def __init__(self, secret='Xinhuamm@2018'):
        # 密钥生成过程:
        # 1. MD5("Xinhuamm@2018") → 32位十六进制字符串
        # 2. 将这个字符串当作 UTF-8 文本 → 32 字节
        # 3. 3DES 需要 24 字节密钥,CryptoJS 自动截取前 24 字节
        md5_hex = hashlib.md5(secret.encode('utf-8')).hexdigest()
        self.key = md5_hex.encode('utf-8')[:24]

    def encrypt(self, plaintext):
        """加密明文 → Base64 密文"""
        cipher = DES3.new(self.key, DES3.MODE_ECB)
        padded = pad(plaintext.encode('utf-8'), DES3.block_size)
        encrypted = cipher.encrypt(padded)
        return base64.b64encode(encrypted).decode('utf-8')

    def decrypt(self, ciphertext):
        """Base64 密文 → 明文"""
        cipher = DES3.new(self.key, DES3.MODE_ECB)
        encrypted = base64.b64decode(ciphertext)
        decrypted = unpad(cipher.decrypt(encrypted), DES3.block_size)
        return decrypted.decode('utf-8')


class XinhuaSpider:
    """新华社客户端爬虫(纯 Python 版)"""

    API_URL = 'https://xhpfmapi.xinhuaxmt.com/v600/core/columnnewslist'

    def __init__(self, cid='25295'):
        self.cid = cid
        self.crypto = XinhuaCrypto()
        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',
        })

    def fetch_page(self, page):
        """请求单页数据"""
        plaintext = json.dumps({
            "cid": self.cid,
            "pn": page,
            "clientVer": "8.8.2",
            "clientLable": "h5",
            "source": 0,
            "userID": ""
        })
        param = self.crypto.encrypt(plaintext)

        resp = self.session.post(self.API_URL, json={'param': param})
        encrypted_data = resp.json().get('data', '')
        if not encrypted_data:
            return []

        decrypted = self.crypto.decrypt(encrypted_data.strip())
        news_list = json.loads(decrypted).get('newsList', [])

        return [
            {
                '标题': item.get('topic'),
                '发布日期': item.get('releasedate'),
                '评论数量': item.get('commentCount'),
            }
            for item in news_list
        ]

    def run(self, pages=5, max_workers=3):
        """并发采集"""
        all_news = []
        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_news.extend(result)
                    print(f'[Page {page}] 获取 {len(result)} 条新闻')
                except Exception as e:
                    print(f'[Page {page}] 请求失败: {e}')
        return all_news


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

纯 Python 版 vs JS 调用版对比

JS 调用版(execjs) 纯 Python 版(pycryptodome)
依赖 Node.js + CryptoJS 仅 pycryptodome
性能 每次调用启动进程,较慢 纯内存计算,快
部署 需要安装 Node.js pip install 即可
适用场景 算法有魔改、不确定实现细节时先用 JS 验证 确认是标准算法后切换到纯 Python

什么时候用哪个:先用 JS 版验证结果正确,确认无魔改后再改写成纯 Python 版。如果发现纯 Python 版输出和 JS 版不一致,说明 CryptoJS 有魔改,老老实实用 JS 版。

四、编码流向详解

Python 加解密代码中频繁出现 .encode().decode()base64.b64encode()base64.b64decode(),容易混淆。这里把整个数据流向画清楚。

加密方向(明文 → 可传输的密文字符串)

text 复制代码
"hello"                          ← str,人能读的字符串
    ↓ .encode('utf-8')
b"hello"                         ← bytes,加密算法只能处理字节
    ↓ pad(..., block_size)
b"hello\x03\x03\x03"            ← bytes,PKCS7 填充到 8 字节整数倍
    ↓ cipher.encrypt(...)
b"\x8a\x3f\x7b..."              ← bytes,加密后的原始字节(乱码,不可读,不可传输)
    ↓ base64.b64encode(...)
b"ij97..."                       ← bytes,Base64 编码后(全是 ASCII 字符,可读)
    ↓ .decode('utf-8')
"ij97..."                        ← str,可以放进 JSON 传输了

解密方向(密文字符串 → 明文)

text 复制代码
"ij97..."                        ← str,从响应 JSON 中拿到的密文
    ↓ base64.b64decode(...)      (b64decode 直接接受 str,不需要先 encode)
b"\x8a\x3f\x7b..."              ← bytes,还原成加密后的原始字节
    ↓ cipher.decrypt(...)
b"hello\x03\x03\x03"            ← bytes,解密后带填充
    ↓ unpad(..., block_size)
b"hello"                         ← bytes,去掉填充
    ↓ .decode('utf-8')
"hello"                          ← str,人能读的明文

核心规律

  1. 加密算法只吃 bytes、只吐 bytes
  2. Base64 是 "二进制乱码 ↔ 可读 ASCII 文本 的桥梁(因为 HTTP/JSON 不能直接传二进制)
  3. 整个链路:人话 → 字节 → 加密字节 → Base64 文本 → 传输 → Base64 文本 → 加密字节 → 字节 → 人话

容易混淆的点:.decode('utf-8') vs base64.b64decode()

这两个名字里都有 "decode",但完全是两回事:

写法 做了什么 内容是否变化
.decode('utf-8') Python 类型转换:bytes → str 内容不变,只是换了个容器
base64.b64decode(...) Base64 算法逆操作:ASCII 文本 → 原始二进制 内容变了
python 复制代码
# .decode('utf-8') 不改变内容,只改变类型
b"5sXqBDO69b5..."              # bytes 类型
"5sXqBDO69b5..."               # str 类型,内容完全一样

# 为什么需要这一步?因为 JSON 只接受 str,不接受 bytes:
requests.post(url, json={'param': b"5sXq..."})   # 报错
requests.post(url, json={'param': "5sXq..."})    # 正常

Base64 输出的字符全部是 ASCII 范围内的(A-Z a-z 0-9 + / =),而 ASCII 是 UTF-8 的子集。所以 .decode('utf-8') 在这里没有做任何编码转换,只是把数据从 bytes 容器倒进 str 容器------就像把水从玻璃杯倒进塑料杯,水还是那个水。

五、总结

总结

环节 要点
抓包 POST 请求,请求体和响应体都是密文,请求头无特殊校验
定位 搜索 param: 精确定位赋值点,比搜 param 高效得多
算法 3DES + ECB + PKCS7,标准 CryptoJS 实现无魔改
密钥 固定字符串 "Xinhuamm@2018" 经 MD5 后作为密钥,藏在闭包上层
验证 对比密钥的 words 数组和加密输出,确认无魔改
复现 直接引用 CryptoJS 库,不需要手动扣算法实现

本案例的核心收获

  1. 遇到标准加密库(CryptoJS / JSEncrypt 等)时,不需要手动扣算法实现。识别出算法类型和参数(密钥、模式、填充方式),直接引用同一个库调用即可。
  2. 精力应该花在 "定位加密位置""找密钥" 上,而不是重新实现算法。
  3. 搜索关键词时加上冒号、等号等赋值符号(如 param:),能大幅减少无关结果。
  4. 密钥不一定在当前函数内,要有意识地往闭包上层找。
相关推荐
冰履踏青云10 小时前
十年饮冰,热血难凉 | JS逆向为爱发电
js逆向
嫂子的姐夫1 天前
047-MD5:飞卢网
爬虫·python·js逆向·逆向
如烟花的信页1 天前
某管理服务平台点选逆向分析
javascript·爬虫·python·js逆向
如烟花的信页4 天前
易盾点选逆向分析
javascript·爬虫·python·js逆向
如烟花的信页5 天前
易盾滑块逆向分析
javascript·爬虫·python·js逆向
一直会游泳的小猫6 天前
Scrapling-深度解析
python爬虫·反爬虫绕过·自适应解析·浏览器指纹伪装
Soari7 天前
GitHub 开源项目解析:D4Vinci/Scrapling —— Python 网页抓取与自动化处理工具
python·开源·github·python爬虫·网页抓取·异步抓取
如烟花的信页8 天前
数美滑块逆向分析
javascript·爬虫·python·js逆向
袁袁袁袁满12 天前
利用亮数据网络解锁API进行数据采集
网络爬虫·爬虫实战·python爬虫·电商数据采集·验证码破解·网页解锁器·爬虫验证码