
搞web安全测试的朋友应该都遇到过这种场景打开调试面板看到一个登录接口的提交参数里躺着一长串看不出含义的字符比如password2f0e9c1d...后面的业务逻辑完全被遮住了。这一行字符背后是前端JS里的一段加密逻辑在起作用。如果不把它还原出来接口测试、数据篡改验证、自动化脚本全都寸步难行。这篇文章就围绕“算法逆向 散列对称非对称 JS源码逆向 AES DES RSA SHA”这一整套基础方法论聊聊我在实际测试中怎么快速定位前端加密函数、识别算法类型、把加密逻辑在本地复现以及在这过程中踩过的坑。内容面向刚接触接口安全测试的读者默认你已经会看浏览器开发者工具的Network面板不用太多前置知识照着步骤能跑通一个完整的“从密文到明文”的还原流程。1. 算法逆向先搞懂这几类加密算法到底在防什么1.1 对称加密AES/DES同一个密钥的“锁”AES和DES是典型的对称加密算法加密和解密使用的是同一个密钥。这类算法在前端JS里非常常见很多系统会把用户的密码、手机号、身份证号先用AES加密再提交到后端。理解对称加密的关键在于四个参数密钥key、初始向量iv、工作模式mode、填充方式padding。AES的密钥长度支持128位、192位、256位分组长度固定128位DES的密钥长度64位实际有效56位如今已经不太推荐用了但在一些老系统里还能见到。前端实现对称加密最常碰到的库是CryptoJS。代码里如果出现CryptoJS.AES.encrypt(...)那就没什么好犹豫的。但识别算法只是第一步要还原成可复用的逻辑必须把key、iv、mode、padding全部捋清楚。比如CBC模式需要ivECB模式不需要iv填充方式常见的有PKCS7、PKCS5、ZeroPadding。同一个密文只要padding不同解密结果就完全不一样。所以做逆向的时候这几个参数一个都不能漏。对称加密的场景直觉也很直观如果提交参数里能看到一个较长但固定长度的乱码字符串并且带有明显的base64特征大小写字母数字加/或者hex特征纯0-9a-f大概率就是AES或DES加密。另外加密前的数据多是对称可逆的后端需要解密出来才能读取所以这类加密保护的通常是敏感字段而不是签名字段。1.2 非对称加密RSA公钥私钥分开的“锁”RSA是另一个极端。加密用公钥解密用私钥公钥可以公开私钥只有服务端持有。前端JS里做RSA加密通常只拿到公钥把明文用公钥加密后提交后端用私钥解密。RSA的一个直观特征就是密文长度特别长。以1024位密钥为例加密后的字节数也是128字节转成base64大约344个字符2048位密钥的密文是256字节base64字符串约684个字符。如果看到登录密码提交后是几百字符的乱码基本可以往RSA的方向考虑。前端RSA常见的库是JSEncrypt代码里一般会有setPublicKey、encrypt这样的方法调用。公钥格式通常是PEM格式以-----BEGIN PUBLIC KEY-----开头也可能以-----BEGIN RSA PUBLIC KEY-----开头后者是PKCS#1格式前者是X.509 SubjectPublicKeyInfo格式。识别RSA的时候不要只看函数名还要看有没有BigInt运算、mod、pow之类的底层实现因为有些系统为了减小体积会自己手写RSA核心运算。RSA在测试中的难点不是加密过程而是验证。只要知道公钥任何人都能加密但反过来如果没有私钥就无法解密验证结果。所以本地复现的时候一般会用我们调试时临时生成的一对密钥替换公钥或者用一个固定的测试私钥来跑通整个流程重点验证前端逻辑是否正确。1.3 散列算法SHA不是加密是“指纹”SHA和AES、RSA有本质区别SHA是散列算法也叫哈希算法它是单向的理论上不可逆。同样的输入必然得到同样的输出但无法从输出反推输入。所以SHA通常不做“加密存储”而是做“完整性校验”或“签名摘要”。常见的有SHA-1、SHA-256、SHA-512。它们的长度固定SHA-1输出20字节hex表达40个字符SHA-256输出32字节hex表达64个字符SHA-512输出64字节hex表达128个字符。前端代码里用SHA的场景也不少。比如登录时将密码加上一个固定盐salt再做SHA-256得到摘要后提交或者一些系统对请求参数按字典序拼接后做SHA-1生成签名防止参数被篡改。识别SHA的关键在于代码里搜索SHA256、SHA512、sha256等关键词观察输出长度是否符合上述特征看它有没有密钥参与。注意这里的“加盐”不是对称加密的密钥盐只是拼进原文的一串附加内容作用是把同样的明文散列成不同的值防止彩虹表攻击。我一般在定位SHA逻辑时会额外关注拼接规则。是password salt还是salt password有没有做大小写转换转hex还是base64这些细节错一个字符结果就对不上。1.4 从场景猜算法先看请求参数长什么样实操中我们一开始往往不知道前端用的什么算法。我的习惯是先从Network面板观察请求参数用特征做一个初步预判然后再去JS源码里找证据。可以列一个特征速查表参数特征可能算法说明固定长度40位hexSHA-1无密钥可能是签名固定长度64位hexSHA-256 / SHA-512?SHA-256常见也可能MD5SHA256固定长度128位hexSHA-512少见但特征明显base64字符串带结尾AES/DES/RSA可能是对称加密密文或RSA密文密文长度数百字符base64RSA优先怀疑RSA密文长度与明文接近可看出部分数据弱加密/Base64非加密只是编码这个表不是绝对准确但能帮我快速锁定搜索方向。比如参数长度明显是64位的hex我就直接去JS里搜sha256如果参数是几百字符的base64我第一反应是RSA会去找JSEncrypt或者setPublicKey。有了方向以后再通过源码逆向确认效率会提高很多。2. JS源码逆向的准备工作工具、目标、合规边界2.1 必备工具链与定位思路JS源码逆向说白了就是在浏览器环境里找到“前台把明文变密文”的那段代码然后把它搬出来。我常用的工具就三样浏览器开发者工具、抓包代理工具、还有Node.js。浏览器开发者工具负责定位和查看源码抓包代理负责分析请求与响应Node.js负责在本地复现加密逻辑。这里的重点不是工具多高级而是思路要清晰。整个逆向过程可以拆成四步第一步找到发起请求的入口第二步定位到加密函数第三步把函数及其依赖完整抠出来第四步用Node.js跑通并验证。我见过很多人卡在第二步觉得代码量大、混淆严重其实是因为没有用对方法。定位加密函数最常用的手段是“搜索断点”。打开Sources面板按CtrlShiftF全局搜索关键词。搜索的关键词我建议用encrypt、decrypt、CryptoJS、JSEncrypt、sha256、AES、RSA等。这种直接搜索通常是最高效的因为很多前端虽然会混淆变量名但库名、方法名一般不会被改掉。如果真的被改了比如把所有方法名都变成了两个字母那就需要换一个思路用调用栈来定位。2.2 用搜索和断点快速找到加密函数如果搜索搜不到或者搜到了但没法确定是哪个函数我一般会配合Network面板的“调用堆栈”功能。在Network里点一下请求随请求会有一个“启动器”信息点击它就能看到发起这个XHR或fetch的JS代码所在位置。从这个位置往上回溯调用栈往往就能找到加密函数。还有一招很好用对XMLHttpRequest.prototype.send和fetch做hook断点。在控制台里直接执行一段重定义脚本每当有请求发出时浏览器都会在发送前停下此时我们可以检查请求的data然后查看调用栈。这个办法可以绕过繁琐的事件定位尤其是在点击按钮触发请求的场景里很省事。举个我调试模拟项目X的例子登录按钮点击后我抓包看到password是一长串base64。在Sources里搜索AES搜到了CryptoJS.AES.encrypt的调用。我在这一行下了断点然后在页面上再次点击登录断点命中后查看了当前作用域里的变量。发现keyWord是一个固定字符串ivValue是另一个固定字符串mode是CBCpadding是Pkcs7。到这里加密逻辑的核心参数基本就齐了。2.3 输出与复现把加密逻辑“抠”到本地定位到加密函数后下一步是把它复现到本地。这时有两个常见情况函数依赖了庞大的库文件比如CryptoJS源文件或者函数内部引用了大量全局变量。对于前者最简单的方法是直接引入完整库我一般从页面源码里把CryptoJS的script标签指到的文件下载下来放到本地Node.js项目里然后在脚本中require或直接读取使用。对于后者我会把依赖的全局变量逐一在当前作用域里打印出来或者直接在断点处执行copy(变量名)把值复制到剪贴板然后在本地脚本里写死。这里有一个关键技巧不要试图把所有代码全部抄一遍。我们只需要还原加密函数本身的行为不需要还原整个业务逻辑。比如登录接口的加密函数它的输入是明文密码输出是密文我可以把它单独摘出来写一个最小可运行函数。但要注意如果原始加密函数内部调用了很多其他函数比如自定义的Base64转换、自定义的padding处理那这些也必须一并抠出来。判断依赖范围的方法是先在控制台里执行该函数如果报错提示引用了某个未定义变量就再把这个变量所属的代码段也拷贝下来。3. 识破四种算法的实战操作细节3.1 AES/DES的识别与还原找key、iv、mode、padding前端AES加密最常见的形式是这样的var ciphertext CryptoJS.AES.encrypt(plainText, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString();在还原的时候我最担心的是key和iv的类型。CryptoJS有一个非常容易踩坑的点如果key传入的是普通字符串CryptoJS会把它当作“密码”用OpenSSL的EVP_BytesToKey派生出一个真正的密钥而不是直接用这个字符串作为密钥。如果传入的是CryptoJS.enc.Utf8.parse(key)的结果那么才是直接用这个字节序列作为密钥。两者算出来的密文完全不一样。所以遇到了AES加密我第一件事就是在断点里看key和iv的构造方式。如果是Utf8.parse过的就直接记录字符串如果是普通字符串则说明可能走的是密码派生模式此时需要连CryptoJS自己的派生逻辑一起还原。顺便说一句CryptoJS在处理密码派生时密文开头会包含一个Salted__头如果你看到base64密文解码后前8个字节是Salted__那基本可以断定是用了密码模式。DES的还原思路和AES相似唯一要注意的是密钥长度。DES密钥长度是8字节3DES密钥长度是24字节。如果发现CryptoJS.DES.encrypt直接看传入的key长度是否匹配有些系统会用固定8位字符串当key那就很简单。还原DES时padding和mode的判断标准跟AES一模一样手法上完全通用。3.2 RSA的识别与还原找公钥、看填充方式RSA在前端的实现最常见的是JSEncrypt库。它的标准用法是var encryptor new JSEncrypt(); encryptor.setPublicKey(publicKey); var encrypted encryptor.encrypt(plainText);encrypt方法返回的是base64字符串。想要在本地复现需要拿到公钥字符串然后把加密逻辑转换成Node.js的crypto模块实现。Node.js里的RSA加密写法大致是const crypto require(crypto); const publicKey -----BEGIN PUBLIC KEY-----\n...; const buffer Buffer.from(plainText, utf8); const encrypted crypto.publicEncrypt({ key: publicKey, padding: crypto.constants.RSA_PKCS1_PADDING }, buffer); const result encrypted.toString(base64);这里有个大坑填充模式。JSEncrypt默认使用的是PKCS#1 v1.5填充也就是Node.js里的RSA_PKCS1_PADDING。如果后端要求的不是这个填充比如OAEP那么Node.js加密结果就会不一致。需要从JS源码中找线索看有没有OAEP、PKCS1_PADDING之类的常量定义。同时也要注意字符编码明文里的中文、特殊字符都是以UTF-8编码进行的如果本地误用了UTF-16结果就错了。RSA还有一种情况是公钥以模数modulus和指数exponent的形式出现比如十六进制字符串。这时候需要把十六进制转成Buffer自己拼一个公钥或者直接用Node.js的crypto.publicEncrypt也不行需要先构建KeyObject。我通常会手动构造一个DER编码的RSA公钥或者直接写一个用crypto.createPublicKey接收{key: {n, e}}的形式。虽然麻烦一点但能处理更多自定义实现。3.3 SHA系列的识别与还原从长度和初始化向量判断SHA系列识别起来最简单。先看输出长度40位就是SHA-164位通常是SHA-256128位就是SHA-512。再看代码里搜索到的关键词比如CryptoJS.SHA256、createHash(sha256)。前者是CryptoJS后者是Node.js或浏览器WebCrypto写法。在CryptoJS里常见的写法var hash CryptoJS.SHA256(message).toString();这个过程中没有任何key、iv、mode的概念只需要拼接字符串时注意顺序。我曾经在处理一个系统时发现它的签名串是param1value1param2value2keysecret但代码里是先对这个字符串做了encodeURIComponent再做SHA1最后转成大写。拿到原文拼接规则后用Node.js的crypto.createHash(sha256).update(str).digest(hex)就能复现。还有一个细节CryptoJS的toString()默认是hex编码而有些系统重写了方法输出为base64比如hash.toString(CryptoJS.enc.Base64)。所以一定要确认输出编码。一个更隐蔽的坑是UTF-8编码问题。浏览器中字符串默认是UTF-16的但在Web Crypto API里TextEncoder转成了UTF-8CryptoJS则直接把字符串按UTF-8字节处理。如果在Node.js里直接用Buffer.from(str, utf8)处理一般没问题但如果原始JS用了CryptoJS.enc.Utf16LE.parse之类的操作那就完全是另一套字节序了。3.4 用明文密文对验证还原结果不管还原了AES、DES、RSA还是SHA最后都要做验证。最可靠的方法就是找一组已知的明文和密文对页面上输入一个固定的测试密码抓包得到密文然后在本地用同样明文跑一遍还原出来的函数看结果是否一致。验证的过程中我会同时检查三个地方。第一字符串拼接顺序比如key text还是text key。第二编码方式是hex还是base64。第三额外参数比如是否带上了时间戳、随机数。举个例子我在模拟项目X里发现同一个密码每次提交的密文都不一样一开始以为是AES用了随机iv后来发现是明文里拼接了一个每次请求都要变化的随机字符串。这种情况下就要先去还原随机字符串的生成逻辑否则没法复现。如果发现密文对不上不要急着改参数先做分段调试。以AES为例我可以把加密过程拆成四段先做key解析再搞iv解析然后验证mode最后看padding。每一段都打印中间结果和浏览器断点时看到的变量值对比很快就能定位到是哪一段不匹配。4. 常见问题与排查技巧实录4.1 定位不到关键断点怎么办有一种很常见的情况我在代码里搜到了AES也找到了调用点但是在这一行打断点后怎么刷新都不命中。原因往往是因为这个加密函数被包装在某个异步流程里或者只在特定条件下才执行。比如有的系统只有在用户没有勾选“记住我”时才走AES加密勾选了就只是做简单哈希。我的排查办法是先回到事件源头。在页面上点击登录然后在Network里找到请求点击请求的“调用堆栈”可以看到整个调用链。从调用链的最外层开始逐层下断点逐步往里走比盲猜加密函数位置要靠谱得多。还有一种技巧是给window对象加一个访问记录比如重写CryptoJS.AES.encrypt在里面执行debugger来强停。如果连CryptoJS都不存在说明这个“AES”可能是自己实现的。这时候观察密文长度和结构比如如果分组看起来是16字节有规律再根据源码中某些特征函数去全局搜索。总之定位不到关键代码首先怀疑自己的定位思路而不是代码不存在。4.2 混淆代码里怎么找入口现在很多前端会把代码做压缩和混淆变量名都变成a、b、c甚至还会加入大量无意义的控制流扁平化逻辑。在混淆严重的代码里直接搜关键词经常一无所获。我的思路是“以点带面”。用已知的输入输出反推在浏览器控制台里hook住一个JSON字符串化函数比如JSON.stringify当页面往请求里拼参数时这个函数一定会被调用。通过hook我可以拿到参数字典还能看到调用栈调用栈里就会出现真正负责加密的函数文件名和行号这个行号即使代码混淆了位置不会变。具体hook代码大致长这样// 在控制台执行重写JSON.stringify (function() { var original JSON.stringify; JSON.stringify function(value) { console.trace(JSON.stringify called with:, value); return original.call(this, value); }; })();这样一旦页面执行到序列化请求参数的地方控制台就会打印调用栈。从调用栈里跳进去就能找到加密逻辑所在的位置。这个方法对大多数基于对象拼接的代码都有效因为几乎所有的请求参数最终都要经过JSON.stringify或者字符串拼接。4.3 动态key和时间戳的应对动态key算是前端逆向里比较头疼的问题。有些系统的AES密钥不是固定的而是用当前时间戳做SHA256后取前32位作为keyIV又用另一个时间参数。这种情况下即便拿到了加密算法也无法用固定值去复现。我常用的办法是控制时间源。在浏览器里重写Date.now和new Date()的返回值让它们的输出固定成一个我指定的时间戳然后再触发加密流程。这样就能拿到一个可复现的key和iv。// 在控制台执行固定时间戳 Date.now function() { return 1700000000000; }; var originalDate Date; window.Date function() { if (arguments.length 0) return new originalDate(1700000000000); return new originalDate(arguments[0]); }; window.Date.now Date.now;重写时间之后页面生成的请求参数就固定了密文也固定了我就可以在本地用同样逻辑和相同时间戳算一遍能对得上就说明还原正确。需要注意的是重写范围要覆盖所有可能的时间获取方式有些代码会通过performance.now()获取更精确的时间也需要一并拦截。4.4 多算法嵌套的情况怎么拆解很多系统并不会只用一个算法而是采用“组合拳”。我遇到过一种典型的嵌套外层对参数做AES加密内层再用SHA256生成签名签名附加在密文后面还有一种是先RSA加密一个临时对称密钥再用这个临时密钥AES加密业务数据类似混合加密的简化版。面对嵌套算法不要慌。我的处理原则是“从外层往内层剥”。外层看数据类型如果是一个明显的JSON字符串里面有data和sign两个字段那sign多半是SHAdata多半是AES密文。先分别提取这两个字段找到各自生成逻辑。如果外层只有一段base64解出来是二进制字节那大概率就是RSA或AES的密文。如果解出来之后发现里面又包含一段有效UTF-8文本这说明外层可能只是简单的编码真正的加密还在更里层。剥洋葱的时候每一步都要记录中间结果。我会保存一个临时文件步进式记录每一步的解码、解密结果。当某一步开始出现英文标签、{之类的JSON骨架时就离明文很近了。最后再逆序组装整个加密流程写成一个完整的本地加密函数用它重新生成一遍完整请求验证是否能通过服务端校验。5. 写在最后的合规提醒与个人体会做算法逆向和JS源码逆向首先要想清楚边界。跑到别人系统的生产环境里去逆向加密逻辑然后尝试越权、篡改数据这是明确越界的行为。我这篇文章里所有的案例都限于自己负责的模拟项目X、有授权的渗透测试或公开的CTF练习。如果你拿到的是一个没有授权的真实系统我建议立刻停手否则后续的法律责任不是一句“技术研究”能挡住的。从技术角度说我最大的体会是前端逆向的核心不是“破解加密”而是“理解加密”。当你理解了业务模型你会发现AES、RSA、SHA本质上都是一些标准积木前端只是把它们按照一定规则拼起来。真正难的从来不是算法本身而是藏在拼接里的那些细节——是UTF8还是UTF16是hex还是base64是先加盐还是直接散列是CBC还是ECB。这些细节没有捷径只能靠多调、多试、多打点。如果让我给刚起步的朋友一个建议我会说先找一个最简单的登录接口亲手把它从抓包开始到搜索关键词、下断点、导出函数、Node.js复现完整走一遍。走完这一步你对“算法逆向”这四个字的理解会立刻变得具体起来。之后再遇到复杂的混淆和嵌套都只是一个工程问题而不是认知问题。