Frida动态Hook解密Cocos2d-JS手游so加密脚本实战
2026/8/8 7:42:50 网站建设 项目流程

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或类似的库中。一个典型的流程是:

  1. 资源读取:游戏通过FileUtils或自定义的接口读取打包文件(如.apk中的资源文件或自定义的.dat包)中的脚本数据块。此时数据是加密的。
  2. 解密调用:读取到的加密数据会被传递给一个解密函数。这个函数可能是引擎内置的(如xxtea_decrypt),也可能是游戏开发者自定义的。它的输入是加密缓冲区指针和长度,输出是解密后的缓冲区指针。
  3. 脚本执行:解密后的数据(可能是JSC字节码或明文的JS代码)被传递给SpiderMonkey、JavaScriptCore或V8等JS引擎去执行。

我们的Hook点就选择在第2步的解密函数上。只要我们能定位到这个函数,就能在它执行前后,拿到输入(密文)和输出(明文)。有时候,解密密钥可能作为参数传入,也可能是一个全局变量或硬编码在函数内部的常量,这就需要我们结合上下文进行分析。

2.3 工具链与环境准备

工欲善其事,必先利其器。以下是完成本项目所需的核心工具及其选型理由:

  • Frida & Frida-tools:核心动态插桩框架。选择它是因为其跨平台(Windows/macOS/Linux均可对Android/iOS进行逆向)、脚本语言友好(JavaScript/Python),以及活跃的社区。版本对应关系需要特别注意:Frida-server(运行在手机上的守护进程)的版本必须与PC端安装的fridafrida-tools的Python包版本严格一致,否则会出现连接失败或协议错误。通常使用pip install frida-tools会连带安装匹配的frida客户端。最稳妥的方式是去Frida的GitHub Releases页面,同时下载对应版本号的frida-server(如frida-server-16.1.11-android-arm64.xz)和通过pip install frida==16.1.11 frida-tools==12.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文件,寻找可疑的函数名(如包含decryptdecodexxteadec等字符串的函数),或分析资源读取函数(如FileUtils::getDataFromFile)的交叉引用,找到其调用链。
    • objection:基于Frida的命令行工具,可以快速枚举模块、搜索内存、测试Hook,用于前期侦查。
  • 代码编辑器:用于编写和调试Frida JavaScript脚本。

注意:整个实验请在合法的、自己拥有版权的应用或明确用于安全研究的学习样本上进行,严格遵守相关法律法规。

3. 实战:定位并Hook解密函数

3.1 目标分析与函数定位

假设我们有一个目标手游com.example.cocosgame。首先,我们需要找到那个负责解密的Native函数。

方法一:字符串搜索将游戏的APK解包,找到主要的游戏so库,比如libcocos2djs.so。用IDA Pro加载它,在Strings窗口搜索与加密解密相关的关键词,如decryptdecodecryptoxxteaaes等。找到这些字符串后,查看其交叉引用,定位到使用它们的函数。

方法二:导出函数分析查看so的导出函数表(Exports window)。Cocos2d-x引擎的一些辅助函数或工具函数可能会被导出。寻找形如decryptScriptDatadecodeBuffer之类的函数名。

方法三:动态追踪与堆栈回溯如果静态分析找不到明显目标,就需要动态手段。我们可以先Hook一些确定的起点,比如fopenfread或者Cocos2d-x的FileUtils::getDataFromFile。当游戏读取脚本文件时,我们打印出调用堆栈,看看是哪个上层函数最终处理了这些数据。这需要用到Frida的Thread.backtrace

这里,我们假设通过静态分析,在libgame.so中找到了一个可疑函数nativeDecodeBuffer,其函数签名推测为void* nativeDecodeBuffer(void* encryptedData, int dataLen, const char* key)

3.2 编写Frida Hook脚本

找到了目标函数,接下来就是编写Hook脚本。脚本的核心任务是:

  1. 附加到目标进程。
  2. 在目标函数被调用时,打印传入的参数(密文指针、长度、密钥)。
  3. 让原函数执行,获取其返回值(明文指针)。
  4. 将密文和明文都从内存中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 脚本执行与数据捕获

  1. 启动环境:确保目标手游已经安装在模拟器或真机上,并且Frida-server已经在设备上运行。
  2. 附加进程:在PC的命令行中,使用Frida命令附加到游戏进程。
    frida -U -l hook_cocos_decrypt.js -f com.example.cocosgame --no-pause
    -U表示连接到USB设备,-l指定脚本,-f表示启动应用,--no-pause让应用立即启动。
  3. 触发解密:在游戏中正常进行,触发脚本加载(比如进入新关卡、打开某个界面)。此时,你的终端会打印出Hook函数被调用的信息。
  4. 获取成果:如果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 i=0; i<3; 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标准库函数:如果怀疑使用了openssllibcrypto库,可以Hook如AES_decryptEVP_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 ...
  • 使用定制版或隐藏工具:如objectionandroid hiding插件,或使用frida-gum进行更底层的隐藏。
  • 静态Patch:在游戏启动初期,通过修改so文件或内存Patch,直接绕过或禁用检测代码。这需要更深入的逆向分析。

5. 常见问题排查与实战心得

在实际操作中,你几乎一定会遇到下面这些问题。这里我把踩过的坑和解决方案整理出来,希望能帮你节省大量时间。

5.1 Frida连接失败或脚本不执行

  • 症状frida -U命令报错Failed to spawn: unable to connect to deviceUnable to connect to remote frida-server
  • 排查
    1. 版本一致性:这是最常见的问题。用frida --version查看PC端版本,用adb shell /data/local/tmp/frida-server --version查看设备端版本。必须完全一致
    2. 进程权限:确保设备已root,或者已使用Magisk等工具授予了ADB root权限。在雷电模拟器中,通常默认就是root环境。
    3. 端口冲突:检查27042端口是否被占用。可以adb shell netstat -tlnp | grep 27042查看。
    4. 防火墙/网络:如果是通过TCP/IP连接真机,确保手机和PC在同一局域网,且手机的5555端口已通过adb tcpip 5555打开。

5.2 Hook函数失败,没有打印日志

  • 症状:脚本成功注入,但预期的函数调用日志没有出现。
  • 排查
    1. 函数地址错误:使用Module.enumerateExports('libgame.so')Module.findExportByName(null, '函数名')来确认函数是否存在于当前进程中,以及其准确地址。注意,函数名可能是C++修饰后的(mangled name),需要去.so里看导出表的确切名称。
    2. 调用时机:你的Hook脚本可能是在目标函数被调用之后才注入的。尝试在应用启动早期就注入(使用-f参数自动启动),或者Hook一个更早的、肯定会发生的函数(如JNI_OnLoad),确保我们的脚本在目标函数被调用前就已就位。
    3. 脚本错误:检查JavaScript脚本是否有语法错误。Frida的send函数或console.log如果使用不当可能导致脚本静默失败。可以先用一个最简单的Hook脚本(如Hookstrlen)测试环境是否正常。

5.3 读取内存时崩溃(SIGSEGV)

  • 症状:脚本执行后,游戏闪退,Frida输出Segmentation faultaccess violation
  • 排查
    1. 空指针或无效指针:在readByteArrayreadCString之前,一定要用isNull()判断指针是否有效。args[0]可能在某些情况下是NULL
    2. 错误的长度dataLen参数可能不是真实的缓冲区长度,或者缓冲区长度比参数指示的要小。尝试先读取一个较小的固定长度(如16字节)测试。
    3. 内存保护:要读取的内存区域可能不可读。虽然这种情况在函数参数指向的缓冲区不常见,但也要注意。可以尝试使用Memory.protect(ptr, len, 'rwx')临时修改权限(需root),但这有风险。

5.4 解密后的数据不是可读的JS

  • 症状:成功dump出数据,但文件开头不是//function等JS代码,而是乱码或未知二进制。
  • 分析
    1. JSC字节码:Cocos2d-JS默认会将JS编译为字节码(.jsc文件)。你dump出来的就是这种字节码。你需要使用专门的JSC反编译器(如jsdeccocos2d-console中的jsc2js工具,但可能因版本而异)来将其还原为JS源码。这又是另一个逆向课题了。
    2. 二次加密或压缩:解密函数可能只是第一层保护,解密后的数据可能还被压缩(如zlib)或经过了另一种编码。你需要观察解密后数据的特征,看是否有压缩头(如0x78 0x9c是zlib)或其它结构。
    3. Hook点不对:你可能Hook的是解密过程中的一个中间函数,而不是最终输出明文的那一个。需要结合调用栈,进一步向上或向下追踪。

5.5 实战心得与技巧

  1. 由浅入深:不要一开始就Hook最核心的解密函数。先尝试Hook一些简单的、确定的函数,如fopenstrlenCCFileUtils::getStringFromFile,确保你的Frida环境和脚本基础功能正常。
  2. 日志分级:在脚本中使用不同级别的日志输出。比如,console.log用于关键信息,send用于将大量数据(如dump的二进制)传回PC端保存,避免终端被刷屏。
  3. 保存上下文:Frida Hook的onEnteronLeave回调中,使用this.myVar = ...来保存信息,可以在两个回调间传递。这非常有用,例如将onEnter中读取的密文和onLeave中读取的明文关联起来。
  4. 模块基址重定位:每次游戏启动,so库加载的基址都可能变化(ASLR)。因此,绝对地址0x12345678是不可靠的。一定要使用Module.findBaseAddress(moduleName).add(offset)的方式来计算函数地址,其中offset是函数在so文件中的相对虚拟地址(RVA)。
  5. 耐心与迭代:逆向工程很少能一次成功。根据每次Hook得到的信息(参数值、堆栈、输入输出),不断修正你的假设,调整Hook的点和方式,这是一个反复迭代、逼近真相的过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询