逆向分析手游通信加密:基于Frida动态Hook与算法还原实战
1. 项目概述当手游通信遇上加密最近在分析一些基于Cocos2d-js引擎开发的老手游时我遇到了一个挺典型的场景游戏客户端与服务器之间的网络通信数据在抓包工具里看是一堆乱码。这通常意味着通信内容被加密了。对于想研究游戏协议、开发辅助工具或者单纯想了解其实现逻辑的人来说这无疑是一道需要跨过的门槛。这类游戏的核心逻辑和通信加解密算法往往就封装在那个名为libcocos2djs.so的动态链接库里。所以我们的目标很明确定位并逆向这个so文件中的加解密函数最终实现对其通信内容的实时解密与Hook。这不仅仅是“破解”更像是一次深入引擎内部的探索。Cocos2d-js作为一款经典的跨平台游戏引擎其JavaScript逻辑最终会通过SpiderMonkey或JavaScriptCore等引擎在本地执行而关键的、对性能有要求的模块比如加密、网络通信则会用C实现并编译进so。libcocos2djs.so就是这个桥梁里面包含了JS与Native交互的接口以及那些我们关心的“硬核”功能。通过分析它我们不仅能拿到解密密钥和算法更能理解游戏是如何组织其安全体系的。2. 核心思路与工具选型面对一个加固或混淆过的libcocos2djs.so直接静态分析汇编代码犹如大海捞针。我们的策略是动静结合以动态分析为主静态分析为辅。核心思路可以概括为“从外到内由果溯因”。2.1 为什么选择Frida作为核心工具在动态分析领域Frida几乎是当前的首选。它是一个动态代码插桩工具包允许你将JavaScript脚本或自定义库注入到目标进程中。相比于传统的调试器Frida有几个显著优势脚本化与自动化你可以用JavaScript或Python快速编写测试逻辑无需反复下断点、单步跟踪极大提升了分析效率。这对于需要反复尝试、搜索特征码的场景至关重要。跨平台对Android和iOS的支持都很好我们分析Android手游用Frida再合适不过。强大的内存操作能力能够方便地读取、修改内存数据调用原生函数甚至替换函数实现这正是我们Hook加解密函数所需要的。其他备选方案如Xposed需要修改系统环境门槛较高而传统调试器如IDA Pro的远程调试在应对反调试时可能更麻烦。因此Frida在灵活性和易用性上胜出。2.2 辅助工具链的搭建仅有Frida还不够一个高效的逆向环境需要以下工具协同Android设备/模拟器需要Root权限或使用可调试的ROM。对于手游分析推荐使用真机或像雷电、夜神这类对游戏兼容性好的模拟器。抓包工具Charles或Fiddler。用于捕获HTTPS/HTTP流量即使数据是加密的我们也能看到原始的传输内容密文这是我们的分析起点和验证终点。静态分析工具IDA Pro或Ghidra。用于反汇编libcocos2djs.so进行初步的代码浏览、字符串搜索、函数交叉引用分析帮助我们理解代码结构定位可疑函数。adb (Android Debug Bridge)用于连接设备、安装应用、转发端口等基础操作。2.3 整体分析流程设计我们的实战流程将分为四个清晰的阶段环境准备与初步侦察搭建Frida环境运行游戏确认libcocos2djs.so被加载并抓取加密的网络包。动态追踪与关键函数定位使用Frida脚本暴力枚举或通过特征Hook寻找实际执行加密或解密的函数。算法分析与参数提取在Hook点打印函数的输入、输出参数以及内部关键数据如密钥、IV结合静态分析还原加密算法常见如AES、DES、或自定义XOR。完整Hook与数据解密编写稳定的Frida Hook脚本在函数入口截获密文并调用解密逻辑或在函数出口直接获取明文实现通信流的实时解密。注意整个分析过程必须在您拥有合法权限的应用上进行例如自己开发的测试应用、已获得授权的应用或纯粹用于学习研究的开源项目。尊重知识产权和法律法规是技术从业者的底线。3. 实战环境准备与初步侦察工欲善其事必先利其器。这个阶段的目标是把所有工具配置好并确认目标状态。3.1 Frida环境部署在电脑端安装Frida和Frida-toolspip install frida-tools同时需要根据你的Android设备架构通常是arm或arm64从Frida的GitHub Releases页面下载对应的frida-server二进制文件。推送到设备并运行adb push frida-server-android-arm64 /data/local/tmp/ adb shell su cd /data/local/tmp chmod 755 frida-server-android-arm64 ./frida-server-android-arm64 保持这个终端运行。在另一个终端使用frida-ps -U命令如果能看到设备上的进程列表说明Frida服务运行正常。3.2 目标应用安装与启动将待分析的手游APK安装到设备上。通过抓包工具如Charles设置设备代理并安装Charles的根证书到设备系统证书目录需Root以确保能捕获HTTPS流量。启动游戏进行一些能触发网络通信的操作如登录、获取角色列表。3.3 确认SO加载与抓取密文在电脑上编写一个简单的Frida脚本用于枚举目标进程加载的模块// enumerate_modules.js Java.perform(function() { var modules Process.enumerateModules(); modules.forEach(function(module) { if (module.name.indexOf(cocos2djs) ! -1) { console.log([*] Found module:, module.name, Base:, module.base, Size:, module.size); } }); });使用命令frida -U -l enumerate_modules.js -f com.game.package.name --no-pause附加到游戏进程并运行脚本。你应该能看到libcocos2djs.so的加载地址和大小。这证实了我们的目标存在。同时查看Charles抓取的网络请求。你会看到请求和响应的Body部分很可能是一串不可读的十六进制数据或Base64编码的乱码。记录下其中一个典型的加密请求和响应数据包这将是我们后续验证解密是否成功的“考卷”。4. 动态追踪定位加解密函数这是整个过程中最具技巧性的部分。libcocos2djs.so中函数成百上千如何找到那个负责加解密的“关键先生”4.1 基于导出函数名的初步筛选首先我们可以用Frida枚举该SO的所有导出函数// enumerate_exports.js var moduleName libcocos2djs.so; var moduleBase Module.findBaseAddress(moduleName); if (moduleBase) { var exports Module.enumerateExports(moduleName); exports.forEach(function(exp) { // 寻找名称中包含加密相关关键词的函数 var name exp.name.toLowerCase(); if (name.indexOf(encrypt) ! -1 || name.indexOf(decrypt) ! -1 || name.indexOf(crypto) ! -1 || name.indexOf(aes) ! -1 || name.indexOf(des) ! -1) { console.log([*] Suspicious export:, exp.name, at, exp.address); } }); }不过经过混淆或未导出的函数不会被列出。这只是一个起点。4.2 基于函数调用关系的追踪更有效的方法是Hook游戏里已知的、与网络收发相关的JavaScript函数或Native函数然后回溯。Cocos2d-js的网络层最终会调用如curl_easy_perform或系统Socket函数。我们可以先Hook这些底层函数打印调用栈。// trace_socket.js Interceptor.attach(Module.findExportByName(null, send), { onEnter: function(args) { // args[1]是数据缓冲区指针args[2]是长度 var buffer args[1]; var size parseInt(args[2]); if (size 0 size 10240) { // 限制大小避免打印过多 console.log([*] send called, size:, size); // 打印调用栈寻找来自libcocos2djs.so的调用 console.log(Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress).join(\n) \n); } } });在调用栈中寻找来自libcocos2djs.so的函数地址。然后用IDA Pro打开这个SO跳转到该地址分析其周围的函数。通常在发送数据前会有一个函数负责对原始数据明文进行加密接收数据后会有一个函数负责对收到的数据密文进行解密。4.3 基于内存数据特征的暴力搜索如果上述方法不奏效可以采用一种“笨”但有效的方法搜索内存中的密钥或特征数据。例如如果你怀疑是AES加密密钥可能是16或32字节的固定值。我们可以在libcocos2djs.so的内存范围内搜索这些可能的字节序列。// search_memory.js var moduleName libcocos2djs.so; var moduleBase Module.findBaseAddress(moduleName); var moduleSize Module.findSizeByName(moduleName); console.log([*] Searching in, moduleName, range:, moduleBase, -, moduleBase.add(moduleSize)); // 假设我们猜测一个可能的密钥例如 0123456789abcdef 的hex形式 var keyPattern 30 31 32 33 34 35 36 37 38 39 61 62 63 64 65 66; var results Memory.scan(moduleBase, moduleSize, keyPattern, { onMatch: function(address, size){ console.log([] Found potential key at:, address); }, onComplete: function(){ console.log([*] Memory scan complete); } });找到这些数据后在IDA中查看其交叉引用很可能就能定位到使用它的函数。4.4 Hook常见加密库函数许多游戏会使用OpenSSL或系统自带的加密库。我们可以尝试Hook这些通用函数如AES_encrypt,AES_decrypt,EVP_CipherInit_ex等。如果游戏直接链接了这些库Hook它们能直接截获加解密操作。// hook_openssl.js var aes_encrypt Module.findExportByName(libcrypto.so, AES_encrypt); if (aes_encrypt) { Interceptor.attach(aes_encrypt, { onEnter: function(args) { // args[0]: input, args[1]: output, args[2]: key console.log([*] AES_encrypt called!); console.log( Input block:, Memory.readByteArray(args[0], 16)); console.log( Key:, Memory.readByteArray(args[2], 32)); // 假设是256位密钥 } }); }实操心得在实际分析中这几种方法往往需要结合使用。我个人的习惯是先抓包确认数据特征然后用调用栈追踪法找到大概范围最后结合静态分析IDA仔细阅读可疑函数的汇编代码观察其对数据的操作如异或、查表、移位来判断是否是加解密函数。这个过程需要耐心和一定的汇编阅读能力。5. 算法分析与参数提取一旦我们成功Hook到了一个疑似加解密的函数下一步就是深入分析其算法逻辑和具体参数。5.1 记录完整的函数调用信息我们需要编写一个更详细的Hook脚本来捕获所有相关信息// hook_crypto_func.js var targetFunc Module.findBaseAddress(libcocos2djs.so).add(0x1234AB); // 替换为实际函数地址 Interceptor.attach(targetFunc, { onEnter: function(args) { this.args args; // 保存参数指针 this.argNum 3; // 假设函数有3个参数根据实际情况调整 console.log( onEnter Function at, targetFunc, ); for (var i 0; i this.argNum; i) { var argVal this.args[i]; // 尝试将参数解释为指针数据缓冲区或整型长度/标识 console.log( args[${i}] (as ptr): ${argVal}, (as int): ${argVal.toInt32()}); // 如果参数看起来像是一个合理的指针非零且对齐尝试读取前64字节看看 if (!argVal.isNull() argVal.toInt32() 0x1000) { try { var peekData Memory.readByteArray(argVal, 64); console.log( Peek data (hex): ${Array.prototype.map.call(new Uint8Array(peekData), x (00 x.toString(16)).slice(-2)).join( )}); console.log( Peek data (ascii): ${Memory.readCString(argVal)}); // 如果是字符串 } catch(e) {} } } // 也可以打印返回地址帮助在IDA中定位 console.log( LR (Return Address):, this.context.lr); }, onLeave: function(retval) { console.log( onLeave Function ); // 打印返回值同样尝试解释 console.log( retval (as ptr):, retval, (as int):, retval.toInt32()); // 如果返回值是指向结果的指针读取结果 if (!retval.isNull() retval.toInt32() 0x1000) { try { // 我们需要知道结果长度这可能来自某个输入参数或固定值 // 假设结果长度存储在 this.args[2] 中 var outLen this.args[2].toInt32(); var resultData Memory.readByteArray(retval, outLen); console.log( Result data (hex): ${Array.prototype.map.call(new Uint8Array(resultData), x (00 x.toString(16)).slice(-2)).join( )}); } catch(e) {} } console.log(\n); } });运行这个脚本触发网络通信。观察打印的日志分析哪个参数是输入数据明文/密文哪个是输出缓冲区哪个是数据长度以及是否有密钥或初始化向量IV作为参数传入。5.2 结合静态分析还原算法将Hook得到的函数地址输入IDA Pro进行反编译如果支持或仔细阅读汇编代码。重点关注循环结构加解密通常涉及对数据块的循环处理。常量表S-BoxAES等算法会使用预定义的替换表。在IDA的二进制视图中查找大段的、看似随机的常量数据这很可能是S-Box。密钥扩展例程观察是否有函数将输入的原始密钥扩展成多轮密钥。魔数Magic Number算法中可能包含固定的魔数如AES的轮常数。通过动态Hook获得的输入、输出和密钥你可以在IDA中模拟跟踪数据流验证你的猜想。例如如果你Hook的函数输入是16字节明文和16字节密钥输出是16字节密文并且你在代码中看到了AES的S-Box和列混合操作那么基本可以确定是AES-128 ECB模式加密。5.3 确定加密模式与填充仅仅知道是AES还不够还需要知道模式ECB, CBC, CFB等和填充方式PKCS#7, ZeroPadding等。这通常通过以下方式判断IV的存在如果函数除了密钥和数据还有第三个类似长度的参数那很可能就是IV指向CBC等模式。数据长度处理观察代码中对输入数据长度的处理。如果看到长度对齐到16字节边界的操作那很可能使用了PKCS#7填充。动态测试用已知的明文如AAAAAAAAAAAAAAAA和Hook到的密钥调用你怀疑的加密函数将结果与抓包数据对比。也可以使用Python的cryptography库用相同的密钥和不同的模式/填充进行加密测试看哪种能匹配上。注意事项有些游戏会使用自定义的、经过修改的加密算法或者是在标准算法前后增加了额外的编码步骤如Base64、自定义的字节混淆。这时动态Hook获取的输入输出对比至关重要。你需要仔细比较进入函数前的数据和离开函数后的数据找出其中的变换规律。有时算法可能简单到只是一个循环异或XOR操作密钥就是异或值。6. 编写完整Hook脚本与数据解密在成功分析出算法和参数后我们就可以编写一个完整的、稳定的Frida脚本来实现通信数据的实时解密和加密。6.1 构建解密函数首先我们需要在JavaScript中实现我们分析出来的解密算法。如果算法是标准的如AES-CBC-PKCS7我们可以直接使用Frida的CipherAPI或者调用设备上已有的加密库。但为了通用性和清晰度这里展示如何在脚本中嵌入一个纯JavaScript的AES实现例如使用crypto-js的移植版或简单的XOR。// 假设我们分析出来是简单的XOR加密密钥为单字节 0xAA function customDecrypt(dataBytesArray) { var decrypted []; var key 0xAA; // 从Hook中获取的实际密钥 for (var i 0; i dataBytesArray.length; i) { decrypted.push(dataBytesArray[i] ^ key); } return decrypted; } // 如果是复杂的AES建议使用Frida的NativeFunction来调用so中的解密函数或者使用Interpreter调用纯JS库更可靠的方式是直接调用libcocos2djs.so中我们找到的那个解密函数。因为游戏本身就用它解密兼容性绝对没问题。// 获取解密函数指针假设地址为 0x7890CD var decryptFuncAddr Module.findBaseAddress(libcocos2djs.so).add(0x7890CD); // 定义NativeFunction根据函数签名来定义参数和返回类型 // 假设函数签名void* decrypt(void* input_data, int data_len, void* key) var decryptFunc new NativeFunction(decryptFuncAddr, pointer, [pointer, int, pointer]);6.2 Hook网络收发层并集成解密接下来我们需要在一个合适的时机调用我们的解密函数。最直接的时机是Hook游戏底层的数据接收函数如recv或SSL_read在数据被游戏逻辑处理前解密。// full_hook_decrypt.js Java.perform(function() { var libcocos2djsBase Module.findBaseAddress(libcocos2djs.so); // 假设我们找到的解密函数偏移是 0x7890CD密钥存储在so的某个固定地址 0x1000 var decryptFuncAddr libcocos2djsBase.add(0x7890CD); var keyPtr libcocos2djsBase.add(0x1000); // 指向密钥的指针 var keySize 16; // 密钥长度 var decryptFunc new NativeFunction(decryptFuncAddr, pointer, [pointer, int, pointer]); // Hook recv 函数 var recvAddr Module.findExportByName(libc.so, recv); Interceptor.attach(recvAddr, { onEnter: function(args) { // args[0]: socket, args[1]: buffer, args[2]: length, args[3]: flags this.buffer args[1]; this.length args[2]; }, onLeave: function(retval) { // retval 是实际接收到的字节数 var bytesReceived retval.toInt32(); if (bytesReceived 0) { // 1. 读取原始密文 var encryptedData Memory.readByteArray(this.buffer, bytesReceived); console.log([] Received encrypted data ( bytesReceived bytes):); console.log(hexDump(encryptedData)); // 2. 分配内存存放解密结果假设解密后长度不变或更短 var decryptedBufferPtr Memory.alloc(bytesReceived); // 3. 调用原生解密函数 var decryptedSizePtr decryptFunc(decryptedBufferPtr, bytesReceived, keyPtr); // 假设decryptFunc返回了解密后数据的指针或者我们直接使用decryptedBufferPtr var decryptedData Memory.readByteArray(decryptedBufferPtr, bytesReceived); // 注意实际长度可能需要从函数返回获取 console.log([] Decrypted data:); console.log(hexDump(decryptedData)); console.log( As string:, Memory.readUtf8String(decryptedBufferPtr)); // 尝试以UTF8字符串打印 // 4. 可选将解密后的数据写回原缓冲区让游戏逻辑收到明文 // Memory.writeByteArray(this.buffer, decryptedData); // 注意这可能会破坏游戏逻辑建议先只做日志记录 } } }); // 一个简单的十六进制dump函数 function hexDump(buffer) { var hex ; var ascii ; var bytes new Uint8Array(buffer); for (var i 0; i bytes.length; i) { var b bytes[i]; hex (00 b.toString(16)).slice(-2) ; ascii (b 32 b 126) ? String.fromCharCode(b) : .; if ((i 1) % 16 0) { hex ascii \n; ascii ; } } return hex; } });6.3 处理加密请求发送同理我们可以Hooksend或SSL_write函数在数据发送前进行Hook查看加密前的明文或者验证我们的加密算法理解是否正确。// 在同一个脚本中添加对send的Hook var sendAddr Module.findExportByName(libc.so, send); Interceptor.attach(sendAddr, { onEnter: function(args) { // args[1]: buffer, args[2]: length var buffer args[1]; var length args[2].toInt32(); if (length 0) { var dataToSend Memory.readByteArray(buffer, Math.min(length, 1024)); // 限制长度 console.log([-] Data to be sent (plaintext?):); console.log(hexDump(dataToSend)); console.log( As string:, Memory.readUtf8String(buffer)); } } });运行这个完整的脚本再次触发游戏网络操作。你将在控制台看到实时的、解密后的服务器响应数据以及客户端发送前的原始数据。将解密后的数据与你抓包工具中的密文对比并与游戏实际表现如登录成功、收到角色信息相互印证即可确认解密成功。7. 常见问题与排查技巧实录在实际操作中你几乎一定会遇到各种问题。下面是我在多次分析中总结的一些典型问题及其解决方法。7.1 Frida附加失败或脚本不执行问题frida-ps -U能看到进程但附加时超时或失败。排查Root权限确保设备已Root且Frida-server是以su权限在后台运行的。应用加固许多手游使用了第三方加固如360加固保、腾讯乐固。加固会在应用外层加壳阻止调试和注入。症状是Frida能附加但枚举不到libcocos2djs.so模块或者模块加载地址异常。解决方案对抗加固寻找脱壳后的APK。对于某些加固可以在应用启动早期Zygote阶段使用frida -U -fspawn 模式启动应用赶在加固代码执行前注入。使用 objectionobjection是Frida的一个封装工具有时其-g参数在Gadget模式可以绕过一些简单的反注入。定制Frida极端情况下可能需要修改Frida的二进制文件特征以绕过检测。7.2 Hook函数后游戏崩溃问题注入Hook脚本后游戏立即闪退或无响应。排查函数签名错误NativeFunction定义的参数类型、个数或调用约定stdcall,thiscall等与实际不符。Android ARM通常是arm或arm64的默认调用约定但C成员函数可能是thiscall。内存访问违规在Hook的onEnter/onLeave中尝试读取了无效的指针如args[0]是0。解决方案仔细分析汇编在IDA中确认函数的起始和结束观察其如何操作栈和寄存器来推断参数。保守读取在读取指针内容前先用Memory.isReadable(address, size)检查内存是否可读。逐步Hook先写一个最简单的Hook只打印地址不进行任何内存操作。稳定后再逐步增加功能。7.3 解密出的数据仍是乱码问题Hook成功调用了解密函数但输出的数据看起来还是乱码不是预期的JSON或协议结构。排查找错了函数Hook的可能不是最终的解密函数而是某个中间处理函数如Base64解码、解压缩函数。算法或参数错误密钥不对、IV不对、加密模式判断错误、填充方式不对。数据不完整网络包可能分片你Hook到的单次recv调用可能只是一个完整数据包的一部分。解决方案向上追踪在IDA中对疑似解密函数进行交叉引用Xrefs to看看是谁调用了它。继续Hook它的调用者观察更上层的数据流。对比验证用已知的明文-密文对进行测试。在游戏中触发一个你知道服务器会返回固定内容如“登录成功”的请求然后用你的算法解密抓到的包看是否能得到这个字符串。组合Hook同时Hookrecv和可能的数据包组装函数。有些游戏有自定义的封包格式包头包体你需要先处理掉包头剩下的才是加密的包体。7.4 性能问题与稳定性问题Hook脚本导致游戏卡顿或长时间运行后Frida断开连接。排查日志过多console.log在频繁调用的函数中会产生巨大开销。内存泄漏在Hook回调中分配了内存如Memory.alloc但没有妥善释放虽然JavaScript有GC但在频繁调用中仍需注意。解决方案条件日志只在特定条件下打印日志例如数据包含特定特征时才输出。使用send将重要日志通过send函数发送到Python端处理减少设备端的输出负担。优化逻辑确保Hook回调中的代码尽可能高效。避免复杂的循环或字符串处理。7.5 对抗反调试与反Hook问题游戏检测到Frida或调试环境主动退出或行为异常。排查检测Frida端口默认的27042端口。检测Frida进程或线程名。检测ptrace。解决方案修改端口启动Frida-server时使用-l 0.0.0.0:8080指定其他端口。使用隐蔽模式一些Frida的变种或插件可以隐藏特征。Patch检测代码在游戏启动后用Frida找到检测函数的地址直接将其代码修改为NOP指令或直接返回0。整个从解密到Hooklibcocos2djs.so通信加密的过程就像一场精细的外科手术。它考验的不仅仅是逆向工具的使用技巧更是对程序执行流程、加密学基础和问题排查能力的综合运用。每一次成功解密都是对游戏客户端与服务器之间那座“安全桥梁”的一次彻底理解。记住技术是用来学习和创造的请务必在合法合规的范围内使用这些知识。