ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Frida动态Hook解密Cocos2d-JS手游so加密脚本实战

Frida动态Hook解密Cocos2d-JS手游so加密脚本实战 1. 项目概述与核心价值最近在分析一些老旧的Cocos2d-JS手游时发现一个挺有意思的现象很多游戏的核心逻辑和资源都打包在.so动态链接库里尤其是脚本部分往往经过了加密或混淆。直接静态分析IDA看到的是一堆乱码或者加密后的数据流常规的逆向手段在这里就有点使不上劲了。这时候动态分析工具的价值就凸显出来了而Frida正是这方面的利器。这个项目就是带你一步步用Frida在游戏运行时“现场直播”式地解密出Cocos2d-JS手游so文件中的关键数据并附上可以直接拿来用的完整Hook脚本。简单来说这就像是你知道一个保险箱so文件里有重要的文件游戏脚本但保险箱上了锁加密。传统的做法是试图撬锁静态解密但锁很复杂。我们的做法是在保险箱主人游戏进程正常开锁取文件的那一刻在旁边架设一个高清摄像机Frida Hook记录下他按下的每一个密码解密函数、密钥、输入输出从而我们也能拿到那份文件。这种方法不依赖于逆向出完整的加密算法而是利用运行时环境“借力打力”特别适合对付那些加密逻辑复杂或与运行时状态强相关的保护。适合谁来参考呢如果你是对手游安全、游戏逆向或者动态分析感兴趣的安全研究人员、逆向工程师或者是一位想学习如何将Frida应用到实际复杂场景中的开发者那这篇内容会非常对胃口。你需要对Android基础、ARM汇编有初步了解并且已经搭建过Frida的基本环境。不用担心即便你Frida只写过简单的Java层Hook跟着步骤走也能理解整个流程并复现出来。2. 核心思路与技术选型解析2.1 为什么选择Frida进行动态解密面对加密的so文件我们通常有几条路一是静态分析硬刚加密算法这需要极高的密码学和逆向功底且遇到白盒加密或VM保护时几乎不可行二是动态调试用GDB或IDA在调试状态下跟踪但手游环境复杂反调试检测频繁过程繁琐。Frida提供了第三条路无侵入式的动态插桩。它允许我们将JavaScript代码注入到目标进程中拦截和修改函数调用、内存访问这一切都在进程正常运行时进行无需暂停或附加调试器极大地降低了被检测的风险。对于Cocos2d-JS手游其JavaScript脚本在发布时通常会被编译成字节码JSC文件或直接加密后打包进so库。游戏运行时由Cocos2d-x引擎的C部分在so中负责读取、解密这些数据然后交给JavaScript引擎执行。我们的目标就是Hook这个“读取-解密”的链条。Frida的Interceptor.attach可以让我们在C/C的Native层函数被调用时执行我们的代码从而捕获到解密前的密文、解密后的明文以及可能用到的密钥或算法参数。2.2 Cocos2d-JS脚本加载流程剖析要精准下钩必须了解钩子该挂在哪里。Cocos2d-x引擎中与JS脚本加载相关的关键函数通常位于libcocos2djs.so或类似的库中。一个典型的流程是资源读取游戏通过FileUtils或自定义的接口读取打包文件如.apk中的资源文件或自定义的.dat包中的脚本数据块。此时数据是加密的。解密调用读取到的加密数据会被传递给一个解密函数。这个函数可能是引擎内置的如xxtea_decrypt也可能是游戏开发者自定义的。它的输入是加密缓冲区指针和长度输出是解密后的缓冲区指针。脚本执行解密后的数据可能是JSC字节码或明文的JS代码被传递给SpiderMonkey、JavaScriptCore或V8等JS引擎去执行。我们的Hook点就选择在第2步的解密函数上。只要我们能定位到这个函数就能在它执行前后拿到输入密文和输出明文。有时候解密密钥可能作为参数传入也可能是一个全局变量或硬编码在函数内部的常量这就需要我们结合上下文进行分析。2.3 工具链与环境准备工欲善其事必先利其器。以下是完成本项目所需的核心工具及其选型理由Frida Frida-tools核心动态插桩框架。选择它是因为其跨平台Windows/macOS/Linux均可对Android/iOS进行逆向、脚本语言友好JavaScript/Python以及活跃的社区。版本对应关系需要特别注意Frida-server运行在手机上的守护进程的版本必须与PC端安装的frida和frida-tools的Python包版本严格一致否则会出现连接失败或协议错误。通常使用pip install frida-tools会连带安装匹配的frida客户端。最稳妥的方式是去Frida的GitHub Releases页面同时下载对应版本号的frida-server如frida-server-16.1.11-android-arm64.xz和通过pip install frida16.1.11 frida-tools12.1.1来安装指定版本的客户端。Android设备/模拟器测试环境。推荐使用雷电模拟器或真机。雷电模拟器通常基于Android 7.1或9且自带root部署Frida-server非常方便。真机则需要root权限或Magisk模块来绕过限制。在模拟器上安装Frida-server主要步骤是解压下载的xz文件得到二进制adb push到/data/local/tmp/adb shell进去后赋予可执行权限chmod 755 frida-server然后以后台方式运行./frida-server 。逆向分析工具用于辅助定位关键函数。IDA Pro/Ghidra静态分析so文件寻找可疑的函数名如包含decrypt、decode、xxtea、dec等字符串的函数或分析资源读取函数如FileUtils::getDataFromFile的交叉引用找到其调用链。objection基于Frida的命令行工具可以快速枚举模块、搜索内存、测试Hook用于前期侦查。代码编辑器用于编写和调试Frida JavaScript脚本。注意整个实验请在合法的、自己拥有版权的应用或明确用于安全研究的学习样本上进行严格遵守相关法律法规。3. 实战定位并Hook解密函数3.1 目标分析与函数定位假设我们有一个目标手游com.example.cocosgame。首先我们需要找到那个负责解密的Native函数。方法一字符串搜索将游戏的APK解包找到主要的游戏so库比如libcocos2djs.so。用IDA Pro加载它在Strings窗口搜索与加密解密相关的关键词如decrypt、decode、crypto、xxtea、aes等。找到这些字符串后查看其交叉引用定位到使用它们的函数。方法二导出函数分析查看so的导出函数表Exports window。Cocos2d-x引擎的一些辅助函数或工具函数可能会被导出。寻找形如decryptScriptData、decodeBuffer之类的函数名。方法三动态追踪与堆栈回溯如果静态分析找不到明显目标就需要动态手段。我们可以先Hook一些确定的起点比如fopen、fread或者Cocos2d-x的FileUtils::getDataFromFile。当游戏读取脚本文件时我们打印出调用堆栈看看是哪个上层函数最终处理了这些数据。这需要用到Frida的Thread.backtrace。这里我们假设通过静态分析在libgame.so中找到了一个可疑函数nativeDecodeBuffer其函数签名推测为void* nativeDecodeBuffer(void* encryptedData, int dataLen, const char* key)。3.2 编写Frida Hook脚本找到了目标函数接下来就是编写Hook脚本。脚本的核心任务是附加到目标进程。在目标函数被调用时打印传入的参数密文指针、长度、密钥。让原函数执行获取其返回值明文指针。将密文和明文都从内存中dump出来保存到文件。下面是一个完整的Hook脚本框架你需要根据实际找到的函数签名和地址进行修改。// hook_cocos_decrypt.js Java.perform(function () { console.log([*] Script loaded. Targeting Cocos2d-JS decrypt function.); // 指定要Hook的库和函数 var targetLib libgame.so; // 替换为你的so库名 var targetFunc nativeDecodeBuffer; // 替换为你的函数名 // 如果知道函数偏移地址也可以使用绝对地址如0x12345678 // 解析函数参数类型 (根据逆向分析结果调整) // 假设函数签名void* (*)(void* input, int len, const char* key) var nativeDecodeBuffer new NativeFunction( Module.findExportByName(targetLib, targetFunc), pointer, [pointer, int, pointer] ); // 使用Interceptor拦截该函数 Interceptor.attach(nativeDecodeBuffer, { onEnter: function (args) { console.log(\n[] ${targetFunc} called!); this.encryptedDataPtr args[0]; // 第一个参数加密数据指针 this.dataLen args[1].toInt32(); // 第二个参数数据长度 this.keyPtr args[2]; // 第三个参数密钥字符串指针 // 打印基本信息 console.log( - Encrypted Data Ptr: ${this.encryptedDataPtr}); console.log( - Data Length: ${this.dataLen} bytes); // 读取并打印密钥如果存在 if (!this.keyPtr.isNull()) { var key this.keyPtr.readCString(); console.log( - Key: ${key}); this.key key; } else { console.log( - Key: (null)); this.key null; } // 将加密数据从内存中读取出来并保存到变量中 if (this.dataLen 0 this.dataLen 1024 * 1024) { // 防止过大内存读取 this.encryptedBuffer this.encryptedDataPtr.readByteArray(this.dataLen); console.log( - Read encrypted buffer (${this.encryptedBuffer.length} bytes)); // 可选将密文实时保存到文件 (需要文件写入权限) // var filePath /data/data/${packageName}/encrypted_${Date.now()}.bin; // writeBufferToFile(filePath, this.encryptedBuffer); } else { console.warn( - Data length suspicious: ${this.dataLen}); this.encryptedBuffer null; } }, onLeave: function (retval) { console.log([] ${targetFunc} returned.); console.log( - Return Value (Plaintext Ptr): ${retval}); // 如果函数成功返回了一个指针指向解密后的数据 if (!retval.isNull() this.dataLen 0) { // 读取解密后的数据 var decryptedBuffer retval.readByteArray(this.dataLen); // 假设解密后长度不变或已知 if (decryptedBuffer) { console.log( - Read decrypted buffer (${decryptedBuffer.length} bytes)); // **核心操作将解密后的数据保存到文件** // 构建一个唯一的文件名包含时间戳和长度 var timestamp Date.now(); var savePath /sdcard/Download/decrypted_${timestamp}_${this.dataLen}.bin; // 调用一个Helper函数来写文件 saveByteArrayToFile(savePath, decryptedBuffer); console.log( [CRITICAL] Decrypted data saved to: ${savePath}); // 尝试以字符串形式预览前几百个字节可能是JS源码或JSC头 var preview bytesToString(decryptedBuffer.slice(0, Math.min(256, decryptedBuffer.length))); console.log( - Preview (first 256 bytes):\n${preview}); } } else { console.log( - No valid decrypted data returned.); } } }); // --- 工具函数定义 --- // 将字节数组保存到文件 (需要进程有写权限通常/data/data/下需要root) function saveByteArrayToFile(filePath, byteArray) { var file new File(filePath, wb); file.write(byteArray); file.close(); console.log( File written: ${filePath}); } // 将字节数组转换为可读的字符串尝试ASCII/UTF-8 function bytesToString(bytes) { var str ; for (var i 0; i bytes.length; i) { var b bytes[i]; if (b 32 b 127) { // 可打印ASCII范围 str String.fromCharCode(b); } else { str \\x (0 b.toString(16)).slice(-2); // 转义不可打印字符 } } return str; } // 辅助函数写Buffer到文件另一种实现 function writeBufferToFile(path, buffer) { var fd open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd ! -1) { write(fd, buffer, buffer.length); close(fd); } } });3.3 脚本执行与数据捕获启动环境确保目标手游已经安装在模拟器或真机上并且Frida-server已经在设备上运行。附加进程在PC的命令行中使用Frida命令附加到游戏进程。frida -U -l hook_cocos_decrypt.js -f com.example.cocosgame --no-pause-U表示连接到USB设备-l指定脚本-f表示启动应用--no-pause让应用立即启动。触发解密在游戏中正常进行触发脚本加载比如进入新关卡、打开某个界面。此时你的终端会打印出Hook函数被调用的信息。获取成果如果Hook成功在/sdcard/Download/目录下脚本中指定的路径就会生成decrypted_*.bin文件。这就是从内存中dump出来的、已经解密好的游戏脚本数据。实操心得第一次运行脚本很可能失败原因可能是函数签名不对、参数数量/类型错误或者函数是Thumb/ARM指令集混编导致地址不对。这时需要根据错误信息调整NativeFunction的参数类型声明或者使用Module.findBaseAddress(targetLib).add(offset)的方式通过偏移地址来定位函数。使用console.log(JSON.stringify(Thread.backtrace(this.context, Backtracer.ACCURATE)))在onEnter中打印堆栈可以帮助验证是否Hook到了正确的调用链。4. 高级技巧与深度解析4.1 处理未知函数签名与参数很多时候我们只能通过反汇编大致猜出函数的参数个数和类型。Frida提供了更灵活的方式来处理这种情况使用Interceptor.attach的原始地址模式并通过args数组和Memory.read系列API来手动解析。// 假设我们只知道函数的绝对地址 0xCF00A8 var targetAddress Module.findBaseAddress(libgame.so).add(0xCF00A8); Interceptor.attach(targetAddress, { onEnter: function(args) { console.log(Function at ${targetAddress} called.); // 假设第一个参数是指针尝试按int指针读取 var possibleInt Memory.readInt(args[0]); console.log(Arg0 as int: ${possibleInt}); // 尝试按C字符串读取 try { var possibleString Memory.readCString(args[0]); console.log(Arg0 as string: ${possibleString}); } catch(e) {} // 打印前三个参数的值作为指针 for(var i0; i3; i) { console.log( args[${i}]: ${args[i]}); } } });通过这种“试探性”的Hook观察不同场景下的参数值可以逐步反推出函数真实的用途和签名。4.2 解密算法识别与密钥提取Hook到解密函数并捕获输入输出后我们可能还想知道它具体用的什么算法。除了静态分析函数内部的汇编代码动态时也可以辅助判断观察数据特征对比密文和明文如果明文开头有明确的文件魔数如JSC文件的\x16\x0a或JS注释//而密文看起来是随机的说明加密是有效的。分析密钥如果密钥以参数形式传入且是字符串如mySecretKey123可能是简单的XOR或自定义流加密。如果是16/24/32字节的二进制数据则可能是AES。如果是128位16字节且无可见字符串可能是MD5或简单哈希派生出的密钥。Hook标准库函数如果怀疑使用了openssl或libcrypto库可以Hook如AES_decrypt、EVP_DecryptUpdate等标准函数来确认算法和模式。4.3 应对反调试与Frida检测一些加固后的手游会检测Frida的存在。常见检测手段包括检测frida-server相关进程名、端口默认27042、特征文件或内存中的Frida字符串。应对策略包括重命名Frida-server将frida-server二进制文件改名为其他名字如/data/local/tmp/dbus并相应修改启动命令。使用非常规端口启动frida-server时指定其他端口./frida-server -l 0.0.0.0:8080然后在PC端连接时使用frida -H 192.168.x.x:8080 ...。使用定制版或隐藏工具如objection的android hiding插件或使用frida-gum进行更底层的隐藏。静态Patch在游戏启动初期通过修改so文件或内存Patch直接绕过或禁用检测代码。这需要更深入的逆向分析。5. 常见问题排查与实战心得在实际操作中你几乎一定会遇到下面这些问题。这里我把踩过的坑和解决方案整理出来希望能帮你节省大量时间。5.1 Frida连接失败或脚本不执行症状frida -U命令报错Failed to spawn: unable to connect to device或Unable to connect to remote frida-server。排查版本一致性这是最常见的问题。用frida --version查看PC端版本用adb shell /data/local/tmp/frida-server --version查看设备端版本。必须完全一致。进程权限确保设备已root或者已使用Magisk等工具授予了ADB root权限。在雷电模拟器中通常默认就是root环境。端口冲突检查27042端口是否被占用。可以adb shell netstat -tlnp | grep 27042查看。防火墙/网络如果是通过TCP/IP连接真机确保手机和PC在同一局域网且手机的5555端口已通过adb tcpip 5555打开。5.2 Hook函数失败没有打印日志症状脚本成功注入但预期的函数调用日志没有出现。排查函数地址错误使用Module.enumerateExports(libgame.so)或Module.findExportByName(null, 函数名)来确认函数是否存在于当前进程中以及其准确地址。注意函数名可能是C修饰后的mangled name需要去.so里看导出表的确切名称。调用时机你的Hook脚本可能是在目标函数被调用之后才注入的。尝试在应用启动早期就注入使用-f参数自动启动或者Hook一个更早的、肯定会发生的函数如JNI_OnLoad确保我们的脚本在目标函数被调用前就已就位。脚本错误检查JavaScript脚本是否有语法错误。Frida的send函数或console.log如果使用不当可能导致脚本静默失败。可以先用一个最简单的Hook脚本如Hookstrlen测试环境是否正常。5.3 读取内存时崩溃SIGSEGV症状脚本执行后游戏闪退Frida输出Segmentation fault或access violation。排查空指针或无效指针在readByteArray或readCString之前一定要用isNull()判断指针是否有效。args[0]可能在某些情况下是NULL。错误的长度dataLen参数可能不是真实的缓冲区长度或者缓冲区长度比参数指示的要小。尝试先读取一个较小的固定长度如16字节测试。内存保护要读取的内存区域可能不可读。虽然这种情况在函数参数指向的缓冲区不常见但也要注意。可以尝试使用Memory.protect(ptr, len, rwx)临时修改权限需root但这有风险。5.4 解密后的数据不是可读的JS症状成功dump出数据但文件开头不是//或function等JS代码而是乱码或未知二进制。分析JSC字节码Cocos2d-JS默认会将JS编译为字节码.jsc文件。你dump出来的就是这种字节码。你需要使用专门的JSC反编译器如jsdec、cocos2d-console中的jsc2js工具但可能因版本而异来将其还原为JS源码。这又是另一个逆向课题了。二次加密或压缩解密函数可能只是第一层保护解密后的数据可能还被压缩如zlib或经过了另一种编码。你需要观察解密后数据的特征看是否有压缩头如0x78 0x9c是zlib或其它结构。Hook点不对你可能Hook的是解密过程中的一个中间函数而不是最终输出明文的那一个。需要结合调用栈进一步向上或向下追踪。5.5 实战心得与技巧由浅入深不要一开始就Hook最核心的解密函数。先尝试Hook一些简单的、确定的函数如fopen、strlen、CCFileUtils::getStringFromFile确保你的Frida环境和脚本基础功能正常。日志分级在脚本中使用不同级别的日志输出。比如console.log用于关键信息send用于将大量数据如dump的二进制传回PC端保存避免终端被刷屏。保存上下文Frida Hook的onEnter和onLeave回调中使用this.myVar ...来保存信息可以在两个回调间传递。这非常有用例如将onEnter中读取的密文和onLeave中读取的明文关联起来。模块基址重定位每次游戏启动so库加载的基址都可能变化ASLR。因此绝对地址0x12345678是不可靠的。一定要使用Module.findBaseAddress(moduleName).add(offset)的方式来计算函数地址其中offset是函数在so文件中的相对虚拟地址RVA。耐心与迭代逆向工程很少能一次成功。根据每次Hook得到的信息参数值、堆栈、输入输出不断修正你的假设调整Hook的点和方式这是一个反复迭代、逼近真相的过程。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进