Python逆向JSVMP加密:实战音乐平台数据解密与爬虫实现
2026/7/25 0:44:07 网站建设 项目流程

1. 项目概述

今天我们来聊聊一个在Python爬虫领域里,既让人头疼又充满挑战的实战案例:模拟某音乐平台的数据解密。如果你曾经尝试过爬取一些主流音乐App的数据,大概率会遇到过这种情况——明明在浏览器开发者工具里能看到清晰的JSON数据,但用requestsaiohttp直接请求接口,返回的却是一堆看不懂的乱码,或者干脆就是加密后的密文。这背后,往往是平台为了反爬虫而部署的JavaScript虚拟机保护(JSVMP)技术。这个项目,就是带你一步步拆解这个“黑盒”,用Python完整复现其数据解密流程,最终拿到我们想要的、结构清晰的歌曲、歌手、专辑等信息。

简单来说,这就是一场“逆向工程”的实战。目标不是简单地获取公开数据,而是理解平台如何在前端保护数据,并在Python环境中完整模拟这一保护机制,实现自动化、稳定地解密。这对于需要批量获取音乐元数据(比如做音乐推荐分析、歌单研究、市场趋势洞察)的朋友来说,是必须跨过的一道坎。整个过程会涉及网络抓包、JavaScript逆向分析、Python模拟执行等多个环节,我会把每个环节的“坑”和技巧都摊开来讲清楚。

2. 核心思路与技术选型解析

2.1 为什么是JSVMP,以及我们面对的是什么?

首先得明白对手是谁。传统的反爬手段比如验证码、IP频率限制、请求头校验,都属于“关卡型”防御,我们在请求阶段想办法绕过就行。而JSVMP(JavaScript Virtual Machine Protection)属于“数据型”防御,它允许你正常请求接口,服务器也正常响应,但返回的关键数据(如歌曲列表、歌词、播放链接)被一层甚至多层由JavaScript动态生成的加密逻辑包裹着。解密的核心算法和密钥,都隐藏在经过混淆、压缩、虚拟化处理的JavaScript代码中。

这带来了几个难点:

  1. 算法隐匿:你无法像分析简单AESRSA那样,直接找到一个叫decrypt的函数。真正的解密逻辑被拆散成无数个碎片化的操作码(opcode),在一个自定义的虚拟机中顺序执行。
  2. 环境依赖:解密过程往往严重依赖浏览器环境,比如windowdocument对象,或者一些特定的浏览器API。纯Python的execjs有时无法提供完整的模拟环境。
  3. 动态变化:加密逻辑和密钥可能随着时间或用户会话动态变化,增加了静态分析的难度。

面对JSVMP,通常有几种思路:

  • 完整模拟JS执行环境:使用PyExecJSPyMiniRacerNode.js子进程,直接执行扣出来的解密JS代码。优点是直接,缺点是环境补全麻烦,且执行效率可能较低。
  • 算法还原:通过静态分析和动态调试,理解其虚拟机的指令集和加密算法本质(如可能是变种的AES、DES,或自定义的位运算),然后用Python重新实现。这是最优雅、效率最高的方式,但难度也最大。
  • 自动化浏览器:使用SeleniumPlaywright,让真正的浏览器去执行JS并获取结果。这是“降维打击”,简单粗暴且有效,但资源消耗大,速度慢,不适合大规模爬取。

我们的选择是:算法还原为主,辅以关键JS代码模拟。原因在于,音乐数据爬取通常对时效性要求不是毫秒级,但需要稳定、可批量执行。完全依赖自动化浏览器,成百上千个任务会把机器拖垮。而如果能把核心解密逻辑用Python实现,我们的爬虫就能轻装上阵。当然,在逆向分析阶段,我们依然会重度依赖浏览器开发者工具和Node.js调试环境。

2.2 工具链准备:你的“手术刀”和“显微镜”

工欲善其事,必先利其器。以下是本次实战的核心工具栈,我会解释为什么选它们:

  1. 抓包与分析工具:Charles / Fiddler / 浏览器开发者工具 (Network面板)

    • 作用:捕获应用发出的所有HTTP/HTTPS请求,找到目标数据接口。重点关注XHRFetch类型的请求。
    • 选择理由:Charles和Fiddler能查看更底层的流量,包括可能被前端代码隐藏的请求。浏览器DevTools则更便捷,可以直接看到请求的调用栈(Initiator),这对于逆向追踪加密参数生成位置至关重要。
  2. JavaScript逆向与分析工具:Chrome DevTools (Sources面板) & Node.js

    • 作用Sources面板用于设置断点、单步调试、查看调用堆栈、监控变量变化,是动态分析的灵魂。Node.js环境则用于在本地隔离地执行和测试我们扣出来的JS代码片段。
    • 实操技巧:在Network面板找到目标请求后,右键选择Copy -> Copy as cURL,然后可以粘贴到命令行或Postman里重放,方便测试。在Sources面板搜索关键词如encryptdecryptsigntoken,或者直接搜索接口返回的密文特征串。
  3. Python核心库:requests, execjs, pycryptodome

    • requests:毋庸置疑,HTTP请求库。用于模拟请求,携带我们逆向出来的参数。
    • execjs:一个执行JavaScript代码的Python库。当我们无法完全用Python还原算法,或者需要执行一些环境初始化代码时,用它来调用我们整理好的JS函数。注意:它依赖本地安装的JS运行时(如Node.js)。
    • pycryptodome:一个强大的密码学库。当逆向发现加密算法是标准算法(如AES、RSA)的变种时,我们需要用它来实现对应的加解密。它比Python自带的crypto模块更全面、更活跃。
  4. 辅助工具:AST解析器、代码格式化工具

    • 作用:面对高度混淆的JS代码(变量名全是_0xabc123,代码挤成一团),我们需要先格式化,然后可能借助抽象语法树(AST)分析工具来简化代码流,识别出关键的控制结构和函数调用关系。在线工具如jsnice.org或本地库esprima可以帮上忙。

注意:整个分析过程请在法律允许和个人授权范围内进行,仅用于学习交流和技术研究,切勿用于侵犯他人合法权益或商业牟利。

3. 逆向工程实战:定位与剖析加密逻辑

3.1 接口定位与参数初探

我们以某音乐平台的歌曲列表接口为例。首先打开平台网页版,进入一个歌单页面,打开开发者工具的Network面板,刷新页面。

  1. 筛选请求:在筛选框输入apimusiclist等关键词,找到返回歌单JSON数据的请求。通常这种请求的Response看起来是一大串毫无规律的字符,或者是一个结构里包含data字段且其值为加密字符串。
  2. 分析请求参数:点击该请求,查看HeadersPayload。除了常见的cookieuser-agent,你需要特别关注Query String ParametersForm Data中那些看起来是随机生成的长字符串参数,比如paramsencSecKeysignature等。这些很可能就是加密后的参数。
  3. 追踪参数生成:在该请求的Initiator列,点击调用的JS文件,它会带你进入Sources面板对应的代码位置。这里就是加密发生的起点。

3.2 深入JSVMP:断点调试与逻辑追踪

进入加密JS文件后,你大概率会看到面目全非的代码。别慌,按以下步骤来:

  1. 格式化代码:在Sources面板,点击左下角的{}(Pretty-print)按钮,让混淆的代码变得可读一些。
  2. 搜索关键点:在格式化后的代码中,搜索上一步找到的加密参数名,如encSecKey。或者搜索可能包含加密逻辑的函数名,如window.asrsea(这是某平台曾用的函数名)。
  3. 下断点:在找到的疑似加密函数入口处,或是在包含参数赋值操作的行号前点击,设置一个断点。
  4. 触发断点:回到网页,进行一次能触发该接口的操作(如点击“加载更多”)。此时代码执行会在断点处暂停。
  5. 单步执行与观察
    • Step Into (F11):进入当前函数内部。
    • Step Over (F10):执行当前行,不进入函数内部。
    • Step Out (Shift+F11):跳出当前函数。
    • 在右侧的Scope面板,观察LocalClosure作用域中变量的值变化。在Call Stack面板查看函数调用链。
    • 核心目标:追踪传入加密函数的原始参数(明文)是什么,以及加密后的输出(密文)是什么。记录下这个映射关系。

一个典型的JSVMP结构你可能看到如下代码

// 经过混淆和虚拟化处理的代码示例 var _0x5c8d26 = function(_0x12b5e8, _0x2f5c7a) { // ... 一大堆位操作和数组操作 ... // 关键:这里可能是一个虚拟机的解释器循环 for (var _0x3a4b2c = 0; _0x3a4b2c < _0x12b5e8['length']; _0x3a4b2c++) { var _0x1a2b3c = _0x12b5e8[_0x3a4b3c]; // 根据 _0x1a2b3c (操作码) 执行不同的操作 switch (_0x1a2b3c) { case 0x1: ... // 操作1,可能是加法 case 0x2: ... // 操作2,可能是异或 // ... 更多case } } return _0x2f5c7a; // 返回处理后的结果 }; // 调用处 var encryptedData = _0x5c8d26(vmOpcodeArray, rawData);

你的任务就是理清这个_0x5c8d26函数,或者它内部调用的其他函数,到底对rawData做了哪些操作。

3.3 关键代码提取与简化

通过断点,我们定位到了核心加密函数。接下来需要把它“扣”出来。

  1. 复制函数及其依赖:在Sources面板,右键点击函数所在的行,选择Store as global variable。然后在Console中,输入copy(temp1)(假设存储的变量是temp1),将这个函数定义复制到剪贴板。但这通常不够,因为这个函数内部可能依赖了外部的其他变量或函数。
  2. 回溯依赖:在断点暂停时,查看Scope里该函数闭包中包含哪些外部变量。在Console中尝试执行这个函数,如果报错说某个变量未定义,就根据错误信息,去源代码中找到这个变量或函数的定义,一并复制出来。
  3. 构建独立执行环境:将复制出来的所有代码片段,整理到一个单独的.js文件中。开头需要补全一些浏览器环境下的对象,但只补用到的部分。例如:
    // 补全环境 var window = this; var navigator = {userAgent: ''}; var document = {cookie: ''}; // 这里放置你扣出来的所有函数和变量定义 function coreEncryptFunc(rawData) { // ... 扣出来的加密逻辑 } // 导出函数,方便Node.js或execjs调用 module.exports = { coreEncryptFunc };
  4. 本地测试:在Node.js环境中运行这个文件,传入一个已知的明文,看输出是否与浏览器中加密的结果一致。这个过程需要反复调试和补全缺失的环境或函数,直到能成功复现加密结果。

4. Python模拟实现:从JS到Python的跨越

4.1 方案一:使用execjs调用扣出的JS代码

这是最直接的方法,适用于加密逻辑复杂但我们已经成功扣出完整JS函数的情况。

  1. 安装依赖:确保安装了Node.js,然后在Python中安装PyExecJS
    pip install PyExecJS
  2. 准备JS文件:将上一步整理好的、包含coreEncryptFunc函数的JS文件保存为encrypt_logic.js
  3. Python调用
    import execjs import os # 获取JS文件路径 current_dir = os.path.dirname(os.path.abspath(__file__)) js_file_path = os.path.join(current_dir, 'encrypt_logic.js') # 读取JS代码 with open(js_file_path, 'r', encoding='utf-8') as f: js_code = f.read() # 创建执行上下文 ctx = execjs.compile(js_code) # 调用函数 raw_data = {"id": "123456", "limit": 30} # 模拟请求参数 # 注意:JS函数可能需要将参数转为特定格式,如JSON字符串 encrypted_params = ctx.call('coreEncryptFunc', raw_data) print(f"加密后的参数: {encrypted_params}")
    优点:开发快,能100%还原前端逻辑。缺点:执行效率相对较低(涉及进程间通信);依赖Node.js环境;如果JS代码依赖复杂的浏览器对象(如Canvas),补全会非常困难。

4.2 方案二:算法还原与Python原生实现

这是更高级、更优雅的方案。通过分析,我们发现某音乐平台的加密核心其实是两次AES加密,并用RSA加密了AES的密钥。虽然被JSVMP包装得很复杂,但本质可以还原。

  1. 逆向分析结论(以某平台历史版本为例,仅作演示):

    • 明文:需要发送的请求参数(一个JSON对象)。
    • 过程
      1. 将明文JSON字符串进行第一次AES加密(模式CBC,填充PKCS7),密钥key1和初始向量iv1是固定的16位字符串。
      2. 将上一步的结果(密文1)作为明文,进行第二次AES加密,使用另一个固定的密钥key2iv2。得到最终的params
      3. 生成一个随机的16字节字符串作为本次会话的AES密钥(secretKey)。
      4. 用固定的RSA公钥加密这个secretKey,得到encSecKey
    • 最终请求:携带paramsencSecKey发送请求。
  2. Python实现

    import json from Crypto.Cipher import AES, PKCS1_v1_5 from Crypto.PublicKey import RSA from Crypto.Util.Padding import pad import base64 import random import string class MusicDecryptor: def __init__(self): # 这些key和iv是通过逆向分析得到的固定值(示例值,非真实) self.first_aes_key = b'0CoJUm6Qyw8W8jud' self.first_aes_iv = b'0102030405060708' self.second_aes_key = b'a8LWv2uAtXjzSfk8' self.second_aes_iv = b'0102030405060708' # RSA公钥(示例) self.rsa_public_key = """-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...(很长一串) -----END PUBLIC KEY-----""" def _aes_encrypt(self, data: str, key: bytes, iv: bytes) -> str: """AES CBC PKCS7加密""" cipher = AES.new(key, AES.MODE_CBC, iv) encrypted_bytes = cipher.encrypt(pad(data.encode('utf-8'), AES.block_size)) return base64.b64encode(encrypted_bytes).decode('utf-8') def generate_params(self, raw_data: dict) -> tuple: """ 生成加密后的params和encSecKey :param raw_data: 原始请求参数字典 :return: (params, encSecKey) """ # 1. 将原始数据转为JSON字符串 text = json.dumps(raw_data) # 2. 第一次AES加密 params = self._aes_encrypt(text, self.first_aes_key, self.first_aes_iv) # 3. 第二次AES加密 params = self._aes_encrypt(params, self.second_aes_key, self.second_aes_iv) # 4. 生成随机AES密钥并RSA加密(模拟过程,实际平台可能更复杂) # 生成16位随机字符串作为本次的secretKey secret_key = ''.join(random.choices(string.ascii_letters + string.digits, k=16)).encode() # 加载RSA公钥 rsa_key = RSA.import_key(self.rsa_public_key) cipher_rsa = PKCS1_v1_5.new(rsa_key) # 加密secret_key得到encSecKey enc_seckey = base64.b64encode(cipher_rsa.encrypt(secret_key)).decode('utf-8') return params, enc_seckey def decrypt_response(self, encrypted_data: str) -> dict: """ 解密服务器返回的数据(如果返回数据也被加密) 需要逆向分析其解密逻辑,这里假设是AES解密 """ # 示例:假设返回数据是base64编码的AES加密数据 # key和iv可能需要从请求上下文或固定值获取 decrypt_key = b'xxx' # 需要通过逆向获取 decrypt_iv = b'xxx' cipher = AES.new(decrypt_key, AES.MODE_CBC, decrypt_iv) decrypted_bytes = cipher.decrypt(base64.b64decode(encrypted_data)) # 去除PKCS7填充 unpadded = decrypted_bytes[:-decrypted_bytes[-1]] return json.loads(unpadded.decode('utf-8')) # 使用示例 if __name__ == '__main__': decryptor = MusicDecryptor() raw_request = {'ids': '[123456789]', 'level': 'standard'} params, enc_seckey = decryptor.generate_params(raw_request) print(f'Params: {params}') print(f'EncSecKey: {enc_seckey}') # 然后用requests发送请求 # import requests # resp = requests.post('api_url', data={'params': params, 'encSecKey': enc_seckey}) # data = decryptor.decrypt_response(resp.json()['data']) # 如果返回数据加密

    优点:纯Python,执行效率高,不依赖外部环境,部署方便。缺点:逆向分析难度极大,需要深厚的密码学和代码分析功底。一旦平台更新加密逻辑,需要重新分析。

4.3 整合与请求模拟

无论采用哪种方案,最终都要整合到爬虫流程中:

import requests from your_decrypt_module import MusicDecryptor # 或使用execjs方案 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...', 'Referer': 'https://music.xxx.com/', 'Content-Type': 'application/x-www-form-urlencoded', # 可能还需要Cookie,可以通过模拟登录或手动获取后填入 } def get_playlist_detail(playlist_id): decryptor = MusicDecryptor() # 1. 构造原始请求参数 raw_data = { 'id': playlist_id, 'limit': 100, 'offset': 0, 'total': True, 'n': 1000, 'csrf_token': '' # 可能需要 } # 2. 生成加密参数 params, enc_seckey = decryptor.generate_params(raw_data) # 3. 发送请求 api_url = 'https://music.xxx.com/weapi/v3/playlist/detail' form_data = { 'params': params, 'encSecKey': enc_seckey } response = requests.post(api_url, data=form_data, headers=headers) resp_json = response.json() # 4. 处理响应(如果响应数据也加密了,需要解密) # if resp_json['code'] == 200: # real_data = decryptor.decrypt_response(resp_json['data']) # return real_data # else: # print(f"请求失败: {resp_json}") # return None # 假设该平台此接口返回未加密数据 return resp_json # 测试 detail = get_playlist_detail('123456789') if detail and detail['code'] == 200: for track in detail['playlist']['tracks']: print(f"歌曲: {track['name']}, 歌手: {track['ar'][0]['name']}")

5. 常见问题、排查技巧与优化策略

5.1 逆向分析阶段常见问题

  1. 断点无法命中或代码被动态加载

    • 现象:在Sources面板找到的代码文件里打了断点,但请求发生时断点不触发。
    • 排查:代码可能是通过evalFunction构造函数动态生成的。在Network面板,过滤js文件,寻找在页面加载后新请求的、较小的JS文件,它们很可能包含核心逻辑。或者,在开发者工具设置中开启JavaScript source maps支持(如果有的话)。更通用的方法是使用“事件监听器断点”(Event Listener Breakpoints),在Script类别下勾选Script First Statement,当有新脚本执行时会自动暂停。
  2. 扣出来的代码在Node.js中运行报错

    • 现象:提示window is not defined,document is not defined等。
    • 解决:这是环境缺失。需要在你的JS文件顶部模拟这些全局对象。但原则是“按需补全”,只补用到的属性和方法。例如:
      // 在Node.js环境头部添加 global.window = global; global.navigator = { userAgent: 'Mozilla/5.0 ...' }; // 如果代码用了`btoa`,Node.js没有,需要polyfill global.btoa = (str) => Buffer.from(str).toString('base64'); global.atob = (b64Encoded) => Buffer.from(b64Encoded, 'base64').toString();
    • 技巧:在浏览器Console中,执行console.log(typeof window.xxx)来检查代码依赖了哪些不存在的属性,然后针对性补全。
  3. 加密结果与浏览器不一致

    • 现象:同样的输入,你的JS代码或Python代码输出的密文和浏览器里抓包看到的不一样。
    • 排查步骤
      1. 输入一致性:确保你传入加密函数的原始字符串(包括空格、引号格式)与浏览器中完全一致。在浏览器断点处,使用JSON.stringify()查看变量的精确值。
      2. 密钥/IV一致性:确认使用的密钥、初始向量是否完全正确,包括字节长度和值。混淆代码里可能对字符串进行了额外的编码或转换。
      3. 算法细节:确认加密模式(CBC, ECB)、填充方式(PKCS7, ZeroPadding)、输出格式(Base64, Hex)。一个字符的差异都会导致结果不同。
      4. 随机数/时间戳:检查加密过程是否混入了随机数或时间戳。如果有,需要在你的代码里用相同的方式生成。

5.2 Python模拟阶段常见问题

  1. execjs报错RuntimeError或编码错误

    • 确保Node.js已安装且路径正确:在命令行输入node -vnpm -v检查。
    • 检查JS代码语法:将扣出的JS代码先在Node.js命令行里直接运行测试,排除语法错误。
    • 编码问题:确保读写JS文件时使用utf-8编码。execjs.compile()时也可以指定编码execjs.compile(js_code, encoding='utf-8')
  2. pycryptodome加解密结果不对

    • 对齐块大小:AES是块加密,明文长度必须是16字节的倍数。使用Crypto.Util.Padding.pad进行PKCS7填充,解密后用unpad去除填充。
    • 密钥和IV类型:确保传入AES.new()keyivbytes类型,而不是字符串。如果源数据是十六进制或Base64字符串,需要正确解码。
    • 模式匹配:确认加密模式。CBC模式需要IV,ECB模式不需要。GCM模式是认证加密,用法不同。
  3. 请求被服务器拒绝(403/412等)

    • 请求头不全:仔细比对浏览器发送的请求头,除了User-AgentRefererContent-Type,可能还需要OriginAcceptAccept-Language,甚至一些自定义头部。
    • Cookie失效或不全:某些接口需要登录态。你需要维护一个有效的会话Cookie。可以通过模拟登录获取,或者手动从浏览器复制一个短期可用的Cookie。注意Cookie的过期和更新。
    • 参数签名动态变化:除了paramsencSecKey,可能还有其他参数如_tokentimestampnonce需要动态生成并参与签名。这需要更全面的逆向分析。

5.3 性能与稳定性优化策略

  1. 缓存与复用:如果加密算法的密钥是固定的,或者RSA公钥长期不变,那么MusicDecryptor类实例可以全局复用,避免每次请求都重新初始化加密器。对于execjs方案,execjs.compile()创建上下文的过程开销较大,应该只执行一次,然后多次调用ctx.call

  2. 错误重试与降级:网络请求可能失败,接口可能临时变更。代码中应加入重试机制(如tenacity库)和异常捕获。对于execjs方案,可以准备一个降级策略,比如当JS执行失败时,尝试切换到算法还原的Python方案(如果已实现)。

  3. 日志与监控:详细记录每个请求的入参、加密结果、响应状态和内容。当解密失败时,保存下原始的加密响应数据,便于后续离线分析和算法更新。

  4. 应对算法更新:平台加密逻辑不会一成不变。最好的防御是主动监控。可以定期(如每天)运行一个简单的测试用例,用已知的明文加密后与历史正确密文对比,或者直接请求一个已知接口看返回数据是否还能正常解析。一旦发现不一致,立即触发告警,启动重新分析流程。

这个从JSVMP黑盒到Python透明解密的旅程,本质上是一场耐心的较量。它没有一成不变的公式,每一个平台、甚至同一个平台的不同接口都可能有所不同。但核心方法论是相通的:抓包定位、断点调试、逻辑分析、代码提取、模拟实现。掌握这套流程,再配合细致的观察和不断的试错,绝大多数前端加密的“城墙”都能找到突破口。最后再次强调,技术的学习是为了更好地理解系统与创造,请务必在合法合规的范围内运用这些知识。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询