☰
JS逆向实战:反调试绕过与AES加密参数解密全流程
2026/10/20 5:16:26 网站建设 项目流程

做爬虫的人,几乎都会在某一天对着浏览器开发者工具里那一大段看不懂的加密参数发愁。视频站点尤其典型——接口返回一串又乱又长的密文,页面上却照样解码播放,中间那层“解密盒子”就藏在JS代码的某个角落。今天要聊的正是这类场景里最常见的两道坎:反debugger机制和AES加密参数。我会拿一个虚拟的“某动漫视频站”逆向过程当主线,把定位加密逻辑、识别算法类型、绕开反调试、最后在Python端复现解密的一整套流程讲透。内容限定在技术学习与安全研究范畴,适合有爬虫基础、刚想进阶JS逆向的朋友参考。

1. 加密参数逆向的整体思路:定位比写代码更重要

1.1 先从Network面板找到信息量最大的那个请求

很多新手拿到一个目标页面,第一反应就是去看网页源代码,在HTML里搜关键字,这基本属于白费力气。真正有价值的东西不在HTML里,而在Network面板的XHR请求里。浏览器开发者工具F12打开,切换到Network,刷新页面,你会看到密密麻麻的请求列表。这时候不要慌,重点是过滤出XHR/Fetch请求,按名称或请求载荷里包含的字段去找那个“最可疑”的家伙。

以视频站点为例,页面上通常会有好几个接口:获取用户信息的、获取剧集列表的、获取播放地址的。其中获取播放地址的接口往往是最特殊的,因为它的响应体一般不是明文JSON,而是一整段看起来像乱码的字符串。这个字符串大概率就是AES加密后的密文,我们要做的第一步,就是把它从几十个请求里精准摘出来。

我有一个实操习惯:打开Network后,先在筛选栏里输入“m3u8”或“mp4”之类的关键词。如果接口是加密的,那么响应的虽然不会是明文的m3u8,但请求参数或者JS文件名里经常会带点提示,比如getplayinfo、playurl、videosign这些单词,总有一个会命中。找到了这个请求,就能顺着它后面的JS调用栈往上层爬。

定位这个关键请求的时候,我建议随手点开它的Payload和Response,把请求参数、响应体原样复制保存下来。后面调试Python脚本时,这些都是现成的对照组。你只有知道“正常的请求长什么样”,才能在移植到Python里时,一眼看出哪个参数丢了、哪个请求头漏了。

1.2 从请求参数反查JS调用链:两条最实用的路径

拿到关键请求之后,下一步是搞清楚这个请求是由哪段JS代码发起的。这里我最常用的方法有两个,各有利弊。

第一种是直接在Sources面板里搜索参数名。比如请求体里有个参数叫encrypt_value,我就在Sources里按下Ctrl+Shift+F(全局搜索),输入这个字段名。JS代码里只要出现过这个字符串,都会被搜出来。搜索结果里可能有好几处命中的地方,从里面找ajax、axios、fetch这种调用附近的代码,基本就能定位到加密函数。

第二种方法更稳,就是用XHR/fetch断点。在Sources面板右侧的XHR/fetch breakpoints区域,添加一条符合请求URL关键词的断点规则,比如输入play或encrypt。刷新页面后,当浏览器发这个请求之前,执行就会断在发起请求的代码行上,你可以直接顺着调用栈往上点,从最底层一路点上来,每一步的变量值都清清楚楚。这种方法对付那些把参数名藏得很深、直接搜不出东西的场景特别有效。

我个人的习惯是先用方法一尝试,因为快。如果搜索出来的命中场景太多,无法一眼判断,再切到方法二。很多时候两者结合着用:先用断点找到调用栈,再把内存里的真实变量值记录下来,跟堆栈里的代码做对照。这比凭空猜测要高效太多了。说到底,逆向的本质不是跟代码较劲,而是跟着数据流走:看到密文,找到加密前的那块输入,再把输入和输出之间的关系看明白。

1.3 识别加密算法:AES、RSA、还是自定义魔改

定位到加密函数之后,就要识别它具体属于哪一种加密。这一步很像做指纹识别。AES加密在JS里最常见的特征是引用了CryptoJS库,比如代码里会出现CryptoJS.AES.encrypt、CryptoJS.enc.Utf8.parse、CryptoJS.mode.CBC这样的写法。RSA加密则经常用某个JSEncrypt库,代码里能看到setPublicKey、JSEncrypt这类关键字。还有一些站点会直接用Base64、ASCII码偏移、异或运算之类的简单手段做“防君子不防小人”的加密,一眼就能看穿。

这里要提醒一下:别看到了AES.encrypt就兴奋,密文的格式有时候会给你下套。比如某些开发者在加密之后又做了URL编码、十六进制转码、或者自定义的字符替换,让你解密时先多一步还原处理。所以拿到密文后,先仔细观察它的特征。CBC模式下的AES密文经过Base64编码后,一般以大小写字母和数字为主,偶尔有+/=这些字符;如果密文全是十六进制字符且长度是32的倍数,那可能压根不是Base64,而是Hex编码的密文。

对于混淆严重的JS代码,有个东西很值得用——console.log注入法。在定位到加密函数之后,直接在源代码上改动,先在函数入口处加一行console.log(data),然后在页面里触发一次请求,看看控制台究竟打印了什么。前端代码跑在用户的浏览器里,任何加密逻辑最终都必须还原成明文才能显示,所以加密入口的输入输出一定是合法的观察对象,用打印的方式看变量值,是逆向过程最朴素也最有效的做法。

2. AES加密参数的识别与解密实操

2.1 CryptoJS调用特征:一眼认出AES

在前面提到的方法帮助下,估计你很快就能找到一段长这样的代码:

var key = CryptoJS.enc.Utf8.parse("1234567890abcdef"); var iv = CryptoJS.enc.Utf8.parse("abcdef1234567890"); var encrypted = CryptoJS.AES.encrypt(data, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 });

看到这一段,心里就该有数了。CryptoJS.AES.encrypt明确告诉你加密算法是AES,mode: CBC告诉你用的是CBC模式,padding: Pkcs7告诉你填充方式是PKCS7,而key和iv都通过CryptoJS.enc.Utf8.parse做了UTF-8编码的转换。这几个参数加在一起,就是解密的全部要素。

需要小心的是,实际项目里密钥和IV不一定是这种写死的字符串。有些站点会把密钥藏在Cookie里,跟请求一起传到后端;有些则是通过一个初始化接口动态下发,密钥本身又经过别的加密处理。这种情况下,光把CryptoJS.AES.encrypt里的字面量复制走是不够的,你得继续往上追“这个key变量到底从哪来的”。追变量来源的时候,在那一行下个断点,把变量值打印出来就行。

还有一个研究价值很大的细节:AES的Key不一定非要是16字节。按AES规范,密钥长度必须是16字节(128位)或32字节(256位),但很多人用的字符串长度不太对,比如用了24字节的字符串。这种情况在JS里面是能跑的,但实际上最终参与运算的密钥是经过处理的。所以你从JS里面看到的那个key字符串,最好直接确认一下它的长度是不是16或32。如果不是,要么你自己在代码后面打印一下,要么就留意JS代码里面是否还有对key再做处理的逻辑。

2.2 密钥、IV、模式与填充:解密四要素

网上一搜“AES解密”,大部分教程只会丢给你一段代码,却不说清楚为什么非得这四样缺一不可。按我的理解,AES解密就好比是开一把保险柜的锁:密钥是钥匙,IV是开锁时的起始位置,模式是运动轨迹,填充则是清洗整理抽屉的规则。

密钥(key)不必多说,就是对错钥匙的问题。IV在CBC模式下作用很关键,它决定了解密时第一个密文块的迭代初值,如果IV错了,解出来的第一组明文就会是一段乱码。mode字段把分组密码的执行方式定死了,CBC要求每个密文块先跟上一个块的解密结果做异或,再去解密,所以它天然具备一定的扩散性。padding字段则是为了解决明文长度不是16字节整数倍的问题,PKCS7会在末尾补齐若干字节,解密后必须用对应的unpad逻辑把这些占位字节去掉。

实际逆向中最常见的错误就是只拿到了key和iv,忽略了mode和padding。JS代码里虽然是CryptoJS.AES.encrypt调用的,但mode实际可以是ECB、CBC、CTR,padding也可以是Pkcs7、NoPadding、AnsiX923。如果mode是ECB,那压根就不需要IV;如果你照样传个IV进去,反而会解错。所以看代码的时候,mode这一项一定要确认清楚,没有mode选项时,CryptoJS默认采用的是CBC,padding则是Pkcs7——这个默认值可以用来作为判断的参考。

2.3 Python端用pycryptodome复现解密

Python解密这一段,我最常用的是pycryptodome这个库。安装方式很简单,pip install pycryptodome。基本样板代码不长,但每次我都会先跑通它,再往复杂的方向改。

from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 key = b"1234567890abcdef" iv = b"abcdef1234567890" def decrypt_aes_cbc(data_base64: str) -> str: cipher = AES.new(key, AES.MODE_CBC, iv) raw = base64.b64decode(data_base64) return unpad(cipher.decrypt(raw), AES.block_size).decode("utf-8")

这段代码的输入是Base64编码的密文,输出是解密后的明文字符串。第一次解密时,我习惯在unpad之前先把cipher.decrypt(raw)的结果打印出来,用十六进制看一眼,确认是不是真的解出了可读内容。这样可以区分是解密逻辑错,还是后面的unpad填充逻辑不匹配。如果unpad一直报错,八成是因为密钥或IV不对,或者是JS端PKCS7的填充行为跟Python端有差异。

另一个容易被坑的点是密文的格式转换。有些站点返回的不是Base64字符串,而是纯十六进制字符,比如e10adc3949ba59abbe56e057f20f883e。这种就需要先把hex解码成字节,再送入AES解密:

raw = bytes.fromhex(data_hex)

还有的站点会在密文外层再做一次URL编码,尤其当密文要作为参数拼接到URL里时。这时候最好在Python里先urllib.parse.unquote一下,把%2B还原成+,否则Base64解码必挂。这类“编码套娃”的问题,几乎每一次都能在真实项目里遇到一两种,提前有心理准备,排查起来会快很多。

2.4 一个虚拟演示:某视频站播放地址解密全流程

为了方便讲清楚全链路,我就拿一个虚拟站点来演示,假设它叫作“某视频站demo”(实际是我本地搭的测试环境)。这个站点的播放页面会先请求一个配置接口,返回一段AES加密过的播放信息,解密后能得到真实的m3u8地址。

第一步,打开开发者工具,过滤XHR请求,找到名为/api/playinfo的请求。它的响应体是一串Base64密文。第二步,在Sources里搜索AES.encrypt,定位到加密函数,我看到了这样的代码:

function getPlayInfo(data) { const key = CryptoJS.enc.Utf8.parse("demoKey123456789"); const iv = CryptoJS.enc.Utf8.parse("demoIv9876543210"); return CryptoJS.AES.encrypt(data, key, { mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString(); }

注意,这里故意不写iv选项,CryptoJS在CBC模式下会默认生成一个随机IV塞进密文头部。这种情况下,如果你还用固定的IV去Python端解,结果必然是错的。正确做法是把返回值结构看清楚:CryptoJS的toString()输出通常是Base64的格式化字符串,其中包含了随机生成的Salt和IV,并不是单纯的密文。

遇到这种“随机IV进密文”的情况,用Python复现就会稍微麻烦一些。CryptoJS的加密结果遵循OpenSSL的文本格式,前8个字节是“Salted__”字样,紧接着8字节是随机salt,其后才是真正的密文。需要用pbkdf2之类的密钥派生方式才能完整还原。不过大多数站点的开发者为了省事,都会像第一个例子一样固定IV,真正遇到随机IV的站点相对少。真遇到了,我的建议是用Node.js把CryptoJS这一小段逻辑直接抠出来,在Node里跑通再包一层HTTP接口给Python调用,比硬用Python重写要省事得多。

把解密跑通之后,我们就拿Python拿到了明文JSON,里面是一个playUrl字段,对应着m3u8的地址。整个流程走通后,后面的事情就是把它接到你的爬虫框架里了。

3. 反调试机制的原理与绕过思路

3.1 debugger断点为什么会自动触发

不少视频站点在加载播放器时会启动反调试,最典型的手段就是下面这段代码:

function debugBlocker() { debugger; } setInterval(debugBlocker, 100);

我不知道你第一次撞到这种断点时是什么体验,反正我第一次是懵的。明明我什么都没设置,浏览器却无缘无故断在debugger那一行,而且每当我点“继续”之后,没过0.1秒又断一次,整个页面就没法正常操作了。这个机制的原理其实特别简单:开发者工具里一旦执行到debugger语句,JavaScript虚拟机就会主动暂停执行,让调试器接管。如果这段代码被setInterval反复触发,等于每隔一小段时间就强制中断一次,直接把人往外赶。

除了这种debugger大法,还有一类更阴险的检测方式,就是通过window.outerWidth - window.innerWidth是否大于某个阈值来判断开发者工具是否处于开合状态。一旦检测到,重定向到别的页面或者把整个页面搞成无限循环。这种方案对付普通用户够用,但在技术分析者看来,也就是多费几分钟的事。

3.2 三种实用的分析期绕过手法

绕过的路子不少,我说三种我实际用过的、方法等级从低到高。

第一种,在Sources面板中被触发断点的那一行代码上,右键点击,选择“Never pause here”。这个操作的原理是给那一段断点位置加了一个断点禁用标记,之后每次执行到debugger这一行,浏览器都不会真正停下。这是最快的应急方案,但我发现某些站点会把debugger包在定时器里,哪怕你右键了,缩小窗口或重新加载页面后它又会恢复。所以这个方法适合临场救急,不适合长期分析。

第二种,直接在Console里覆盖掉定时器。比如setInterval每次会触发debuggerBlocker,那我们就在刷新页面后立刻在Console里执行:

window.setInterval = function(){};

这样原本注册的定时器就不再周期执行了。不过这里有个前提:你得保证替换发生在页面注册定时器之前。实际操作中,我通常会在页面加载刚开始时就切换一个干净的空白标签页,先执行覆盖脚本,再回到目标页面刷新。稍微麻烦,但效果很彻底。

第三种,也是最可靠的方法——用Chrome的“Add folder to workspace”或“Override”功能,把目标JS文件保存到本地,直接删掉或者改写其中的反调试逻辑,再刷新页面用本地版本替代线上版本。这个方法等于是接管了整个JS文件,想怎么改就怎么改,想下断点就下断点。唯一要注意的是,文件路径和页面路径要保持一致,并且Sources里要确认当前加载的是本地覆盖版本。

3.3 站在防御方:反调试的局限与进阶思路

聊完怎么绕过,我还想从防御方角度多说几句。因为我平时也写过前端加密逻辑,知道很多人有个认知误区:以为加了反调试、再上点混淆,前端逻辑就是铜墙铁壁了。实际上,只要核心逻辑是在浏览器端运行,它就一定有被观察、被复现的空间。反调试的作用只有一个:提高分析门槛,消耗分析者的耐心。

我见过不少站点把反调试做得很花哨,什么检测开发者工具、检测键盘快捷键、无限递归debugger,全堆上去,但后端接口还是只要一个固定参数就能拿到明文数据。这种防护的强度基本为零。真正有价值的前端防护思路应该是:核心的计算逻辑尽量放到后端完成,前端只负责展示结果;敏感数据不要一次性全量下发,而是配合权限和时效性做动态控制。换句话说,别指望前端手段能守住一切,安全和便利的平衡才是设计重点。

4. Python端落地:把解密能力接进爬虫流程

4.1 请求流程的完整设计

加密逻辑分析清楚,绕过了反调试,下一步就是把这些能力集成到Python爬虫里。很多人在这一步会犯一个错误:把之前手工操作的内容全部一股脑写进main函数,没有规划好请求步骤,结果经常是改一个参数就要重跑整个流程。我的做法是先梳理请求流,画一张脑图,把每个环节的输入输出固定下来。还是拿那个虚拟的“某视频站demo”来举例,它的完整流程是这样的:

  1. 请求首页或初始化接口,拿到一个会话令牌和加密所需的部分密钥材料。
  2. 调用播放信息接口,请求体中携带按照JS逻辑生成的sign参数和会话令牌。
  3. 响应体返回AES密文。
  4. Python端用之前复现的解密函数解出播放地址。
  5. 用播放地址去请求m3u8或分段视频。

明确这五步之后,代码结构就很清晰了。整个函数的返回结果,可以直接设计成一个字典,比如{"play_url": ..., "headers": ..., "expire": ...},方便后续拓展下载任务。还有一点容易忽略的是,接口返回的播放地址一般有有效期,可能是5分钟也可能是30分钟。爬虫任务里如果要批量抓取,就必须做到“取到地址立刻用”,不要存起来过一阵再取,否则地址过期了你还得重新走一遍全流程。

4.2 用requests.Session保持会话与补齐请求头

在Python端,我强烈建议使用requests.Session()而不是每次requests.get()。原因很简单:Session能把Cookie、请求头、连接池的状态都保留下来,尤其是很多站点依赖的会话令牌之类的Cookie属性,需要在多个请求之间保持一致。

import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Referer": "https://yoursite.example.com/", "Origin": "https://yoursite.example.com", }) resp = session.get("https://yoursite.example.com/api/config")

User-Agent、Referer、Origin这三件套是我每次必带的。有些接口不怎么校验收发者的Cookie,却盯着Referer不放,一旦发现Referer不是本站域名,直接给403。这些请求头字段对服务器来说,就是一种“身份自证”,跟门卫检查工牌差不多。你不带这些文件,哪怕Cookie和签名都正确,也可能被拒之门外。所以抓包时一定要留意成功请求的请求头长什么样,尽量一比一复制。

4.3 集成过程中的几个细节坑

把解密函数接进爬虫,听起来简单,做起来却有一些非常烦人的细节坑,我先替你踩一遍。

第一,字符编码问题。请求和响应的编码不一致非常常见。有的接口是UTF-8,有的则是GBK。如果直接resp.text,在中文部分大概率出现乱码,进而导致后面JSON解析失败。最稳妥的做法是看响应头里的Content-Type,或者干脆用resp.content配合手动decode("utf-8")。尤其是解析密文时,任何一步编码转换错了,都会连累整个解密结果。

第二,签名参数的时效性。很多加密参数并不只是“静态值”,而是跟当前时间戳绑定,过期即失效。如果你在调试时手动复制了一个签名参数扔进Python脚本,可能下一分钟就失效了。所以在代码里,凡是和时间相关的字段,一定要现场生成,而不是写死。通常时间戳就是Unix时间戳,但有些站点会用自己定制的格式,这个需要到JS代码里确认。

第三,请求频率控制。有些加密站点虽然后端做得还行,但抗不住爬虫高频刷新,反而会触发风控。如果你在调试阶段频繁刷新页面,可能会导致账号IP被临时限制。建议从设计阶段就给爬虫加上频率限制,哪怕只做调试,也要保持克制。

5. 常见问题与排查技巧实录

5.1 解密失败的四类高频原因速查

解密失败,大多数情况下跑不出下面几个原因。我把它们整理成一张速查表,方便对号入座:

报错现象可能原因处理建议
binascii.Error: Invalid base64-encoded string密文不是纯Base64,可能含换行符、URL编码先做urllib.parse.unquote,或strip()掉空白字符
ValueError: Padding is incorrect密钥、IV、模式、填充其中一项不匹配先在JS端打印这四个要素,跟Python端逐一比对
UnicodeDecodeError解密后的字节不是UTF-8,解密可能部分成功尝试latin-1或gbk解码,输出hex观察规律
解密成功但内容是乱码CBC模式下IV不对,导致第一个块解错确认IV字符串是否正确传入CBC参数中

这张表是我被坑了几次之后才总结出来的。尤其是第一项,Base64字符串里如果带了一个换行符,Python的b64decode就会直接不认。平时抓包保存密文时,最好先看看输出格式,如果是前一步拼接参数产生的,可能还需要额外处理。

5.2 JS端的输出与Python端对不上的排查思路

在实际复现加密逻辑时,经常出现一种情况:明明JS端的代码我全都看懂了,Python照着写,但最后输出死活对不上。这种问题最消耗时间,我摸索出一套排查顺序:

先检查字符串类型。JS里的字符串是Unicode,Python里则是严格区分str和bytes的。CryptoJS的enc.Utf8.parse会把JS字符串转成WordArray,Python这边你手动写上b"1234567890abcdef"时,一定要确认这个b前缀的字节串和JS里那个字符串完全一致。一个常见的坑就是,JS里可能有个看不见的零宽字符或者空格,复制的时候带过去了,Python端看不出差别,但哈希、加密时结果就变了。

再检查密文格式。CryptoJS的toString()默认输出OpenSSL格式,Python端如果直接当普通Base64去解,几乎必挂。这边我没有快速结论,最好的办法就是先在JS控制台里手动执行一遍加密过程,打印出密文,然后在Python里用各种方式尝试:纯Base64解码、去头8字节后再解、或者用Crypto.Protocol.KDF做PBKDF2,总有一个能匹配上。

最后检查填充模式。JS端明明写了Pkcs7,但Python端解的时候提示padding错误,这种情况很可能是两边的密钥派生方式不一致,导致解密出来之后末尾的字节序列对不上。不过不用担心,这个通过对比十六进制的中间结果,能很快找出分歧点。

5.3 页面改版后的快速适配方法

前端代码最大的特点就是会频繁改版,可能今天用的AES,明天就换成了RSA+自定义签名。作为依赖这些逻辑的爬虫开发者,必须有应对改版的预案。我的做法是:每次完整分析完一个站点,就把关键JS文件下载一份存档,并且用diff工具做版本对比。

具体操作上,我在项目里专门留一个目录存放各次抓取下来的JS快照,命名带上日期和当前分析结论。当页面改版导致爬虫失效时,先看控制台的报错,再去Network面板找到新的请求,把新旧JS文件拿来diff,重点看加密函数相关的区域有没有变化。这么一比对,往往几分钟就能定位“是密钥换了”还是“整个加密逻辑重写了”,再决定是只改参数还是重新分析一遍。

另外,建议给爬虫脚本里的解密函数单独封装,不要跟下载逻辑耦合在一起。比如把decrypt_playinfo()、generate_sign()都做成独立模块,每块只干一件事。这样就算改版,通常也只是动其中一两个函数,不会造成整个项目推倒重来。这个习惯,对我自己省下的调试时间,真的不是一点半点。

6. 合规提醒:辨析边界,让技术走正道

6.1 正当使用场景与绝对红线

写了这么多实操内容,这一章可能才是最值得认真看的。加密参数逆向技术本身是中性的,就像一把螺丝刀,可以用来拆玩具、修家具,也可以被用来撬别人的锁。关键的问题在于你把它用在什么场景里。

我平时接触到的合法使用场景主要有这么几类:一是企业内部的前端安全审计,开发人员检查自己上线的站点是否存在加密信息泄露风险;二是做接口联调或者数据迁移,比如你负责的一个系统需要跟另一个系统对接,对方接口参数是加密的,在授权范围内去解;三是为了学习原理,在本地虚拟机里对模拟的站点做安全实验,不触及任何真实用户的利益。

红线上我要说得很清楚:任何未经授权爬取他人平台数据、绕过版权保护获取付费内容、以及把逆向出来的地址和内容向第三方传播的行为,都是不可接受的。尤其是针对影视、动漫、音乐等版权密集领域,获取播放地址到采集分发一步之遥,涉嫌侵权的风险极其严重。做技术研究和做侵权工具,边界就在这里,不需要找任何借口。

6.2 几句掏心窝的话

最后说点个人感受。我接触JS逆向这几年,最大的收获不是“能破解多少站点”,而是养成了一套分析问题的思维方式:先定位关键路径,再拆解黑盒,最后用工具复现验证。这套思路放到任何工程场景里都通用。但我也见过一些技术不错的人,因为没有守住边界,最后把一手好牌打烂了。技术能力越强,越要懂得克制。随手能解出来的密文,不等于有权去拿;能绕过的防护,更不等于应该去绕。真正的高手,是能在“我能做”和“我该做”之间划清界限的人。希望这篇不只教会你方法,也能提醒你时刻想清楚边界在哪里。

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

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

立即咨询