Python逆向JSVMP加密:从原理到实战,破解音乐平台反爬参数
2026/7/27 5:46:26 网站建设 项目流程

1. 项目概述与核心挑战

最近在做一个音乐数据采集的小工具,目标平台是国内某主流音乐服务。和很多爬虫开发者一样,我遇到了那个经典的“拦路虎”:请求参数被加密了。这可不是简单的sign或者token,而是一套完整的JSVMP(JavaScript Virtual Machine Protection)保护方案。简单来说,网站把核心的加密逻辑放到了一段被虚拟机保护的JavaScript代码里,传统的“找到加密函数,直接扣出来用”的方法在这里基本失效。你看到的是一堆高度混淆、自执行的代码,核心逻辑被虚拟指令操作,直接阅读和调试的难度呈指数级上升。我的目标很明确,就是用Python实现对这个最新版JSVMP加密参数的逆向,让爬虫能稳定地构造出合法的请求。

这不仅仅是写一个能跑的脚本,更是一次对前端反爬技术深水区的探索。JSVMP、OB混淆(Obfuscation)这些词听起来就让人头大,但它们背后代表的是一种越来越普遍的保护思路:不让你轻易看懂逻辑。对于爬虫开发者而言,这要求我们必须从“找函数”升级到“理解执行流程和虚拟机原理”。本次逆向的核心,就是穿透这层虚拟机的迷雾,定位到参数生成的最终逻辑,并用Python将其复现出来。整个过程会涉及浏览器动态调试、堆栈分析、逻辑还原和Python代码重构,我会把踩过的坑和最终验证通过的代码都分享出来。

2. JSVMP加密原理与逆向思路拆解

在动手之前,我们必须先搞清楚对手是什么。JSVMP,即JavaScript虚拟机保护,是一种高级的代码混淆和反调试技术。它的核心思想是:不直接执行原始的JavaScript逻辑,而是将逻辑编译成一套自定义的字节码(或中间代码),然后通过一个用JavaScript编写的“虚拟机”来解释执行这些字节码。

2.1 JSVMP是如何工作的?

想象一下,你有一本用英文写成的操作手册(原始JS逻辑)。为了保密,你发明了一套只有自己懂的密码符号(字节码),并写了一本密码翻译规则(虚拟机)。现在,你把操作手册翻译成密码符号发给别人。别人即使拿到了密码符号和翻译规则,也需要费很大功夫才能理解原始操作是什么。JSVMP就是这个过程。

  1. 代码转换:开发者工具中看到的是一大坨高度混淆的JS代码,其中包含一个巨大的数组(字节码数组)和一个复杂的switch-case或循环结构(虚拟机解释器)。
  2. 虚拟机解释执行:虚拟机解释器读取字节码数组,根据不同的指令码(通常就是数组里的数字),执行对应的操作(如加法、减法、函数调用、内存访问等)。这些操作最终会完成诸如MD5AESRSA或自定义哈希等加密计算。
  3. 环境依赖:为了对抗模拟执行,JSVMP的实现往往严重依赖浏览器环境,比如windowdocumentlocation对象,甚至是某些DOM操作的时序。

2.2 我们的逆向策略:不还原虚拟机,定位关键点

完全还原整个虚拟机指令集和逻辑是浩大的工程,对于爬虫需求来说性价比极低。我们的策略是“擒贼先擒王”:

  1. 动态调试,定位入口:在浏览器开发者工具中,对加密参数(如paramsencSecKey)的生成位置下XHR断点或全局搜索。由于代码被混淆,函数名可能是bcd这类无意义字符。我们的目标是找到最终返回加密字符串的那个函数出口。
  2. 追溯调用栈:在出口函数处暂停,查看JavaScript调用栈(Call Stack)。调用栈会显示这个函数是被谁调用的,一层层往上找,我们就能找到最外层的、可能是虚拟机调度器的入口函数。
  3. 日志插桩与逻辑分析:在关键的虚拟机操作(如涉及加密初始化的指令)前后,通过console.log打印出此时的虚拟机状态(如寄存器、内存数组的值)、输入参数和输出结果。通过对比多次请求的日志,找出参数生成的规律。
  4. 提取核心算法:分析发现,虽然外层是虚拟机,但其内部调用的加密核心(比如某个标准的哈希函数或对称加密)很可能是标准的JavaScript实现。我们的目标就是定位到这个核心算法函数,并把它从虚拟机的包裹中“剥离”出来。
  5. Python复现:将剥离出来的核心加密逻辑,用Python的相应库(如hashlibCrypto)进行复现。对于自定义的算法,则需根据日志分析出的逻辑,用Python代码一步步实现。

注意:逆向工程可能涉及法律和道德风险。务必确保你的行为符合目标网站的服务条款,仅用于个人学习和技术研究,不得用于大规模商业爬取、侵犯版权或干扰网站正常服务。

3. 逆向实战:动态调试与关键逻辑定位

理论说再多不如实际操作一遍。我以某音乐平台的歌曲列表API请求为例。打开浏览器开发者工具(F12),进入Network(网络)面板,清空记录,然后触发一次歌曲列表的加载。

3.1 寻找加密参数

在请求列表中,找到目标API(比如/weapi/v1/play/record)。查看它的请求负载(Payload),会发现有两个关键的加密字段:paramsencSecKey。这两个字段都是很长的一段看似随机的字符串,这就是我们的目标。

3.2 下断点与调用栈分析

  1. 全局搜索:在Sources(源代码)面板,按Ctrl+Shift+F(Windows)全局搜索paramsencSecKey。由于代码被压缩,可能直接搜索不到赋值语句。可以尝试搜索encSecKey的一部分,或者搜索类似"params"这样的字符串字面量。
  2. XHR/Fetch断点:在Network面板,找到该请求的URL,右键选择“Copy -> Copy link address”。然后转到Sources面板的XHR/Fetch Breakpoints区域,点击“+”号,将复制的URL部分粘贴进去。这样,任何向该URL发起的请求都会在发起前被断住。
  3. 触发断点:刷新页面或再次触发请求。脚本会在发起请求前暂停。此时看调用栈,通常最上面是浏览器内置的send方法,往下找,会出现一堆被混淆过的函数(如window.asdfgr[o]等)。这些就是网站的加密代码。
  4. 单步跟踪:在调用栈中,选择一个看起来像是加密逻辑起点的函数(比如一个接收明文参数,返回加密结果的函数),点击它,代码会定位到对应位置。虽然代码混淆严重,但我们可以通过单步执行(F10)步入(F11),结合右侧的Scope(作用域)面板观察变量值的变化。

3.3 插桩与日志分析

在疑似加密核心的代码区域,我们可以通过修改代码来打印信息。在Sources面板找到对应行,点击行号左侧设置断点,然后右键选择“Edit breakpoint...”,输入条件如console.log('当前值:', e, r, t)。或者更直接地,在代码行上右键选择“Add source code...”,插入一行debugger;语句并保存(通常需要在一个Overrides的本地文件夹中保存才能生效),这样每次执行到这里都会暂停。

通过反复执行、观察,我发现关键逻辑集中在某个巨大的switch语句中,它根据一个指令数组来执行操作。我重点关注了那些对输入参数(歌曲ID、用户ID等)进行操作,并最终生成paramsencSecKey的指令分支。

实操心得:不要试图理解每一行混淆代码。关注数据流。找到明文参数从哪里传入,经过哪些关键操作(比如看到调用了CryptoJS.MD5,或者看到一个明显的字符串拼接、切片操作),最终流向了哪里。把这些关键节点用console.log标记出来。

4. 核心加密逻辑剥离与Python复现

经过一番“斗智斗勇”,我剥离出了核心的加密逻辑。它并不是一个标准的AES或RSA,而是一个自定义的加密过程,结合了AES和自定义填充模式。以下是还原后的逻辑和Python实现。

4.1 加密流程解析

假设明文数据是一个JSON字符串,如{"uid": "123456", "sid": "789"}。加密过程大致如下:

  1. 随机生成一个16字节的密钥(secretKey:用于AES加密。
  2. 对明文进行AES加密:使用secretKey,采用CBC模式,PKCS7填充,对一个固定的IV进行加密,得到cipherText
  3. secretKey本身进行非对称加密:使用一个固定的RSA公钥,对secretKey进行加密,得到encSecKey。这就是请求参数中的encSecKey
  4. cipherText进行编码:将AES加密后的cipherText进行Base64编码,得到params

所以,params是加密后的数据,encSecKey是加密数据所用密钥的密文。服务器用私钥解密encSecKey得到secretKey,再用secretKey解密params得到原始数据。

4.2 Python代码实现

我们需要用到pycryptodome库来进行AES和RSA操作。首先安装:pip install pycryptodome

import json import base64 from Crypto.Cipher import AES, PKCS1_v1_5 from Crypto.PublicKey import RSA from Crypto.Util.Padding import pad from Crypto.Random import get_random_bytes import binascii class MusicEncryptor: def __init__(self): # 这些固定值是从JS代码中逆向出来的 self.aes_mode = AES.MODE_CBC self.aes_iv = b'0102030405060708' # 固定的IV,16字节 self.rsa_pub_key = """-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQD...(此处替换为实际逆向出的公钥字符串) -----END PUBLIC KEY-----""" # 注意:公钥通常是连续的字符串,需要按格式组装成PEM格式 def _aes_encrypt(self, text, key): """AES加密,CBC模式,PKCS7填充""" cipher = AES.new(key, self.aes_mode, self.aes_iv) padded_text = pad(text.encode('utf-8'), AES.block_size, style='pkcs7') encrypted_bytes = cipher.encrypt(padded_text) return encrypted_bytes def _rsa_encrypt(self, data): """RSA加密,使用PKCS1_v1_5填充模式""" key = RSA.import_key(self.rsa_pub_key) cipher = PKCS1_v1_5.new(key) # RSA加密的数据长度有限制,需要分段加密,但此处key是16字节,通常可直接加密 encrypted_bytes = cipher.encrypt(data) return encrypted_bytes def encrypt_params(self, data_dict): """ 主加密函数 :param data_dict: 需要加密的参数字典 :return: (params, encSecKey) """ # 1. 将字典转为JSON字符串 text = json.dumps(data_dict, separators=(',', ':')) # 去除空格,与JS保持一致 # 2. 随机生成16字节的AES密钥 secret_key = get_random_bytes(16) # 3. 用AES加密文本 cipher_text = self._aes_encrypt(text, secret_key) # 4. 将AES密文进行Base64编码,得到params params = base64.b64encode(cipher_text).decode('utf-8') # 5. 用RSA公钥加密AES密钥,得到encSecKey # 注意:JS中可能对密钥进行了反转或其它处理,需根据实际情况调整 # 例如,有时需要将密钥字符串化(hex或base64)后再加密 secret_key_hex = secret_key.hex() # 或 binascii.b2a_hex(secret_key).decode() # 假设JS中是直接将16字节Buffer进行RSA加密 enc_secret_key = self._rsa_encrypt(secret_key) # 6. 将RSA加密结果转为16进制字符串,得到encSecKey encSecKey = enc_secret_key.hex() return params, encSecKey # 使用示例 if __name__ == '__main__': encryptor = MusicEncryptor() data = { "csrf_token": "", "type": "1", "uid": "123456789", "limit": "30", "offset": "0" } params, encSecKey = encryptor.encrypt_params(data) print(f"params: {params}") print(f"encSecKey: {encSecKey}")

关键点解析

  • AES IV:这是一个固定值,必须与JS代码中使用的一致。我是在调试时,在AES初始化函数附近通过console.log打印出来的。
  • RSA公钥:这是整个加密的“锁”。需要在JS代码中找到公钥字符串。通常它可能以模数(modulus)和指数(exponent)的形式存在,需要组合成PEM格式。我在代码中搜索PUBLIC KEYmodulus找到了它。
  • 密钥处理:注意secret_key在JS中可能被处理过(比如转成了Hex字符串或Base64)。我通过对比Python和JS在相同输入下的中间结果(secret_key的Hex值)来确保一致性。
  • JSON序列化json.dumps时使用separators=(',', ':')来去除空格,因为JS的JSON.stringify默认输出是无空格的。

5. 逆向过程中的常见问题与排查技巧

即使有了清晰的思路和代码,在实际操作中还是会遇到各种诡异的问题。下面是我踩过的一些坑和解决方法。

5.1 问题一:加密结果与JS不一致

这是最常见的问题。可能的原因和排查步骤:

  1. 检查输入一致性:确保传入加密函数的明文字符串完全一致。包括所有键值对、顺序、空格、引号。最好在JS加密入口处用console.log(JSON.stringify(data))打印出来,然后在Python里用同样的字符串进行加密。
  2. 检查AES参数
    • 密钥(Key):长度必须是16、24或32字节(对应AES-128, AES-192, AES-256)。确认JS中生成的是16字节的随机数,并且Python中生成的随机字节长度一致。
    • IV:确认IV值完全一致,包括字节内容。有时JS中的IV可能是字符串,需要正确编码为字节。
    • 模式与填充:确认都是CBC模式和PKCS7填充。pycryptodomepad函数默认就是PKCS7。
  3. 检查RSA加密
    • 公钥格式:确保PEM格式的公钥是正确的,没有多余空格或换行错误。可以使用在线工具或RSA.import_key时捕获异常来验证。
    • 加密填充:JS中RSA加密通常使用PKCS1_v1_5填充。pycryptodomePKCS1_v1_5.new(key).encrypt()与之对应。
    • 输入数据:确认传递给RSA加密函数的数据(即AES密钥)的格式(是Buffer/字节数组,还是Hex字符串)与JS中完全一致。

排查工具:在Python端,可以在每个加密步骤后,将中间结果(如密钥Hex、加密后的字节)打印出来。在JS端,在对应位置插入console.log,比较两者的输出。从第一步开始逐级比对,定位第一个出现差异的环节。

5.2 问题二:JS代码动态变化,断点失效

网站可能会更新反爬策略,或者使用动态加载的JS文件,导致之前找到的断点位置失效。

  • 持久化脚本:在浏览器的Overrides功能(Sources面板 -> Overrides)中,将关键的JS文件保存到本地,并插入你的调试代码(如console.log)。这样即使页面刷新,也会使用你修改过的本地文件。
  • Hook关键函数:在控制台(Console)中注入代码,钩住(Hook)像JSON.stringifyXMLHttpRequest.prototype.sendfetch等原生函数,在它们被调用时打印参数和堆栈。这可以帮助你快速定位加密入口,即使代码变了,入口函数通常不变。
    // 示例:Hook XHR的send方法 (function() { var originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(body) { console.trace('XHR send called with body:', body); // 如果URL包含目标API关键词,可以在这里下debugger if (this._url && this._url.indexOf('weapi') !== -1) { debugger; } return originalSend.apply(this, arguments); }; })();

    注意:需要先获取_url,通常可以在open方法里钩住并保存。

5.3 问题三:环境依赖检测

JSVMP代码可能会检测浏览器环境,比如检查windowdocumentnavigator对象是否存在或具有某些属性。在Node.js或纯Python环境中运行扣出来的JS代码可能会失败。

  • 补环境:在Node.js中,可以使用jsdom库来模拟一个浏览器环境。在Python中,如果必须执行JS,可以使用execjsPyExecJS库,并提前在全局对象上定义好缺失的浏览器API。
  • 核心提取:更好的方法是彻底分析JS代码,只提取出与加密直接相关的、不依赖浏览器环境的纯计算函数。这需要更深入的代码分析能力,但一旦成功,代码会更简洁、高效。

5.4 问题四:加密算法并非标准AES/RSA

有时网站会使用自定义的加密算法,或者对标准算法进行魔改(如修改S盒、改变轮函数顺序)。这就需要通过日志进行更细致的算法还原。

  • 数据流跟踪:记录下所有对输入数据和中间变量的操作。如果看到大量的位运算(&|^<<>>),那很可能是一个自定义的哈希或对称加密。
  • 对比测试:构造简单的输入(如全0、全1、递增序列),观察输出。尝试用已知的算法(如CRC32, MD5, SHA1)对同样输入进行计算,看是否匹配。如果不匹配,就需要根据记录的操作步骤,在Python中手动实现一遍。
  • 寻找特征:在JS代码中搜索encryptdecrypthashCryptoJS等关键词,可能会找到被引用的标准库,从而确认算法。

6. 工程化与稳定性优化

当加密逻辑跑通后,我们需要把它集成到一个健壮的爬虫项目中,并考虑长期稳定性。

6.1 代码封装与配置管理

将加密类独立成一个模块,如encryptor.py。所有固定参数(如RSA公钥、AES IV)作为类属性或从配置文件读取。这样当网站更新密钥时,只需修改一处。

# config.py class MusicConfig: RSA_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----""" AES_IV = b'0102030405060708' API_BASE_URL = 'https://music.example.com' # encryptor.py from config import MusicConfig class MusicEncryptor: def __init__(self): self.aes_iv = MusicConfig.AES_IV self.rsa_pub_key = MusicConfig.RSA_PUBLIC_KEY # ... 其余初始化

6.2 请求会话与错误处理

使用requests.Session()来保持会话,自动处理cookies。为请求添加完善的错误处理、重试机制和超时设置。

import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def create_session(retries=3, backoff_factor=0.5): session = requests.Session() retry_strategy = Retry( total=retries, backoff_factor=backoff_factor, status_forcelist=[429, 500, 502, 503, 504], ) adapter = HTTPAdapter(max_retries=retry_strategy) session.mount('http://', adapter) session.mount('https://', adapter) session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...', 'Referer': 'https://music.example.com/', }) return session def make_request(session, url, encrypted_params): try: resp = session.post(url, data={ 'params': encrypted_params[0], 'encSecKey': encrypted_params[1] }, timeout=10) resp.raise_for_status() # 检查HTTP错误 return resp.json() except requests.exceptions.RequestException as e: print(f"请求失败: {e}") # 这里可以加入重试逻辑或告警 return None

6.3 监控与自动更新

最头疼的是加密逻辑突然失效。可以建立简单的监控机制:

  1. 心跳检测:定期(如每小时)用加密函数请求一个简单的API(如获取用户信息)。如果返回状态码异常或数据不符合预期,则触发告警。
  2. 关键词监控:定期下载目标页面的主JS文件,检查其中是否包含RSA公钥、IV等关键字符串。如果这些字符串消失了或改变了,说明加密逻辑已更新。
  3. 降级方案:如果自动更新不可行,至少要让失败快速暴露,并准备好手动介入的流程。

逆向JSVMP加密是一个需要耐心、细心和一定运气的技术活。它没有一成不变的公式,每一次都是新的挑战。但核心思路是相通的:动态调试定位、逻辑分析剥离、数据对比验证。成功的那一刻,看到自己Python代码生成的参数能够成功请求到数据,那种成就感是对所有努力最好的回报。记住,保持学习,尊重规则,将技术用在正途。

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

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

立即咨询