Python逆向JSVMP加密:从原理到实战,破解音乐平台反爬参数

发布时间:2026/7/16 15:05:17
Python逆向JSVMP加密:从原理到实战,破解音乐平台反爬参数 1. 项目概述与核心挑战最近在做一个音乐数据采集的小工具目标平台是国内某主流音乐服务。和很多爬虫开发者一样我遇到了那个经典的“拦路虎”请求参数被加密了。这可不是简单的sign或者token而是一套完整的JSVMPJavaScript Virtual Machine Protection保护方案。简单来说网站把核心的加密逻辑放到了一段被虚拟机保护的JavaScript代码里传统的“找到加密函数直接扣出来用”的方法在这里基本失效。你看到的是一堆高度混淆、自执行的代码核心逻辑被虚拟指令操作直接阅读和调试的难度呈指数级上升。我的目标很明确就是用Python实现对这个最新版JSVMP加密参数的逆向让爬虫能稳定地构造出合法的请求。这不仅仅是写一个能跑的脚本更是一次对前端反爬技术深水区的探索。JSVMP、OB混淆Obfuscation这些词听起来就让人头大但它们背后代表的是一种越来越普遍的保护思路不让你轻易看懂逻辑。对于爬虫开发者而言这要求我们必须从“找函数”升级到“理解执行流程和虚拟机原理”。本次逆向的核心就是穿透这层虚拟机的迷雾定位到参数生成的最终逻辑并用Python将其复现出来。整个过程会涉及浏览器动态调试、堆栈分析、逻辑还原和Python代码重构我会把踩过的坑和最终验证通过的代码都分享出来。2. JSVMP加密原理与逆向思路拆解在动手之前我们必须先搞清楚对手是什么。JSVMP即JavaScript虚拟机保护是一种高级的代码混淆和反调试技术。它的核心思想是不直接执行原始的JavaScript逻辑而是将逻辑编译成一套自定义的字节码或中间代码然后通过一个用JavaScript编写的“虚拟机”来解释执行这些字节码。2.1 JSVMP是如何工作的想象一下你有一本用英文写成的操作手册原始JS逻辑。为了保密你发明了一套只有自己懂的密码符号字节码并写了一本密码翻译规则虚拟机。现在你把操作手册翻译成密码符号发给别人。别人即使拿到了密码符号和翻译规则也需要费很大功夫才能理解原始操作是什么。JSVMP就是这个过程。代码转换开发者工具中看到的是一大坨高度混淆的JS代码其中包含一个巨大的数组字节码数组和一个复杂的switch-case或循环结构虚拟机解释器。虚拟机解释执行虚拟机解释器读取字节码数组根据不同的指令码通常就是数组里的数字执行对应的操作如加法、减法、函数调用、内存访问等。这些操作最终会完成诸如MD5、AES、RSA或自定义哈希等加密计算。环境依赖为了对抗模拟执行JSVMP的实现往往严重依赖浏览器环境比如window、document、location对象甚至是某些DOM操作的时序。2.2 我们的逆向策略不还原虚拟机定位关键点完全还原整个虚拟机指令集和逻辑是浩大的工程对于爬虫需求来说性价比极低。我们的策略是“擒贼先擒王”动态调试定位入口在浏览器开发者工具中对加密参数如paramsencSecKey的生成位置下XHR断点或全局搜索。由于代码被混淆函数名可能是b、c、d这类无意义字符。我们的目标是找到最终返回加密字符串的那个函数出口。追溯调用栈在出口函数处暂停查看JavaScript调用栈Call Stack。调用栈会显示这个函数是被谁调用的一层层往上找我们就能找到最外层的、可能是虚拟机调度器的入口函数。日志插桩与逻辑分析在关键的虚拟机操作如涉及加密初始化的指令前后通过console.log打印出此时的虚拟机状态如寄存器、内存数组的值、输入参数和输出结果。通过对比多次请求的日志找出参数生成的规律。提取核心算法分析发现虽然外层是虚拟机但其内部调用的加密核心比如某个标准的哈希函数或对称加密很可能是标准的JavaScript实现。我们的目标就是定位到这个核心算法函数并把它从虚拟机的包裹中“剥离”出来。Python复现将剥离出来的核心加密逻辑用Python的相应库如hashlibCrypto进行复现。对于自定义的算法则需根据日志分析出的逻辑用Python代码一步步实现。注意逆向工程可能涉及法律和道德风险。务必确保你的行为符合目标网站的服务条款仅用于个人学习和技术研究不得用于大规模商业爬取、侵犯版权或干扰网站正常服务。3. 逆向实战动态调试与关键逻辑定位理论说再多不如实际操作一遍。我以某音乐平台的歌曲列表API请求为例。打开浏览器开发者工具F12进入Network网络面板清空记录然后触发一次歌曲列表的加载。3.1 寻找加密参数在请求列表中找到目标API比如/weapi/v1/play/record。查看它的请求负载Payload会发现有两个关键的加密字段params和encSecKey。这两个字段都是很长的一段看似随机的字符串这就是我们的目标。3.2 下断点与调用栈分析全局搜索在Sources源代码面板按CtrlShiftFWindows全局搜索params或encSecKey。由于代码被压缩可能直接搜索不到赋值语句。可以尝试搜索encSecKey的一部分或者搜索类似params这样的字符串字面量。XHR/Fetch断点在Network面板找到该请求的URL右键选择“Copy - Copy link address”。然后转到Sources面板的XHR/Fetch Breakpoints区域点击“”号将复制的URL部分粘贴进去。这样任何向该URL发起的请求都会在发起前被断住。触发断点刷新页面或再次触发请求。脚本会在发起请求前暂停。此时看调用栈通常最上面是浏览器内置的send方法往下找会出现一堆被混淆过的函数如window.asdfgr[o]等。这些就是网站的加密代码。单步跟踪在调用栈中选择一个看起来像是加密逻辑起点的函数比如一个接收明文参数返回加密结果的函数点击它代码会定位到对应位置。虽然代码混淆严重但我们可以通过单步执行F10和步入F11结合右侧的Scope作用域面板观察变量值的变化。3.3 插桩与日志分析在疑似加密核心的代码区域我们可以通过修改代码来打印信息。在Sources面板找到对应行点击行号左侧设置断点然后右键选择“Edit breakpoint...”输入条件如console.log(当前值, e, r, t)。或者更直接地在代码行上右键选择“Add source code...”插入一行debugger;语句并保存通常需要在一个Overrides的本地文件夹中保存才能生效这样每次执行到这里都会暂停。通过反复执行、观察我发现关键逻辑集中在某个巨大的switch语句中它根据一个指令数组来执行操作。我重点关注了那些对输入参数歌曲ID、用户ID等进行操作并最终生成params和encSecKey的指令分支。实操心得不要试图理解每一行混淆代码。关注数据流。找到明文参数从哪里传入经过哪些关键操作比如看到调用了CryptoJS.MD5或者看到一个明显的字符串拼接、切片操作最终流向了哪里。把这些关键节点用console.log标记出来。4. 核心加密逻辑剥离与Python复现经过一番“斗智斗勇”我剥离出了核心的加密逻辑。它并不是一个标准的AES或RSA而是一个自定义的加密过程结合了AES和自定义填充模式。以下是还原后的逻辑和Python实现。4.1 加密流程解析假设明文数据是一个JSON字符串如{uid: 123456, sid: 789}。加密过程大致如下随机生成一个16字节的密钥secretKey用于AES加密。对明文进行AES加密使用secretKey采用CBC模式PKCS7填充对一个固定的IV进行加密得到cipherText。对secretKey本身进行非对称加密使用一个固定的RSA公钥对secretKey进行加密得到encSecKey。这就是请求参数中的encSecKey。对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 b0102030405060708 # 固定的IV16字节 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, stylepkcs7) 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(fparams: {params}) print(fencSecKey: {encSecKey})关键点解析AES IV这是一个固定值必须与JS代码中使用的一致。我是在调试时在AES初始化函数附近通过console.log打印出来的。RSA公钥这是整个加密的“锁”。需要在JS代码中找到公钥字符串。通常它可能以模数modulus和指数exponent的形式存在需要组合成PEM格式。我在代码中搜索PUBLIC KEY或modulus找到了它。密钥处理注意secret_key在JS中可能被处理过比如转成了Hex字符串或Base64。我通过对比Python和JS在相同输入下的中间结果secret_key的Hex值来确保一致性。JSON序列化json.dumps时使用separators(,, :)来去除空格因为JS的JSON.stringify默认输出是无空格的。5. 逆向过程中的常见问题与排查技巧即使有了清晰的思路和代码在实际操作中还是会遇到各种诡异的问题。下面是我踩过的一些坑和解决方法。5.1 问题一加密结果与JS不一致这是最常见的问题。可能的原因和排查步骤检查输入一致性确保传入加密函数的明文字符串完全一致。包括所有键值对、顺序、空格、引号。最好在JS加密入口处用console.log(JSON.stringify(data))打印出来然后在Python里用同样的字符串进行加密。检查AES参数密钥Key长度必须是16、24或32字节对应AES-128, AES-192, AES-256。确认JS中生成的是16字节的随机数并且Python中生成的随机字节长度一致。IV确认IV值完全一致包括字节内容。有时JS中的IV可能是字符串需要正确编码为字节。模式与填充确认都是CBC模式和PKCS7填充。pycryptodome的pad函数默认就是PKCS7。检查RSA加密公钥格式确保PEM格式的公钥是正确的没有多余空格或换行错误。可以使用在线工具或RSA.import_key时捕获异常来验证。加密填充JS中RSA加密通常使用PKCS1_v1_5填充。pycryptodome的PKCS1_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可以使用execjs或PyExecJS库并提前在全局对象上定义好缺失的浏览器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 b0102030405060708 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(retries3, backoff_factor0.5): session requests.Session() retry_strategy Retry( totalretries, backoff_factorbackoff_factor, status_forcelist[429, 500, 502, 503, 504], ) adapter HTTPAdapter(max_retriesretry_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] }, timeout10) resp.raise_for_status() # 检查HTTP错误 return resp.json() except requests.exceptions.RequestException as e: print(f请求失败: {e}) # 这里可以加入重试逻辑或告警 return None6.3 监控与自动更新最头疼的是加密逻辑突然失效。可以建立简单的监控机制心跳检测定期如每小时用加密函数请求一个简单的API如获取用户信息。如果返回状态码异常或数据不符合预期则触发告警。关键词监控定期下载目标页面的主JS文件检查其中是否包含RSA公钥、IV等关键字符串。如果这些字符串消失了或改变了说明加密逻辑已更新。降级方案如果自动更新不可行至少要让失败快速暴露并准备好手动介入的流程。逆向JSVMP加密是一个需要耐心、细心和一定运气的技术活。它没有一成不变的公式每一次都是新的挑战。但核心思路是相通的动态调试定位、逻辑分析剥离、数据对比验证。成功的那一刻看到自己Python代码生成的参数能够成功请求到数据那种成就感是对所有努力最好的回报。记住保持学习尊重规则将技术用在正途。