JS逆向实战:反调试绕过与AES加密参数解密全流程
做爬虫的人几乎都会在某一天对着浏览器开发者工具里那一大段看不懂的加密参数发愁。视频站点尤其典型——接口返回一串又乱又长的密文页面上却照样解码播放中间那层“解密盒子”就藏在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里按下CtrlShiftF全局搜索输入这个字段名。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明确告诉你加密算法是AESmode: 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、CTRpadding也可以是Pkcs7、NoPadding、AnsiX923。如果mode是ECB那压根就不需要IV如果你照样传个IV进去反而会解错。所以看代码的时候mode这一项一定要确认清楚没有mode选项时CryptoJS默认采用的是CBCpadding则是Pkcs7——这个默认值可以用来作为判断的参考。2.3 Python端用pycryptodome复现解密Python解密这一段我最常用的是pycryptodome这个库。安装方式很简单pip install pycryptodome。基本样板代码不长但每次我都会先跑通它再往复杂的方向改。from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 key b1234567890abcdef iv babcdef1234567890 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”来举例它的完整流程是这样的请求首页或初始化接口拿到一个会话令牌和加密所需的部分密钥材料。调用播放信息接口请求体中携带按照JS逻辑生成的sign参数和会话令牌。响应体返回AES密文。Python端用之前复现的解密函数解出播放地址。用播放地址去请求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里的字符串是UnicodePython里则是严格区分str和bytes的。CryptoJS的enc.Utf8.parse会把JS字符串转成WordArrayPython这边你手动写上b1234567890abcdef时一定要确认这个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逆向这几年最大的收获不是“能破解多少站点”而是养成了一套分析问题的思维方式先定位关键路径再拆解黑盒最后用工具复现验证。这套思路放到任何工程场景里都通用。但我也见过一些技术不错的人因为没有守住边界最后把一手好牌打烂了。技术能力越强越要懂得克制。随手能解出来的密文不等于有权去拿能绕过的防护更不等于应该去绕。真正的高手是能在“我能做”和“我该做”之间划清界限的人。希望这篇不只教会你方法也能提醒你时刻想清楚边界在哪里。