Web Crypto API实战:AES-CBC加密逆向分析与Node.js复现
1. 项目概述:一次教科书式的AES逆向分析
最近在分析一些教育类应用的登录流程时,遇到了“升学e网通”这个案例。它的密码加密方式让我眼前一亮——这竟然是一个相当标准的AES加密实现。在如今各种魔改、混淆、自定义算法横行的逆向环境中,能遇到一个规规矩矩遵循标准协议的加密,简直像在沙漠里找到了一片绿洲。对于逆向新手来说,这无疑是一个绝佳的练手材料,因为它剥离了那些令人头疼的“黑盒”操作,让你能清晰地看到数据从明文到密文的完整、标准的转换过程。这个案例的核心,就是定位到其前端JavaScript代码中调用Web Crypto API进行AES-CBC加密的逻辑,并完整复现其加密过程,最终实现密码的模拟加密。整个过程涉及前端代码调试、加密参数提取和算法复现三个关键环节。
2. 逆向环境与目标分析
2.1 目标网站与工具准备
我们的目标是“升学e网通”的登录页面。在开始之前,需要准备好一套顺手的逆向工具链。浏览器自然是核心战场,Chrome或Edge的开发者工具是首选,其Sources面板和Network面板将是我们主要的侦察阵地。为了更高效地调试JavaScript,特别是处理经过压缩或混淆的代码,一个格式化工具(Pretty Print)是必不可少的。此外,因为涉及加密操作,我们需要一个能够执行JavaScript代码片段的环境,浏览器的Console面板就很好用,但对于更复杂的复现,也可以准备一个Node.js环境。最后,为了验证我们的复现结果,一个能够发送HTTP请求的工具如Postman或Hoppscotch也会很有帮助。
2.2 登录流程初探与加密定位
打开登录页面,输入账号密码(请务必使用测试账号),点击登录前,先打开开发者工具的Network面板,并勾选“Preserve log”。点击登录后,你会看到浏览器发起了一个登录请求。重点关注这个请求的Request Payload,你会发现密码字段(通常叫password)已经不再是明文,而是一长串看似随机的字符,这证实了密码在客户端被加密了。我们的任务就是找到生成这串字符的JavaScript代码。通常,加密函数会在点击登录按钮时被触发。我们可以在Sources面板中,对可能的按钮点击事件监听器或者表单提交事件打上断点,然后再次点击登录,让代码执行暂停在加密发生之前。另一种更直接的方法是,在Network面板中找到那个登录请求,右键点击它,选择“Copy -> Copy as cURL”,然后粘贴到一个文本编辑器中,搜索password这个关键词,看看它的值是什么样子,这能给我们一个关于密文格式(比如是否是Base64编码)的初步印象。
3. 核心加密逻辑的定位与解析
3.1 搜索与追踪加密入口点
在庞大的前端代码库中寻找加密函数,需要一些技巧。最直接的方法是在Sources面板中,对所有已加载的JavaScript文件进行全局搜索。搜索关键词可以尝试“encrypt”、“AES”、“CBC”、“password”、“crypto”等。在“升学e网通”的案例中,通过搜索“encrypt”或“AES”,我们很快就能定位到关键代码段。这些代码通常不会深度混淆,因为Web Crypto API的使用本身比较标准。找到疑似函数后,在其第一行打上断点,然后再次触发登录操作。代码执行会在此处暂停,这时我们就能进入这个函数内部,一步步观察它如何工作。
3.2 标准AES-CBC参数剖析
当断点命中后,我们进入函数内部单步执行(F11)。关键的发现就在这里:代码中明确使用了crypto.subtle.encrypt这个API。这是现代浏览器提供的用于执行底层加密操作的Web Cryptography API接口。我们需要在调试器中仔细观察传递给这个函数的参数。一个标准的AES-CBC加密调用通常如下所示:
const encrypted = await crypto.subtle.encrypt( { name: "AES-CBC", // 算法模式 iv: ivArrayBuffer, // 初始化向量 }, keyObject, // 加密密钥 plaintextArrayBuffer // 明文数据 );从这里,我们必须提取出三个最关键的参数:
- 密钥 (Key):这是加密解密的根本。它可能是一个固定的字符串(硬编码),也可能是从某个接口动态获取的,或者是通过某种算法(如PBKDF2)从用户密码派生而来。在调试器中,找到
keyObject变量的来源至关重要。 - 初始化向量 (IV, Initialization Vector):CBC模式必需的参数,一个随机或固定的字节序列,用于确保相同的明文加密每次产生不同的密文。我们需要找到
ivArrayBuffer的值。 - 明文数据 (Plaintext):即用户输入的原始密码。我们需要确认在加密前,密码是否经过了其他处理(比如添加了盐值
salt,或者进行了特定的编码转换)。
在“升学e网通”的实例中,经过调试发现,其密钥是固定值,通常是一个字符串(如“某固定密钥”),并通过TextEncoder转换为ArrayBuffer。IV也是一个固定值。这正是它“标准”和“简单”的地方——所有加密要素都是静态可知的,没有动态协商或复杂变换。
注意:在调试时,对于
ArrayBuffer类型的数据,直接console.log打印出来是看不到具体内容的。你需要将其转换为十六进制字符串或Base64字符串来查看。例如,在Console中执行btoa(String.fromCharCode(...new Uint8Array(ivArrayBuffer)))可以将ArrayBuffer转为Base64查看。
3.3 数据编码与传输格式确认
crypto.subtle.encrypt返回的结果也是一个ArrayBuffer。前端需要将这个二进制密文转换为字符串才能通过网络传输。常见的转换方式是Base64编码。我们需要在代码中紧接着加密函数之后,寻找类似btoa、Buffer.from().toString('base64')或者引入的第三方Base64库的调用。在“升学e网通”中,加密后的ArrayBuffer被直接转换为Base64字符串,然后作为password字段的值发送。这一步的确认,保证了我们复现算法后,输出格式与目标完全一致。
4. 加密算法的完整复现
4.1 复现环境选择:Node.js
既然我们已经在前端浏览器环境中搞清了所有参数和步骤,现在就需要在一个独立的环境(比如后端或脚本)中复现这个加密过程。Node.js是一个完美选择,因为它内置了crypto模块,功能强大且与Web Crypto API有一定相似性。我们将使用Node.js的crypto模块来重写加密函数。
4.2 关键参数提取与验证
首先,将我们在浏览器调试中抓取到的关键参数记录下来:
- 密钥明文:假设我们从代码中看到密钥字符串是
“this_is_a_secret_key”。 - IV明文:假设我们看到IV是
“initial_vector_iv”。 - 算法细节:AES-CBC,密钥长度可能是128、192或256位。这通常由密钥字符串的长度决定。一个16字节(128位)的密钥很常见。
这里有一个非常重要的步骤:验证这些参数的正确性。我们可以写一个简单的Node.js测试脚本,用这些参数加密一个已知的密码(比如“123456”),然后将输出的Base64密文,与我们在浏览器Network里捕获到的、用相同密码登录时产生的密文进行比对。如果一致,恭喜你,参数正确;如果不一致,就需要回头检查密钥或IV的提取是否正确,或者密码在加密前是否被做了其他处理(例如,是否在密码前后拼接了其他字符串)。
4.3 Node.js 复现代码详解
以下是一个完整的Node.js复现代码示例,包含了详细的注释:
const crypto = require('crypto'); function encryptPassword(password) { // 1. 定义固定的密钥和IV(此处为示例,需替换为实际抓取的值) const KEY_STRING = 'this_is_a_secret_key'; // 替换为实际密钥 const IV_STRING = 'initial_vector_iv'; // 替换为实际IV // 2. 处理密钥:AES-128-CBC要求密钥长度为16字节。 // 如果密钥字符串不是16字节,需要进行处理。常见方法是取MD5哈希值的前16位,或者直接使用字符串的字节表示(如果刚好16字节)。 // 假设我们的密钥字符串就是16字节,则直接使用。 const key = Buffer.from(KEY_STRING, 'utf-8'); // 将字符串转为Buffer // 如果密钥长度不足16字节,可以填充;如果超过,可以截取。这里假设KEY_STRING设计时就是16字节。 if (key.length !== 16) { console.warn(`密钥长度(${key.length}字节)非标准16字节,可能需特殊处理。`); // 一种常见做法:使用密钥字符串的MD5值作为密钥(16字节) // const key = crypto.createHash('md5').update(KEY_STRING).digest(); } // 3. 处理IV:CBC模式要求IV为16字节。 const iv = Buffer.from(IV_STRING, 'utf-8'); if (iv.length !== 16) { console.warn(`IV长度(${iv.length}字节)非标准16字节,可能需特殊处理。`); // 同样,可以截取或填充至16字节。例如,用0填充:const iv = Buffer.alloc(16, 0); iv.write(IV_STRING); } // 4. 创建Cipher对象,指定算法为'aes-128-cbc',使用上文的key和iv。 const cipher = crypto.createCipheriv('aes-128-cbc', key, iv); // 5. 执行加密:更新(传入明文)并最终化(获取全部密文)。 let encrypted = cipher.update(password, 'utf8', 'base64'); // 输入编码utf8,输出编码base64 encrypted += cipher.final('base64'); // 完成加密,追加剩余输出 // 6. 返回Base64格式的密文。 return encrypted; } // 测试 const testPassword = 'my_password_123'; const encryptedPassword = encryptPassword(testPassword); console.log('明文密码:', testPassword); console.log('加密后(Base64):', encryptedPassword);代码关键点解析:
crypto.createCipheriv:这是核心函数,用于创建加密器。aes-128-cbc指定了算法和模式。如果密钥是24字节或32字节,则对应aes-192-cbc或aes-256-cbc。cipher.update和cipher.final:这是流式加密的典型用法。update可以分块处理数据,final输出最后一块并清理资源。对于密码这种短数据,一次update接final即可。- 编码一致性:
update方法的第二个参数是输入编码(‘utf8’),第三个参数是输出编码(‘base64’)。这必须与前端加密时的处理方式一致。前端如果明文是字符串,通常也是UTF-8编码;输出如果是Base64,这里就选Base64。
4.4 复现结果验证与抓包比对
运行上述Node.js脚本,得到一个Base64加密字符串。然后,打开浏览器,在“升学e网通”登录页面,输入你在Node.js脚本中使用的相同测试密码(my_password_123),抓取登录请求。对比抓包数据中的password字段值,和你脚本输出的值。如果两者完全一致,那么你的逆向复现就成功了。这意味着你完全掌握了它的加密逻辑,可以在任何需要模拟登录的地方使用这个加密函数了。
5. 逆向过程中的常见问题与深度思考
5.1 典型问题排查清单
即使面对如此标准的加密,在逆向过程中也可能遇到一些坑。下面是一个快速排查表:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| Node.js加密结果与浏览器抓包结果不一致 | 1. 密钥/IV提取错误或编码不一致。 2. 密码明文在加密前被预处理(如加盐、拼接)。 3. AES密钥长度或模式不匹配(如误用ECB模式)。 4. 输出编码不一致(如浏览器是Base64 URL Safe)。 | 1.核对参数:在浏览器调试态,将密钥、IV的ArrayBuffer转为Hex或Base64,与Node.js脚本中使用的Buffer进行严格比对。 2.追踪明文:在加密函数入口打断点,查看即将被加密的明文数据到底是什么,确认是否就是原始密码字符串。 3.检查算法:确认浏览器 crypto.subtle.encrypt的name参数确实是”AES-CBC”,并确认密钥字节数对应的AES变体(128/192/256)。4.检查编码:对比抓包密文和Node.js输出,看是否存在 +/-和//_的替换,这是Base64 URL Safe的特征。 |
| 无法在代码中搜索到“encrypt”、“AES”等关键词 | 1. 代码被重度混淆,函数名变量名被替换。 2. 加密逻辑被封装在Web Worker或异步加载的模块中。 3. 使用了非标准的加密库(如CryptoJS)。 | 1.格式化与搜索:使用开发者工具的格式化功能,然后搜索更底层的API名,如”subtle.encrypt”、”CBC”。2.网络包搜索:在Network面板,搜索所有加载的JS文件内容(Chrome支持在Network面板下直接搜索文件内容)。 3.事件监听:在登录按钮上查看事件监听器,找到对应的处理函数,逐步跟进。 |
| 密钥或IV看起来是动态的 | 密钥或IV可能从服务器接口获取,或由前端代码动态生成。 | 1.Network搜索:在登录前的网络请求中,寻找可能返回密钥或IV的接口。 2.代码生成逻辑:如果动态生成,需逆向生成算法(如从某个固定字符串派生)。此时,标准AES的“简单”部分可能就不复存在了。 |
5.2 关于“标准”加密的思考与安全启示
“升学e网通”采用标准AES-CBC加密,从逆向学习角度是福音,但从安全角度却暴露了问题。固定密钥和固定IV是安全大忌。这意味着所有用户的密码密文,在已知明文攻击下是脆弱的。一旦攻击者通过某种方式知道了密钥,所有历史通信的密码都可能被解密。更佳的做法应该是:
- 使用HTTPS:这是基础,确保传输过程安全。
- 非对称加密:前端使用公钥加密密码,后端用私钥解密。这样前端无需存储密钥。
- 动态密钥协商:如使用ECDH等密钥交换协议。
- 至少使用随机IV:即使使用对称加密,每次加密使用随机IV,能保证相同明文加密结果不同。
作为开发者,在设计系统时,应避免将对称加密的密钥硬编码在前端代码中。作为安全研究人员或逆向学习者,这个案例则清晰地展示了,“标准”不等于“安全”。逆向工程不仅能帮助我们理解逻辑、实现自动化,更能让我们从攻击者的视角审视系统脆弱点,这对于构建更健壮的系统至关重要。
5.3 从该案例延伸的逆向学习路径
掌握了这个标准AES案例后,你可以尝试挑战更复杂的场景:
- 魔改AES:遇到密钥不是直接使用,而是经过一个自定义函数变换;或者AES的S盒被替换。
- RSA加密:定位前端公钥,学习如何用Node.js的
crypto模块进行RSA加密。 - 哈希加盐:密码不是加密,而是进行
MD5(password + salt)或SHA256哈希,需要找到salt的生成逻辑。 - 代码混淆对抗:当核心函数名被混淆成
a0b1c2时,如何通过调用栈、参数类型和网络行为来定位关键函数。
每一次逆向都是一次解谜。从简单的、标准的算法入手,建立信心和方法论,再逐步攻克更复杂的堡垒,这才是技术能力提升的扎实路径。这个“升学e网通”的案例,无疑是一个近乎完美的起点。
