ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JS逆向实战:AES加密接口破解与参数还原全流程

JS逆向实战:AES加密接口破解与参数还原全流程

1. 案例背景:一个必须解决的接口加密

做web数据采集的人,大概都遇到过这种场景:接口文档看着挺正常,参数名也猜得八九不离十,可真把请求发出去,对方直接甩一个参数错误,或者返回一段看不懂的密文。我这个笔记要写的,就是处理这类问题时会碰到的一个典型分支:JS逆向里的AES加解密。

起因是做某站点的数据整合时,发现登录接口和列表接口的请求体里都多了一个encryptedData字段。明文数据无论怎么模拟都不被接受,一查才发现是前端把所有业务参数AES加密后提交,响应体里的数据也走了同样的加密通道。整个排查过程不算多复杂,但从中抽出来的环节——特征识别、函数定位、参数还原、本地验证——几乎覆盖了JS逆向的完整骨架。这篇案例我就按这个骨架把过程逐段拆开讲,把你也能照着走一遍的东西全部写清楚。

AES在web前端加密里出现频率相当高,登录密码、手机号加密、请求体签名、响应数据解密,全都能撞上它。相比RSA那种非对称加密要处理密钥对的生成、传输和管理,AES作为对称加密密钥单一、处理速度快、代码实现简单,前端项目里大量使用。所以这个案例的参考价值不在于“难”,而在于“常见”——把AES加解密逆向这套流程走顺了,后面再碰上GCM模式、自定义填充、动态IV之类变体,都能少走不少弯路。

这篇笔记适合刚接触JS逆向、想找个真实案例练手的人,也适合已经能处理简单混淆、但一直没系统梳理过AES参数体系的同学。我不打算只贴关键代码,还会把当时判断依据、踩坑过程、验证思路都交代清楚,因为这些东西才是遇到问题时真正救命的。

1.1 前端为什么要拿AES做数据保护

先理解一下业务动机,后面看代码时才能抓到重点。前端加密的诉求通常逃不开几个方向:第一,不让请求参数被轻易改包重放;第二,满足合规要求,比如密码不能明文传输到服务端;第三,核心业务数据不想让调用方直接看懂,于是返回内容也是加密的。

AES在里面扮演的角色就是“对称加密”。同一个密钥,前端用来加密,服务端拿到密文后解密。它的优势是运算速度快、加解密效率高,适合对大批量数据做处理,尤其适合前端在浏览器里跑的场景。劣势也很明显:密钥放在前端JS里,本质上只要愿意花时间分析,就一定能找到。这也是JS逆向能成立的根本前提——前端代码对用户是完全透明的。

这个案例里有个很关键的细节,AES同时承担了“请求参数加密”和“响应数据解密”两件事。我一开始只盯着请求加密逻辑,把加密函数扣出来以后,高高兴兴去请求接口,结果响应体回来一堆Base64密文,当时就懵了。后来往调用链下面追,才发现还有一层解密逻辑挂在请求完成回调里。所以你在做类似分析时,务必要把“请求加密”和“响应解密”当成两条线分别排查,缺一条都不完整。

1.2 逆向闭环的四个环节,先摆个框架

我习惯把一次完整的JS逆向拆成四步,每一步有清晰的目标和出口。

抓包定位:打开开发者工具,找到加密参数出现的具体位置,确认它是请求体参数、请求头参数还是响应数据密文。这一步的核心是搞清楚“加密到底发生在哪”以及“需要还原的是哪些数据”。

脚本定位:通过全局搜索关键字、断点配合调用栈,找到真正执行加密的核心函数。有时候你搜索到的只是某个库的引用,真正调用的地方还得靠断点一层层往回追。

逻辑还原:分析AES的模式、填充方式、密钥、IV等参数,把关键JS函数抠出来,在本地环境重新执行。这一步要特别注意编码细节,密钥怎么parse、IV怎么生成、密文要不要二次编码,每一样都直接影响结果。

一致性验证:用已知明文和页面输出的密文做对照,确保本地还原的加密逻辑和线上行为完全一致。验证通过后,再拿本地生成的密文真实请求一次接口,服务端正常响应才算闭环。

这四个环节在我看来一个都不能省。尤其是第四步,很多人扣完代码以为本地跑通就结束了,没有和线上输出做一致性对比,等真正发请求时才暴露问题,浪费时间不说,排查起来还容易怀疑人生。下面我就按这个流程,把这个AES案例从头到尾还原一遍。

2. 识别加密算法:抓包、看特征、定位函数

动手之前,我习惯先花10分钟观察,而不是一上来就点进Sources里翻源码。观察什么?观察请求长什么样、响应长什么样、密文是什么格式。别小看这一步,很多信息一眼就能判断出来,能帮你节省大量时间。

2.1 密文特征里藏着算法方向

AES加密后的数据一般输出为几种格式:一种是Base64字符串,字符集会比较规矩,经常以等号结尾;一种是十六进制字符串,肉眼看起来就是一串0-9a-f的组合;还有一种是做了自定义编码处理的密文,比如外层套了一层URL编码或自定义字符映射,看起来就不太规律了。

这个案例里,encryptedData字段的值就是一段长长的Base64字符串,而且每次请求长度还不完全一样。这一点很关键——如果密文长度固定且很短,那基本不考虑AES,更像是某种哈希算法或固定长度编码。长度不固定,说明加密过程大概率引入了随机因子。CBC模式下IV是随机生成的,密文长度才会跟明文长度以及随机IV产生联动变化。后面核代码时确认了,确实是CBC模式且IV随机生成。

还有一个小细节值得留意:响应体里的密文也是Base64,但字段名和请求体不一样。当时没注意,导致我以为响应体数据是纯文本,结果解密之后才发现里面是JSON字符串和其他业务数据的拼接。密文特征不仅要看格式,还要看位置和字段名,这些信息组合起来基本能锁定方向。

2.2 关键字搜索实战:从源码堆里捞加密函数

确定大致方向后,就要从压缩混淆的前端代码里找到具体加密函数。我用的标配操作是全局搜索,快捷键Ctrl+Shift+F,在Sources面板里搜整个页面的资源。

搜索关键字怎么选是有讲究的。我一般优先搜下面几类:

  • 加密框架特征名:AES、encrypt、decrypt、CryptoJS
  • 业务参数名:encryptedData、params、sign、secret、token
  • 内置加密对象:crypto、subtle、getRandomValues、createCipher
  • 特殊请求标志:algorithm、mode、padding、iv

这个案例里命中率最高的是CryptoJS。这个库在前端AES加密场景里几乎成了标配,老项目用它,新项目也有不少还在用。搜到CryptoJS后,能看到某处引入了它,并且在某个函数里调用了CryptoJS.AES.encrypt。

但这里有个坑:如果前端代码做了模块化打包,搜索结果可能只是一堆压缩后的代码片段,光靠肉眼很难判断哪个是真正的业务调用。我当时是先看看代码里CryptoJS被引用了几处,然后优先点开包含AES.encrypt字样的片段。如果搜索出来很多无关的库代码,就要配合断点来定位,这就是下一步要说的内容。

2.3 断点把加密调用链钉在案板上

全局搜索能找到函数位置,但确认“加密参数到底是在哪一步生成的”,还得靠断点。我的标配操作是在Network面板里找到目标接口,右键选择Break on URL,这样请求发出之前页面就会停下来。

断下来以后,查看调用栈,从顶层往下一层一层看。压缩混淆后的调用栈看起来会很乱,函数名全被替换成了a、b、c之类,但不用慌,找一下哪一层包含加密参数赋值动作。我的经验是:优先看包含encryptedData赋值语句的那一层,而不是一上来就断进CryptoJS.AES.encrypt内部。进入库函数内部只能看到算法细节,看不到业务逻辑,对定位帮助不大,反而容易被绕晕。

断点还有个附加好处,就是可以观察局部变量的值。我在调试时直接把需要加密的明文结构、加密后的密文、密钥变量、IV变量都打印出来,这些值后面做一致性验证时会用到。特别是IV,如果它是随机生成的,断点里可以看到它生成了多少字节,以及是否被拼接进密文。这些信息在扣代码阶段价值极大。

3. AES参数拆解:模式、填充、密钥、IV,一个都不能少

抓到加密函数只是开始,真正的重头戏是搞懂这串AES到底用了哪些参数。我见过不少人在这一步栽跟头:算法看对了、密钥找对了,只是填充方式判断错了一个,或者IV处理错了一步,结果就完全对不上。AES参数体系说白了就几个维度,但每个维度都可能藏变数。

3.1 AES基础参数快速扫盲

AES本身是分组对称加密标准,固定分组长度16字节。密钥长度支持128位、192位、256位,对应16字节、24字节、32字节。前端项目里最常见的是AES-128和AES-256,具体用哪一种,看CryptoJS传入的key实际字节数就能判断。

影响加解密结果的关键参数有四样:

  • 模式:CBC、ECB、GCM、CTR等。ECB不需要IV,但安全性弱,很多正规项目不太用;CBC最常见,需要IV;GCM在web端也越来越常见,自带认证机制。
  • 填充:PKCS7、ZeroPadding、NoPadding。当明文长度不是16的倍数时,需要填充到分组对齐。PKCS7是CryptoJS默认,也是最常见的。
  • 密钥:同样是abc123这串字符,用UTF-8 parse和用Base64 decode解析,得到的是完全不同长度的字节,解密结果天差地别。
  • IV:CBC模式必需,初始向量决定第一组密文结果。有些项目IV写死,有些项目每次随机生成,还有些项目把随机IV拼在密文前面一起传出。

我做案例拆解的时候,习惯先做一个表格把这几项列出来,然后拿着从断点里观察到的实际值一项一项填。填完以后,加密逻辑基本就清楚了。

3.2 从扣出的代码里逐一确认参数

这个案例加密函数核心逻辑其实很标准,网上很多项目也长这样。我当时的简化还原版本是下面这段:

const CryptoJS = require('crypto-js'); function encryptData(plainText) { const key = CryptoJS.enc.Utf8.parse('0123456789abcdef'); const iv = CryptoJS.enc.Utf8.parse('abcdef0123456789'); const encrypted = CryptoJS.AES.encrypt(plainText, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return CryptoJS.enc.Base64.stringify(encrypted.ciphertext); }

这里有两个细节值得展开。第一,CryptoJS.enc.Utf8.parse这个处理很关键。如果你直接把字符串key传进去而不做parse,CryptoJS会自动把它当成passphrase走另一套密钥派生流程,那结果就跟目标完全不一致了。我当时第一次跑本地脚本,密文对不上,排查半天就是这个原因。

第二,encrypted.ciphertext取的是原始二进制密文,再经过Base64.stringify转成Base64字符串。还有一种常见写法是直接encrypted.toString(),默认输出和ciphertext一致,但如果项目自己定义了toString的行为,结果可能不同。这一步要跟页面上密文的实际格式对齐。

3.3 容易被忽略的二次编码与预处理

把AES本身搞定了,不代表整个逻辑就通了。很多项目会在AES外面再套一层编码或预处理,这层东西看不见就很容易卡住。

我在这个案例里就遇到一个干扰项:加密前,业务参数对象先被序列化成JSON,但JSON序列化时字段顺序不是按我自然书写顺序排的,而是按前端代码里对象属性赋值的顺序。我如果要本地构造同样的明文,就必须按照同样的字段顺序序列化,否则密文永远对不上。

另外,加密完的Base64字符串在发请求时被做了URL编码,因为其中包含+、/、=这些URL特殊字符。后端解析时又会自动解码回来。这个逻辑如果不在本地还原时模拟,就会出现“本地生成的密文和服务端收到的密文不同”的错觉。

我的经验是,逆向AES不要只盯算法本身,还要把数据从明文到最终请求参数的整条链上下游都走一遍。凡是中间经过的编码转换、序列化处理、字段过滤,都要在本地脚本里复现。这也是为什么我一直强调至少要用三组数据做交叉验证,而不是拿一组数据试通就完事。

4. 实操还原:本地扣代码、跑验证、发请求

前面把识别和参数拆解讲清楚了,现在进入真正动手的阶段。我会按实际操作顺序把整个过程记录下来,包括我用的环境、写的脚本、验证的方式,以及最后是怎么确认还原成功的。

4.1 环境准备与依赖说明

本地还原JS加密逻辑,我一般直接跑Node.js,不用浏览器环境。这样后续想批量调用、集成进采集程序时更方便。AES加解密只需要一个crypto-js库,安装命令很简单:

npm init -y npm install crypto-js

这个案例不需要其他依赖,但有些前端项目会依赖window、document之类的浏览器对象,那种情况就得考虑用jsdom来做环境补齐。还有一种项目是使用Web Crypto API,也就是浏览器内置的crypto.subtle,如果是那种写法,Node.js里需要额外的polyfill或用crypto模块模拟。判断方式很简单:看到CryptoJS.AES就是crypto-js,看到crypto.subtle.encrypt就是Web Crypto,两者不能混为一谈。

安装完依赖后,先跑一个最简单的自测,确认crypto-js在当前Node版本下能正常加载。我遇到过npm装完以后require报Cannot find module的情况,检查一下是不是项目根目录和安装路径不一致,或者是不是用了pnpm的隔离node_modules结构。这种环境问题虽然小,但很踩心态。

4.2 完整还原脚本与参数对照

根据断点观察和代码定位的结果,我把加密、解密两个函数都写了出来。解密函数之所以也要写,是因为响应体需要解密才能拿到真实业务数据。下面是简化后的可用版本:

const CryptoJS = require('crypto-js'); // 从页面JS中还原出的固定常量 const SECRET_KEY = '0123456789abcdef'; const SECRET_IV = 'abcdef0123456789'; function encrypt(plainText) { return CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), CryptoJS.enc.Utf8.parse(SECRET_KEY), { iv: CryptoJS.enc.Utf8.parse(SECRET_IV), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ).ciphertext.toString(CryptoJS.enc.Base64); } function decrypt(cipherText) { const decrypted = CryptoJS.AES.decrypt( cipherText, CryptoJS.enc.Utf8.parse(SECRET_KEY), { iv: CryptoJS.enc.Utf8.parse(SECRET_IV), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return decrypted.toString(CryptoJS.enc.Utf8); } module.exports = { encrypt, decrypt };

需要说明的是,案例里的IV虽然是写死的,但我自己遇到过随机IV的项目,那种情况下IV就不能从常量里取。随机IV的还原方式通常是看代码怎么生成、怎么传递:有的是把IV拼接在密文前面然后整体Base64,有的是单独放在请求头的另一个字段里,还有的是通过另一套密钥交换协议动态获取。你在还原时一定先确认IV从哪来,再决定本地怎么写。

4.3 与页面输出做一致性验证,一步都不少

脚本写完后,最关键的就是验证。我习惯分两步走。

第一步是已知明文验证:从页面Console里手动调用页面自己的加密函数,用一串固定的明文,比如{"page":1,"size":20},拿到页面的输出密文。然后在本地Node里用同样的明文跑encrypt函数,对比两边密文是否一致。当时第一次对比,本地密文和页面密文完全不同,差点以为代码扣错了。后来逐行排查,发现是页面加密前对明文做了JSON.stringify且字段顺序被JS对象遍历顺序影响,本地没有复现这个步骤,所以明文本身就是错的。调整之后密文就完全对上了。

第二步是真实请求验证:用本地脚本生成的密文组装一个完整的请求,直接发到接口。这一步能一次性验证很多东西——密钥、IV、模式、填充、密文编码、明文序列化顺序,任何一个环节不对,接口都会报参数错误或返回异常。我当时请求发出去以后,服务端正常返回了密文响应体,再用decrypt函数解回来,拿到了完整JSON数据,到这里整个还原流程才算真正闭环。

4.4 解密函数联调:拿真实响应体验证

请求通了以后,还要专门验证一下响应解密。很多人只验了请求加密,响应解密没有覆盖到,结果采集程序跑起来才发现拿到的全是密文,没法直接用。

我的做法是:先用本地加密函数组一个请求发出去,把响应体保存下来,然后再调用本地的decrypt函数去解,看解出来的内容是不是合法的JSON。如果解出来乱码,先检查密文是不是有额外的URL解码步骤,或者是不是在AES之前还有一层解压。这个案例里没有压缩层,但我见过响应体先Zlib解压再AES解密的组合,那种情况就得在解密前先跑一下zlib.inflateSync。

另外我还发现一个常见现象:响应密文的Base64字符串里偶尔会有换行符,直接丢给CryptoJS.AES.decrypt会报错,需要先去掉换行。网上很多文档不会写这些细节,但实操里概率不低。

5. 问题排查与心得纪要

这一段我整理一下实际调试中遇到过的问题和处理方法,再聊几个花了比较多时间的坑。你可以直接拿这个当速查表用,踩坑时对照着排查效率高不少。

5.1 典型报错和对应解法

报错或现象常见原因排查方法
Cannot read property 'encrypt' of undefinedCryptoJS引入方式不对,或使用了不兼容的构建版本检查require路径,重新安装crypto-js,确认版本
CryptoJS.enc.Utf8.parse is not a function全局变量冲突或混用了不同版本在干净Node进程里测试,排除全局污染
本地加密结果与页面不一致明文序列化顺序不同、密钥未按正确编码parse、IV不一致用三组已知输入输出交叉对比,逐环节定位
解密结果乱码密钥/IV错误、密文有额外编码或压缩层先去掉多余编码,核对AES参数,必要时用已知明文分组调试
请求发出后服务端返回参数错误加密结果正确但请求头/字段结构有差异对比页面请求的完整头信息和参数结构
响应体返回密文但解不开响应数据还有压缩或二次加密,或密文带换行检查Content-Encoding,去除换行符后再解密

这六类问题基本覆盖了AES逆向里八成以上的坑。我之前在群里见过有人卡在“加密结果与页面不一致”上三天,最后发现只是密钥少了个Utf8.parse,很可惜。所以这个核对步骤我建议当成强制流程,每一步都确认一下再往下走。

5.2 三个特别耗费时间的坑

第一个坑是密钥处理方式。CryptoJS里AES.encrypt的第二个参数如果传字符串,库会自动将其视为密码短语,走PBKDF2密钥派生;但如果传的是用Utf8.parse处理过的WordArray,就会直接作为密钥字节使用。这两种路径得到的实际密钥可能是完全不同的,结果自然对不上。我一开始直接传了字符串,密文怎么都对不上,查了好久才发现是这个问题。

第二个坑是随机IV的拼接方式。这个案例里IV是写死的,所以没踩到,但我在另一个项目里遇到过前端每次请求生成一个随机IV,然后把IV的16字节直接拼在密文头部,再整体Base64输出。只看调用代码根本看不出来,因为加密结果是先Concat再encode。如果你发现密文每次长度变化、且本地用固定IV总是解不开,就要怀疑这种拼接模式。处理办法是先按偏移量分割出IV和密文,再分别使用。

第三个坑是响应数据还套了一层协议。我把请求加密还原成功能拿到响应了,但解出来的内容是类似{"code":0,"data":"..."}的结构,data字段里又是一段不明字符串,最后才发现这里面还嵌套了一层Base64编码。做逆向时要记住,加密链路可能不止一层,响应先Base64再AES、或者先AES再Base64的情况都很常见,不要找到一层加密就认为大功告成。

5.3 下次再遇到这类加密,我会怎么快速上手

做完这个案例,我给自己沉淀了一套更顺手的流程,写在这里供参考。

拿到疑似AES加密的接口,先抓包看密文格式和长度,排除哈希和简单编码的可能;然后花五分钟全局搜索CryptoJS、AES这些关键字,再辅以XHR断点查看调用栈,确认加密入口;拿到加密函数后,先把模式、填充、密钥、IV四个参数填进表格,包含编码细节;写完本地脚本后强制做三组一致性验证,分别是固定明文对比、随机明文对比、真实请求联调。最后再检查一下响应解密链路,确保数据能完整解回明文。

这套流程我现在一直在用,不管遇到CBC、ECB还是GCM,流程骨架都不用变,变的只是具体参数。JS逆向这个领域,套路远多于创意,只要你把标准流程跑顺了,剩下的就是时间问题。

我个人实操下来的体会是,JS逆向里最值钱的能力不是拿到一段能跑的代码,而是能快速定位“为什么对不上”。这个能力只能靠多练、多踩坑、多记录来积累。建议你每次做完一个加密的逆向,都把密文特征、关键参数、踩坑点整理成笔记,下次再遇到类似项目可以直接检索复用。做过的案例多了,你会发现很多加密套路都是相似的,真正需要从零开始研究的反而很少。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进