不少做前端开发、接口联调或者数据监控的朋友,应该都遇到过这种情况:明明在浏览器里能看到页面上的数字,但打开开发者工具一看,接口返回的却是乱码一样的东西。第一次遇到某药品展示平台的价格(Price)字段返回一串加密字符串时,我也是这种反应。这篇文章就用一个模拟项目,完整复盘我当时对某药品网站价格字段解密的全过程,包括定位加密入口、还原加密逻辑、环境补齐、常见坑点这几个核心环节。
先说清楚我为什么要做这件事。正经业务场景里,我们并不需要去“爬”别人的数据。真实需求是:我负责的一个医药信息展示平台需要对接第三方数据做展示校验,而对方接口返回的字段是加密的,文档只给了一小段说明。为了完成合法的联调和数据核对,我必须搞清楚前端是怎么把密文解开、展示到页面上的。这种场景在前后端分离、敏感字段加密的系统中非常常见,解密的目的不是绕过安全机制,而是理解数据链路,保证联调能正常进行。
1. 定位加密入口:从接口响应到JS代码
1.1 先看清请求长什么样
打开开发者工具的网络面板,刷新页面,找到那个返回药品价格的XHR请求。我习惯先看响应体,因为能直接确认返回的是不是密文。那次看到的是类似于这样的一串数据:
{"code":"0","data":[{"drugName":"阿莫西林胶囊","price":"Ax8fQ3kP2sLmNcVb..."}]}price字段明显不是明文,而其他字段比如drugName、spec都是明文的。这就说明了一个重要信息:加密不是整包加密,而是字段级别的加密。这是很多业务系统的常见做法——接口本身走HTTPS,敏感字段再加一层业务加密,防止数据在中间链路被窃取后直接暴露。
这一步的关键动作是区分“传输加密”和“业务加密”。如果整个响应体都是二进制或者Base64乱码,那大概率是整体加密或压缩;如果只有个别字段异常,那就是单独处理过的。只有把类型判断对了,后续分析才不会走弯路。
看请求头也是必做动作。Content-Type、Accept、自定义Header等情况能反映出对方前后端的约定方式。如果请求头里带了X-Requested-With: XMLHttpRequest,说明这是一个标准的Ajax请求,加密逻辑大概率在前端JS里完成。如果还看到了自定义的sign、timestamp之类的Header,那就要多个心眼——价格字段的解密可能需要先处理签名逻辑。
1.2 搜索关键字找到加密函数
在Sources面板里定位加密逻辑,我一般用全局搜索。在JS文件堆里找关键字,优先搜索这些:
price(字段名)decrypt(解密)encrypt(加密)AES、DES、RSABase64、CryptoJS
在Chrome开发者工具里,可以通过Ctrl+Shift+F在所有的JS文件中进行全局搜索。搜索price关键字,结果往往会非常多,那就先加上引号搜索"price",或者直接搜索price:,这样能更快定位到赋值语句。
比如在某次分析中,搜索"price"之后,在static/js/chunk-xxxx.js里找到了一段疑似处理价格显示的代码:
let showPrice = decryptPrice(list[i].price);这里出现了decryptPrice函数,这就是关键的搜索锚点。顺着这个函数名,我直接在Sources面板里搜索decryptPrice,找到了它的定义位置。这种方式比一条条看堆栈效率高很多。
1.3 从调用栈回溯逻辑
找到decryptPrice之后,按Ctrl+Shift+F搜索函数名,点击搜索结果左侧的代码行号可以跳转到对应的JS文件位置。在行号上打一个断点,刷新页面,当程序执行到这一行时会自动停下来。这时候在右侧的Call Stack(调用栈)面板里,能看到完整的调用链。
调用栈的好处是能看清“数据从哪来、经过什么处理、最后到哪去”。从栈顶到栈底,可以梳理出类似这样的链路:
decryptPrice (被调用处) -> formatDrugInfo (格式化药品信息) -> renderList (渲染列表) -> handleResponse (处理接口响应)如果继续往栈下面看,可能还能找到接口回调函数里是否已经做了数据预处理。分析这个过程的实际价值在于:加密逻辑可能不是直接在渲染层解开的,而是在数据流早期的某个拦截器里就已经解密了。搞清楚调用链,才能避免在错误的层级里浪费大量时间。
2. 核心加密逻辑拆解
2.1 还原加密流程
在断点处单步执行(F11),一步步进入decryptPrice函数内部,看到类似这样的代码结构:
function decryptPrice(encryptedData) { var key = CryptoJS.enc.Utf8.parse("a1b2c3d4e5f6g7h8"); var iv = CryptoJS.enc.Utf8.parse("1234567890abcdef"); var decrypted = CryptoJS.AES.decrypt(encryptedData, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return decrypted.toString(CryptoJS.enc.Utf8); }看到CryptoJS.AES.decrypt,事情就简单了。这是很典型的AES-CBC解密。key和iv就直接写在JS代码里,变量名虽然不起眼,但实际上没有任何混淆。这里要解释一下AES-CBC的基本原理,方便新接触的朋友理解:
AES(高级加密标准)是分组加密算法,每组固定16字节。CBC模式(密码分组链接模式)在加密时,每个明文分组会先与前一个密文分组进行异或,再执行加密;解密就是逆过程。Pkcs7是填充方式,当明文长度不是16字节的整数倍时,会在末尾填充额外字节,填充分子节值等于填充长度。这种组合在业务加密中非常普遍,因为JS端的加密库成熟稳定,后端也能用对应的库轻松解开。
2.2 参数生成规则
光看到key和iv还不够,还要弄清楚它们是怎么来的。有些项目是硬编码的,就像上面这段;但也有项目是动态生成的,比如通过某个接口获取、或者由固定字符串和时间戳拼接后经过摘要算法生成。
在这个模拟项目中,我顺着变量定义往上查,注意到key和iv是通过一个工具函数生成的:
var key = getKey(); var iv = getIv(); function getKey() { return CryptoJS.enc.Utf8.parse("a1b2c3d4e5f6g7h8"); } function getIv() { return CryptoJS.enc.Utf8.parse("1234567890abcdef"); }如果这两个函数内部是固定的字符串拼接,那本质上还是硬编码。但常见的动态方案有两种:
- 密钥由某个公开请求(如获取配置的接口)返回
- 密钥由固定字符加登录用户的
token拼接后截取
如果是后者,那么在分析时必须定位到token的获取方式,因为每次登录的token不同,密钥也随之变化,写死是行不通的。
在那个模拟项目里,好在key和iv都是固定值,省了很多事。我通常会在本地先写个测试脚本验证一下解密逻辑是否正确:
const CryptoJS = require('crypto-js'); let encryptedData = "Ax8fQ3kP2sLmNcVb..."; let key = CryptoJS.enc.Utf8.parse("a1b2c3d4e5f6g7h8"); let iv = CryptoJS.enc.Utf8.parse("1234567890abcdef"); let decrypted = CryptoJS.AES.decrypt(encryptedData, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); console.log(decrypted.toString(CryptoJS.enc.Utf8));跑一下,如果能输出类似"49.50"这样的明文,说明逻辑就对了。
2.3 解密后的数据与页面映射
解密本身只是一步,接下来还要确认解出来的数据在页面上是怎么用的。回到渲染代码,价格解密后会拼入商品卡片模板,比如:
html += '<span class="price">¥' + decryptPrice(item.price) + '</span>';这就确认了price接的是价格数值字符串。我习惯再检查一下有没有后续处理,比如乘以折扣、加上单位、保留小数位等。如果存在这种后处理,接口返回的密文和最终展示值之间可能隔了两层逻辑,只看页面显示会得出错误结论。
3. 实操过程与工具链
3.1 断点调试的实用技巧
断点调试是整个分析过程里效率最高、也最容易卡住人的环节。分享几个我反复使用的技巧:
第一,条件断点。如果列表很长,每次循环都会断下来,逐个排查太费时间。在断点处右键选择编辑,输入条件,比如item.price === "Ax8fQ3kP2sLmNcVb...",那么只有价格匹配到这个特定值时才会暂停。
第二,小黑板监视(Watch)。在调试时,把decryptPrice、key、iv加到Watch面板里,程序暂停时可以直接看到当前值。这比一个一个鼠标悬停看变量值高效得多。
第三,Call Stack里快速跳转。有时候函数被多个地方调用,栈里能看到非常长的调用链。双击调用栈里不同的层,可以直接跳转到对应代码位置,方便梳理完整链路。
第四,Overrides(本地替换)。Chrome的Overrides功能可以把远程的JS文件映射到本地文件。对于分析加密逻辑,这个方法特别适合“改一行刷新看效果”的场景。你在本地文件里加上一个console.log(decrypted.toString(CryptoJS.enc.Utf8)),保存后刷新页面,控制台就打印出所有解密后的价格。不用反复打断点,非常方便。
3.2 补环境跑通解密函数
有时候因为JS函数体依赖了页面的全局变量,直接复制到Node.js环境运行会报错。比如函数内部用了window、document、localStorage等浏览器对象。报错信息常见的有:
ReferenceError: window is not definedTypeError: Cannot read property 'getItem' of undefined
解决办法通常是补环境。最简单的方案是:在Node.js里定义一个假的window、document对象:
global.window = global; global.document = { getElementById: function() { return null; } }; global.localStorage = { getItem: function() { return null; }, setItem: function() {} };当然,如果直接用CryptoJS重写一遍解密逻辑,其实不用补这些环境,因为AES解密本身不依赖DOM。我推荐这样做:先用重写的方式验证算法正确性,如果遇到复杂的混淆或者环境校验,再考虑补环境运行原始函数。
关于运行环境,还有一个“组合拳”思路:把整个加密相关的JS代码放进一个单独的沙箱脚本,用vm模块跑:
const vm = require('vm'); const fs = require('fs'); const code = fs.readFileSync('./target.js', 'utf-8'); const context = { window: {}, document: {}, localStorage: {} }; context.window = context; vm.createContext(context); vm.runInContext(code, context);这样做的好处是尽量还原浏览器环境。如果目标函数还依赖了CryptoJS库,需要把库文件也合并进来再执行。
3.3 本地验证并批量测试
对于单个密文,解密验证没什么问题;但要确认这个解密逻辑对请求返回的全部数据都有效,不能只测一条。我会把接口抓下来,存成本地的JSON文件,批量跑解密脚本,检查是不是所有price字段都能顺利解开,是否有异常情况,比如有的字段不是加密的、有的密文格式不同。
这里贴一个批量验证的脚本框架:
const fs = require('fs'); const CryptoJS = require('crypto-js'); const rawData = fs.readFileSync('./response.json', 'utf-8'); const data = JSON.parse(rawData).data; const key = CryptoJS.enc.Utf8.parse("a1b2c3d4e5f6g7h8"); const iv = CryptoJS.enc.Utf8.parse("1234567890abcdef"); data.forEach((item, index) => { if (!item.price) { console.log('第' + index + '条价格为空'); return; } try { let decrypted = CryptoJS.AES.decrypt(item.price, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); let plainText = decrypted.toString(CryptoJS.enc.Utf8); console.log(item.drugName + ' => ' + plainText); } catch (e) { console.log('第' + index + '条解密失败:', e.message); } });实际跑下来,价格字段基本都能正常解出数字。少数几条因为密文里多了一些不可视字符或者Base64换行符,导致解密失败,就说明做数据时还应该对密文做一次合法性清洗。
4. 常见问题与排查技巧实录
4.1 动态混淆与变量名压缩
很多网站在生产环境的JS是经过混淆压缩的,变量名是一堆a、b、c或者_0xabc123这样的十六进制风格。刚开始看着很头疼,但有几个应对技巧:
- 不直接读变量名,而是通过断点+观察输出来判断逻辑。
- 利用格式化工具(Sources面板左下角的
{}按钮)把混淆JS格式化成可读形式。 - 专注于字符串常量,因为混淆器一般不会改字符串内容。看到
AES、Base64、Pkcs7这些字符串就可以反推用的算法。 - 借助搜索功能定位特定字符串,然后看周围的逻辑。
还要留意控制流平坦化。这种混淆把原本顺序执行的代码拆散到大量分支里,用循环和状态变量控制执行顺序。遇到这种,建议放弃人肉梳理,直接在关键调用处打断点,实际跑数据。运行时的数据比静态代码更可靠。
4.2 环境检测机制
现在不少前端JS会做环境安全检测,检测是否是正常的浏览器环境。常见的检测点包括:
window.chrome对象及其内部属性navigator.webdriver是否为truedocument.hidden状态- 是否存在
phantomjs、selenium等自动化工具的特征 - 浏览器语言、时区、字体等指纹信息
我们正常联调时,别人页面里的JS不应该做这么重的校验。但如果遇到环境检测,不要往对抗方向想,而应该回到业务本身:如果这个接口文档明确说要做联调,大概率有测试环境或者对应的开放接口,前端加密只是为了防外部的普通访问,真正的合作方一定有官方通道。这时最务实的做法是:确认自己是否有合法的密钥与接口文档,而不是去逆向别人的前端。
从技术学习角度看,做环境检测观察是很有意思的,但不建议在真实系统上做违规操作。
4.3 字符编码与Base64的坑
解密后得到乱码,最常见的原因就是编码不一致。解密程序在很多场景下默认按UTF-8处理,但对方后端如果用的是GBK或者其他编码,中间转换就乱了。
我遇到过的具体情况是这样的:解密后toString(CryptoJS.enc.Utf8)输出了一堆方块字符,但用CryptoJS.enc.Hex输出十六进制字符串时可以看到数据其实是完整的,说明问题出在字符解码,不算法本身。
解决方式是把解密结果先转成字节数组,再手动按正确的编码解码。如果对方没有说明编码,优先尝试UTF-8,其次GBK。
另一个坑是Base64密文里包含换行符或者空格。接口返回的密文有时因为传输格式化被切成了多行,直接把多行密文传进解密函数会报错。应对办法是在解密前做一次清理:
let normalized = encryptedData.replace(/\s+/g, '');第四种情况是密文前缀。有些系统会在密文前面加一段标识,比如AES$、enc:,用来区分加密方式;这种必须先去掉前缀再做Base64解码。
4.4 关于返回数据中其他加密字段的联动
如果页面上不仅价格是加密的,库存、销量、优惠券金额也加密了,但加密方式不一样——不要分别去逆每一个字段。先尝试用同一套解密逻辑验证其他字段,如果失败再单独分析。不同字段用不同密钥的情况也存在,这时候在接口回调入口打断点,一次性观察所有字段的密文和对应的解密函数调用情况,效率是最高的。
我见过一种比较危险的设计:单个页面发起多个接口请求,价格数据来自A接口,展示解密参数来自B接口。如果只盯着A接口分析,永远也找不全逻辑。所以拿到一个加密字段时,我的习惯是先把所有接口的响应都检查一遍,看看有没有哪个接口直接返回了密钥或者配置信息。
5. 写在最后的几点体会
做完整个分析,我对“JS逆向”这件事的看法是这样的:它是一种理解现代前端架构的手段,核心价值在于梳理数据链路、排查联调问题。实际工作中,正规的业务对接一定会有对应的文档或密钥,不应该也不需要依赖破解对方前端保护。
从我个人的经验出发,最值得记住的是三个习惯:
第一,动手前先分清目标。你要做的是联调、是性能监控、是数据核对,还是纯技术学习?目的不同,操作边界完全不同。
第二,善用调试工具而不是盲目搜索代码。断点、调用栈、条件断点、本地替换这套组合,能解决80%以上的加密字段定位问题。剩下的无非是算法识别和环境补齐。
第三,解密脚本尽量本地化。一拿到密文,立刻在本地写一个最小可跑的验证脚本,用真实数据反复验证,再去追深层的逻辑。这样每一步都有结果反馈,不容易跑偏。
那次分析最终在半天内完成,关键就在定位准确、方法对路。如果你也正好在处理类似的加密字段问题,希望这篇复盘能帮你少踩几个坑。不同项目的加密强度差别很大,但分析思路是相通的,核心就是:找入口、抓函数、看参数、跑数据。把这条路走通,大部分问题都能迎刃而解。