实战解析:CryptoJS中Uint8Array与WordArray互转及AES解密
1. 项目概述当AES解密遇上二进制数据流在JS逆向和前端安全分析的日常工作中处理AES加密数据是家常便饭。但很多时候我们遇到的并不是规规矩矩的Base64字符串而是直接来自网络流或二进制文件的Uint8Array或者加密库内部使用的WordArray对象。特别是在分析一些音视频流、文件传输协议或是某些为了提升性能而直接操作二进制数据的Web应用时这种场景就变得非常普遍。标题里的“实战②”已经暗示了这不是一个理论教程而是直接切入实战中令人头疼的环节如何在这些非标准的、底层的二进制格式之间进行转换并成功完成AES解密。很多朋友在初次接触时会卡在CryptoJS.decrypt()方法报错或者解密出来的结果是一堆乱码。核心痛点往往不在于AES算法本身而在于数据格式的“翻译”上——WordArray和Uint8Array就像说着不同方言的两个人虽然表达的是同一串二进制信息但内部结构完全不同直接硬塞给对方肯定会出问题。本文将彻底拆解这个转换过程从内存布局讲起手把手带你打通Uint8Array-WordArray- 解密 -Uint8Array的完整链路并分享几个从实际逆向项目中总结出来的、教科书上不会写的调试技巧和避坑指南。2. 核心原理理解WordArray与Uint8Array的内存布局差异要解决转换问题首先得明白这两个对象在内存里到底长什么样。这是所有后续操作的基础理解透了很多错误就自然知道从哪里排查。2.1 Uint8Array直观的字节序列Uint8Array是JavaScript TypedArray的一种它代表了一个由8位无符号整数即字节值范围0-255组成的数组。它在内存中的布局是最直观的线性序列。假设我们有一个包含6个字节的数据[0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC]。 在Uint8Array中它的存储就是按顺序排列索引(index): 0 1 2 3 4 5 内存值(value): 0x12 0x34 0x56 0x78 0x9A 0xBC你可以通过array[0]直接访问到0x12非常直观。Uint8Array的length属性直接等于字节数。2.2 WordArrayCryptoJS的“字”单元WordArray是CryptoJS库内部用于表示二进制数据的基本结构。它的设计更接近加密算法的底层操作单元“字”通常是32位即4个字节。一个WordArray对象主要包含两个属性words: 一个由32位有符号整数JavaScript中的number类型组成的数组。每个整数即一个“字”存储4个字节的数据。sigBytes: 一个数字表示这个WordArray中有效字节的总数。这是关键因为words数组的长度总是4字节的整数倍但实际数据可能不是。还是用上面6个字节的数据[0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC]举例它转换成WordArray后words数组长度为Math.ceil(6 / 4) 2。两个元素两个“字”分别是words[0]: 存储前4个字节0x12, 0x34, 0x56, 0x78。在内存中它被组合成一个32位数。需要注意的是CryptoJS默认使用大端序Big-Endian即高位字节在前。所以words[0]的值为0x12345678。words[1]: 存储剩下的2个字节0x9A, 0xBC以及2个字节的填充可能是0。它会被存储为0x9ABC0000。注意最后一个字里无效的字节部分这里是最后两个字节的值是不确定的。sigBytes值为6明确告诉库只有前6个字节是有效数据。关键注意点words数组的长度乘以4即总容量可能大于sigBytes。在进行任何操作如转换为字符串或其他格式时CryptoJS都会严格依据sigBytes来截取有效数据忽略最后一个字中超出sigBytes的部分。这是很多转换错误的根源——如果你手动操作words数组但忘了处理sigBytes结果就会多出一堆垃圾字节。2.3 转换的本质字节的重组与拆分理解了内存布局转换的实质就清晰了Uint8Array-WordArray 将连续的字节流每4个一组以大端序的方式打包成一个32位整数放入words数组。同时精确计算并设置sigBytes。WordArray-Uint8Array 遍历words数组中的每个32位整数根据sigBytes的指示将其拆解回独立的字节并按顺序放入新的Uint8Array。网络上找到的那个CryptoJS.enc.u8array补丁文件其parse和stringify方法正是在精确地完成这两件工作。下面我们进入实战看看如何应用它。3. 实战拆解补全CryptoJS的Uint8Array支持CryptoJS官方并没有直接提供Uint8Array的编码器CryptoJS.enc.Uint8Array这在处理现代浏览器API或Node.js的Buffer时很不方便。因此我们需要手动“补全”这个功能。3.1 引入关键的编码器补丁首先你需要加载CryptoJS核心库以及你加解密模式如CFB、CBC、填充方式如NoPadding对应的组件。然后最关键的一步是加入下面这个自定义的编码器对象。通常我们会把它保存为一个独立的JS文件如enc-u8array.js并引入// enc-u8array.js - 为CryptoJS添加Uint8Array与WordArray的转换支持 CryptoJS.enc.u8array { /** * 将WordArray对象转换为Uint8Array。 * param {CryptoJS.lib.WordArray} wordArray - 输入的WordArray对象。 * returns {Uint8Array} 转换后的Uint8Array。 */ stringify: function (wordArray) { // 获取WordArray的内部字数组和有效字节数 var words wordArray.words; var sigBytes wordArray.sigBytes; // 创建一个长度等于有效字节数的Uint8Array var u8 new Uint8Array(sigBytes); // 遍历每一个需要输出的字节 for (var i 0; i sigBytes; i) { // 计算当前字节属于哪个字word var wordIndex i 2; // 等价于 Math.floor(i / 4) // 计算当前字节在该字中的偏移位置0, 1, 2, 3 var bytePos 3 - (i % 4); // 大端序最高位字节索引0对应偏移3 // 通过移位和掩码操作提取出目标字节 var byteValue (words[wordIndex] (bytePos * 8)) 0xff; // 将字节存入Uint8Array u8[i] byteValue; } return u8; }, /** * 将Uint8Array对象转换为WordArray。 * param {Uint8Array} u8arr - 输入的Uint8Array对象。 * returns {CryptoJS.lib.WordArray} 转换后的WordArray对象。 */ parse: function (u8arr) { var len u8arr.length; var words []; // 遍历Uint8Array每4个字节组合成一个字 for (var i 0; i len; i) { // 计算当前字节应放入words数组的哪个索引 var wordIndex i 2; // 等价于 Math.floor(i / 4) // 初始化该字如果尚未存在 if (!words[wordIndex]) { words[wordIndex] 0; } // 计算当前字节在字中的偏移位置大端序 var bytePos 3 - (i % 4); // 将字节左移到正确位置并与原字进行或运算合并 words[wordIndex] | (u8arr[i] 0xff) (bytePos * 8); } // 使用CryptoJS的内部方法创建WordArray并传入有效字节数 return CryptoJS.lib.WordArray.create(words, len); } };实操心得理解大端序Big-Endian的偏移计算代码中最容易让人困惑的是3 - (i % 4)这一行。这是因为在内存中一个32位整数如0x12345678的最高位字节是0x12称为Most Significant Byte, MSB。在大端序系统中这个MSB存储在最低的内存地址对于数组words[0]这个整体值而言。当我们将它拆分为字节数组时我们希望u8[0] 0x12。因此当i0时bytePos 3 - 0 3意味着我们从words[0]右移3*824位来获取最高位的0x12。这个细节是正确转换的生命线写错会导致所有数据错位解密必然失败。3.2 准备密钥Key和初始化向量IV在实战中Key和IV通常也是以二进制形式提供可能是十六进制字符串、Base64或者直接就是字节数组。这里假设我们从服务端接口或某个网络抓包中获取到的是字节数组形式。// 示例服务端提供的32字节256位AES密钥和16字节128位初始化向量 // 这里用十六进制数组表示实际可能来自ArrayBuffer、网络响应等。 var serverKeyBytes [0x26, 0xAF, 0xE2, 0x1A, 0x0C, 0x16, 0x73, 0x54, 0x13, 0xFD, 0x68, 0xDD, 0x8F, 0xA0, 0xB7, 0xC1, 0x57, 0xA6, 0x90, 0xFF, 0xCD, 0xB3, 0x54, 0x61, 0x10, 0x07, 0xD5, 0x7E, 0xDB, 0x1E, 0x4C, 0xE9]; var serverIvBytes [0x15, 0x4C, 0xD3, 0x55, 0xFE, 0xA1, 0xFF, 0x01, 0x00, 0x34, 0xAB, 0x22, 0x08, 0x4F, 0x13, 0x07]; // 步骤1将字节数组转换为Uint8Array var keyUint8 new Uint8Array(serverKeyBytes); var ivUint8 new Uint8Array(serverIvBytes); // 步骤2使用我们自定义的编码器将Uint8Array转换为CryptoJS所需的WordArray var keyWordArray CryptoJS.enc.u8array.parse(keyUint8); var ivWordArray CryptoJS.enc.u8array.parse(ivUint8); console.log(Key WordArray:, keyWordArray); console.log(IV WordArray:, ivWordArray); // 可以检查一下sigBytes是否正确key应为32iv应为16。4. 核心环节实现完整的加解密函数现在Key和IV已经准备就绪我们可以构建处理Uint8Array格式密文和明文的加解密函数了。4.1 解密函数从Uint8Array密文到Uint8Array明文这是逆向分析中最常用的函数拿到一段二进制密文解密出原始数据。/** * 解密Uint8Array格式的AES-CFB密文 * param {Uint8Array} encryptedUint8Array - 待解密的密文Uint8Array格式。 * param {CryptoJS.lib.WordArray} keyWordArray - 密钥WordArray格式。 * param {CryptoJS.lib.WordArray} ivWordArray - 初始化向量WordArray格式。 * returns {Uint8Array} 解密后的明文Uint8Array格式。 */ function decryptUint8Array(encryptedUint8Array, keyWordArray, ivWordArray) { // 1. 将Uint8Array密文转换为WordArray var ciphertextWordArray CryptoJS.enc.u8array.parse(encryptedUint8Array); // 2. 关键步骤将WordArray密文转换为Base64字符串。 // CryptoJS.AES.decrypt方法内部期望密文输入是Base64编码的字符串。 // 注意这里是对“密文WordArray”进行Base64编码不是对结果。 var ciphertextBase64 ciphertextWordArray.toString(CryptoJS.enc.Base64); // 3. 执行AES解密。 // 参数说明 // ciphertextBase64: Base64格式的密文字符串 // keyWordArray: 密钥WordArray // { // iv: ivWordArray, // mode: CryptoJS.mode.CFB, // 模式必须与服务端匹配 // padding: CryptoJS.pad.NoPadding // 填充方式必须与服务端匹配 // } var decryptedWordArray CryptoJS.AES.decrypt(ciphertextBase64, keyWordArray, { iv: ivWordArray, mode: CryptoJS.mode.CFB, padding: CryptoJS.pad.NoPadding }); // 4. 将解密得到的明文WordArray转换回Uint8Array var decryptedUint8Array CryptoJS.enc.u8array.stringify(decryptedWordArray); return decryptedUint8Array; }重要提示CryptoJS.AES.decrypt的密文输入要求这是最容易踩坑的地方。CryptoJS.AES.decrypt的第一个参数虽然官方文档说可以是CipherParams对象、WordArray或字符串但当它是字符串时它被假定为Base64格式。如果我们直接传入一个WordArray它会被隐式调用toString()而WordArray的默认toString()是十六进制字符串这会导致解密失败。因此显式地将密文WordArray转为Base64字符串是保证兼容性的稳妥做法。4.2 加密函数从Uint8Array明文到Uint8Array密文有时我们也需要本地加密来模拟请求或验证算法。/** * 加密Uint8Array格式的明文为AES-CFB密文 * param {Uint8Array} plaintextUint8Array - 待加密的明文Uint8Array格式。 * param {CryptoJS.lib.WordArray} keyWordArray - 密钥WordArray格式。 * param {CryptoJS.lib.WordArray} ivWordArray - 初始化向量WordArray格式。 * returns {Uint8Array} 加密后的密文Uint8Array格式。 */ function encryptUint8Array(plaintextUint8Array, keyWordArray, ivWordArray) { // 1. 将Uint8Array明文转换为WordArray var plaintextWordArray CryptoJS.enc.u8array.parse(plaintextUint8Array); // 2. 执行AES加密。 // CryptoJS.AES.encrypt 可以直接接受明文WordArray作为第一个参数。 var encryptedData CryptoJS.AES.encrypt(plaintextWordArray, keyWordArray, { iv: ivWordArray, mode: CryptoJS.mode.CFB, padding: CryptoJS.pad.NoPadding }); // 3. 加密返回的是一个包含多个属性的对象其中ciphertext属性是密文的WordArray。 var ciphertextWordArray encryptedData.ciphertext; // 4. 将密文WordArray转换回Uint8Array var encryptedUint8Array CryptoJS.enc.u8array.stringify(ciphertextWordArray); return encryptedUint8Array; }4.3 完整流程测试让我们用一个简单的例子串起整个流程验证加解密函数是否正确。// 1. 准备Key和IV (复用之前的) var keyUint8 new Uint8Array(serverKeyBytes); var ivUint8 new Uint8Array(serverIvBytes); var keyWA CryptoJS.enc.u8array.parse(keyUint8); var ivWA CryptoJS.enc.u8array.parse(ivUint8); // 2. 模拟一段明文数据 var originalData [0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21]; // Hello, World! 的ASCII码 var plaintextU8 new Uint8Array(originalData); console.log(原始明文Uint8Array:, plaintextU8); // 3. 加密 var encryptedU8 encryptUint8Array(plaintextU8, keyWA, ivWA); console.log(加密后密文Uint8Array:, encryptedU8); // 4. 解密 var decryptedU8 decryptUint8Array(encryptedU8, keyWA, ivWA); console.log(解密后明文Uint8Array:, decryptedU8); // 5. 验证将解密后的Uint8Array转回字符串 var decryptedText String.fromCharCode.apply(null, decryptedU8); console.log(解密后文本:, decryptedText); // 应输出 Hello, World!如果控制台成功输出Hello, World!那么恭喜你整个Uint8Array与WordArray互转并进行AES加解密的通道就完全打通了。5. 逆向实战中的深度技巧与问题排查掌握了基础流程在实际的JS逆向项目中你还会遇到各种“妖魔鬼怪”。下面分享几个高级技巧和常见问题的排查思路。5.1 模式与填充的识别与匹配加解密失败十有八九是模式或填充不对。服务端可能使用CBC、ECB、CFB、OFB等不同模式填充可能是PKCS#7、ZeroPadding或NoPadding。如何识别搜索关键词在目标网站的JS文件中搜索mode、padding、CBC、CFB、PKCS等。查看加密库调用找到CryptoJS.AES.encrypt或类似函数调用查看其第三个参数配置对象。逆向算法如果代码被混淆可以尝试用已知的测试数据如加密一个全零的块观察输出或者动态调试跟踪进入的加密函数内部。参考服务端如果可能查阅服务端代码或API文档这是最准确的方式。CryptoJS中的对应设置模式CryptoJS 常量备注ECBCryptoJS.mode.ECB无需IVCBCCryptoJS.mode.CBC最常用需要IVCFBCryptoJS.mode.CFB需要IVOFBCryptoJS.mode.OFB需要IVCTRCryptoJS.mode.CTR需要IV填充CryptoJS 常量说明PKCS#7CryptoJS.pad.Pkcs7默认且最常用无填充CryptoJS.pad.NoPadding数据长度必须是块大小(16字节)的倍数Zero填充CryptoJS.pad.ZeroPadding用0x00填充踩坑记录NoPadding的陷阱选择NoPadding时你必须保证待加密数据的长度是AES块大小16字节的整数倍。如果不是CryptoJS会直接抛出错误。而在某些服务端实现中可能会先对数据做一次自定义的填充比如补足到16字节的倍数然后再用NoPadding模式加密。这种情况下你需要在调用CryptoJS加密之前手动在明文后添加相同的填充字节。逆向时需要仔细分析网络数据包的长度规律。5.2 处理非标准Key/IV格式Hex、Base64与字符串实战中Key和IV很少直接以字节数组给出。更常见的是Hex字符串或Base64字符串。// 情况1Key/IV是Hex字符串如 26afe21a0c16735413fd68dd8fa0b7c1... var keyHex 26afe21a0c16735413fd68dd8fa0b7c157a690ffcdb354611007d57edb1e4ce9; var ivHex 154cd355fea1ff010034ab22084f1307; // CryptoJS内置了Hex编码器可以直接解析 var keyWA_fromHex CryptoJS.enc.Hex.parse(keyHex); var ivWA_fromHex CryptoJS.enc.Hex.parse(ivHex); // 情况2Key/IV是Base64字符串如 Jq/iGgwWc1QT/Wjdi9C3wVemkP/Ns1RhEAdVftseTOk var keyBase64 Jq/iGgwWc1QT/Wjdi9C3wVemkP/Ns1RhEAdVftseTOk; var ivBase64 FUzTVf6h/wEAE6siCk8TBw; // CryptoJS内置了Base64编码器 var keyWA_fromBase64 CryptoJS.enc.Base64.parse(keyBase64); var ivWA_fromBase64 CryptoJS.enc.Base64.parse(ivBase64); // 情况3Key/IV是普通字符串如密码需要经过哈希处理 // 通常做法是使用CryptoJS的哈希函数如SHA256生成固定长度的密钥 var passwordString mySecretPassword; // 将字符串转换为WordArray然后进行SHA256哈希输出一个256位的WordArray作为密钥 var keyWA_fromString CryptoJS.SHA256(CryptoJS.enc.Utf8.parse(passwordString)); // IV有时也会从密码派生或者是一个固定值5.3 调试技巧如何验证每一步的数据在逆向时确保每一步的数据转换都正确至关重要。打印中间状态在转换函数parse/stringify和加解密函数前后用console.log输出数据的长度、前几个字节的Hex值。对比服务端生成的数据或已知的测试向量。使用在线工具交叉验证利用如CyberChef这类强大的在线工具。你可以将你的Uint8Array以Hex形式粘贴进去手动执行AES解密步骤与你的JS代码结果对比。Hook关键函数在浏览器开发者工具的Console中重写CryptoJS.AES.encrypt或decrypt方法在其中打印出入参和返回结果这是定位服务端加密逻辑的利器。var _originalEncrypt CryptoJS.AES.encrypt; CryptoJS.AES.encrypt function(plaintext, key, cfg){ console.log([HOOK] Encrypt Called:); console.log( Plaintext (WordArray):, plaintext); console.log( Key (WordArray):, key); console.log( Config:, cfg); var result _originalEncrypt.call(this, plaintext, key, cfg); console.log( Result Ciphertext (WordArray):, result.ciphertext); return result; };验证sigBytes在操作WordArray时时刻留意sigBytes属性。一个常见的错误是手动创建WordArray时忘记设置sigBytes或者设置错误导致转换时多出或少字节。5.4 性能考量与大数据处理当处理大型二进制文件如图片、视频片段时直接操作巨大的Uint8Array可能会遇到性能问题或内存压力。分块处理AES的CFB、OFB等流加密模式支持分块加密/解密。你可以将大的Uint8Array切片Slice成较小的块例如每次64KB循环调用加解密函数。注意对于CBC等分组模式分块更复杂需要保持链式关系通常建议一次性处理或使用专门的流式API。使用Web Worker将耗时的加解密计算放到Web Worker线程中避免阻塞主线程导致页面卡顿。直接操作ArrayBufferUint8Array是建立在ArrayBuffer之上的视图。在数据来源是FileReader、fetch的Response.arrayBuffer()时直接使用ArrayBuffer可以避免一次额外的数据拷贝。6. 从浏览器到Node.js环境适配与差异处理我们的代码主要在浏览器环境运行。如果你需要移植到Node.js环境需要注意一些差异。6.1 在Node.js中使用CryptoJS首先安装CryptoJSnpm install crypto-js。 引入模块的方式不同但核心代码逻辑基本一致。// Node.js 环境 const CryptoJS require(crypto-js); // 同样需要引入我们自定义的u8array编码器可以放在同一个文件里 CryptoJS.enc.u8array { /* ... 同上stringify和parse定义 ... */ }; // 在Node中你可能会直接拿到Buffer const serverKeyBuffer Buffer.from(serverKeyBytes); // Buffer是Uint8Array的子类 const serverIvBuffer Buffer.from(serverIvBytes); // Buffer可以直接被我们的parse函数使用因为Buffer也符合Uint8Array的接口 const keyWA CryptoJS.enc.u8array.parse(serverKeyBuffer); const ivWA CryptoJS.enc.u8array.parse(serverIvBuffer); // 加解密函数完全通用6.2 使用Node.js原生Crypto模块对于性能要求更高的场景Node.js的原生crypto模块是更好的选择。它直接支持Buffer。const crypto require(crypto); function decryptWithNodeCrypto(encryptedBuffer, keyBuffer, ivBuffer) { const decipher crypto.createDecipheriv(aes-256-cfb, keyBuffer, ivBuffer); // 注意Node.js的createDecipheriv默认自动处理Padding如果是NoPadding需要额外设置 // decipher.setAutoPadding(false); let decrypted decipher.update(encryptedBuffer); decrypted Buffer.concat([decrypted, decipher.final()]); return decrypted; // 返回Buffer } function encryptWithNodeCrypto(plaintextBuffer, keyBuffer, ivBuffer) { const cipher crypto.createCipheriv(aes-256-cfb, keyBuffer, ivBuffer); let encrypted cipher.update(plaintextBuffer); encrypted Buffer.concat([encrypted, cipher.final()]); return encrypted; } // 使用示例 const keyBuffer Buffer.from(serverKeyBytes); const ivBuffer Buffer.from(serverIvBytes); const plaintextBuffer Buffer.from(Hello, World!, utf8); const encryptedBuffer encryptWithNodeCrypto(plaintextBuffer, keyBuffer, ivBuffer); const decryptedBuffer decryptWithNodeCrypto(encryptedBuffer, keyBuffer, ivBuffer); console.log(decryptedBuffer.toString(utf8)); // Hello, World!环境差异提示Node.js的crypto模块和浏览器的CryptoJS在默认行为上可能有细微差别例如默认的填充方式、IV的处理等。在跨环境验证加解密结果时务必确保所有参数密钥长度、模式、填充、IV完全一致。最可靠的方法是用同一份测试数据和密钥在两个环境中分别运行对比输出的Hex字符串。7. 总结与扩展思路通过以上步骤我们不仅解决了Uint8Array和WordArray互转的具体问题更构建了一套完整的、可用于实战的浏览器端AES二进制流加解密方案。关键在于理解数据格式的本质差异并利用自定义编码器桥接两者。对于更复杂的逆向场景比如遇到自定义的加密流程、经过混淆的代码或者需要还原整个通信协议你可以将本文的方法作为基础工具。接下来可以尝试自动化Hook编写脚本自动拦截页面中的CryptoJS对象或Uint8Array的转换过程记录下所有的密钥、IV和模式。算法还原如果对方没有使用标准库而是自己实现了AES那么你需要用JavaScript重写其加密过程。这时对WordArray这种结构的理解将至关重要。结合网络调试使用浏览器开发者工具的Network面板配合XHR/Fetch断点捕获请求和响应中的二进制数据查看Response的ArrayBuffer形式然后立即在Console中调用你的解密函数进行验证。记住逆向工程是一个“胆大心细”的过程。大胆猜测可能的加密方式细心验证每一步的数据转换。当你成功将一段乱码般的Uint8Array解密成可读的明文时那种成就感就是驱动我们不断深入探索的最佳动力。