ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cocos2d-x手游逆向实战:用Frida动态解密Lua脚本

Cocos2d-x手游逆向实战:用Frida动态解密Lua脚本 看到这个标题点进来的朋友估计要么是正在啃某个Cocos2d-x手游要么是刚摸到Frida这个工具想找个实战场景练手。我先说结论这条路是通的而且比你想的要快。Cocos2d-x是国内中轻度手游里使用率最高的开源引擎之一大量卡牌、RPG、棋牌类游戏都靠它配合Lua脚本实现核心玩法而Frida是近几年在客户端安全分析、App动态调试、内存逆向里出现频率最高的动态插桩工具。把这两者结合起来就是一套完整的“分析游戏逻辑、解密资源文件、理解引擎行为”的打法。这篇文章会从引擎判型、Frida环境搭建一路讲到lua字节码的动态解密、常见崩溃排查适合有基础编程经验、准备入门或已经在做移动端逆向分析的开发者参考。1. 内容整体设计与思路拆解1.1 拿到APK先别急着Hook确认它是不是Cocos2d-x很多新手拿到一个游戏包就直接上Frida结果脚本写了一堆连目标so都没找到浪费时间。正确的第一步是判型。Cocos2d-x游戏有几个很明显的皮肤特征解包后lib目录下存在libcocos2dlua.so、libcocos2dcpp.so或libgame.so这类带cocos标识的引擎soassets目录结构通常是res/、src/、script/src下必然有main.lua或类似的启动脚本用jadx打开AndroidManifest.xml入口Activity大多直接或间接继承org.cocos2dx.lib.Cocos2dxActivity或者内部引用了libcocos.so。用一条命令就能快速验证unzip -l game.apk | grep -E libcocos|src/main.lua|script如果看到libcocos2dlua.so和src/main.lua那基本就是Cocos2d-x Lua的组合。这里要区分一个关键点libcocos2dlua.so表示脚本走Lua虚拟机libcocos2dcpp.so表示纯C逻辑两者后续的Hook点完全不同。下面所有内容默认针对Lua版本这也是市面上最常见的形态。1.2 为什么选Frida而不是Xposed或LSPosed我经常被问做游戏逆向用Xposed不也挺好Xposed确实能hook Java层但Cocos2d-x游戏的核心逻辑90%都在native层加上很多游戏已经做了V2签名校验和反Xposed检测Java层的hook空间非常有限。LSPosed虽然也能配合XposedBridge但模块一旦激活需要重启系统调试时候的反馈周期太长效率很低。Frida的优势在于它是“注入式”的不用改系统、不用重启、不落盘一条命令挂上去就能开始调试。它的编程模型是Python宿主加JavaScript脚本写起来快十几行代码就能完成一个完整的函数级Hook。API也很直接Interceptor.attach挂函数、Memory.readByteArray读内存、Module.findExportByName找导出符号整个链路非常顺滑。对游戏这种“跑起来才能暴露问题”的目标来说Frida的轻量和即时性就是最大的优势。当然它也有缺点最典型的是反调试检测。很多商业游戏会检测Frida默认端口27042或者扫描maps里的特征字符串。这个后面我会专门讲怎么绕先不在这里展开。1.3 动态解密的核心理念绕过算法而不是破解算法Cocos2d-x游戏要保护Lua脚本常见做法是对源文件做xxtea加密、异或混淆或者把文件头改成PNG来伪装。这些算法本身设计得很巧妙直接在磁盘上逆向解密流程往往要啃大半天算法代码性价比极低。但你会发现一个矛盾文件在磁盘上是密的游戏要跑起来必须先把它们解成明文放进内存。这一步无论如何躲不掉。所以动态解密的核心思路是不去逆向解密算法而是等在解密完成之后、数据被消费之前直接从内存里把明文掏出来。就像你不需要知道保险箱的密码是什么等主人自己打开保险箱拿东西的时候你在旁边拍照就行了。这个思路落到Cocos2d-x上就是找到引擎从“密文”到“Lua虚拟机”之间的那个临界点然后在那儿下一个Interceptor.attach。理论部分先说到这下面开始搭环境。2. 环境准备与工具链选择2.1 PC端Frida开发环境搭建Frida的控制端是Python包安装非常简单pip install frida-tools frida --version安装完成之后大概率会遇到的第一个坑就是版本不匹配。PC端的frida、frida-tools、设备端的frida-server三者版本必须严格对齐否则连接设备时会报Unable to connect to remote frida-server。特别是有些人直接pip install frida-tools装到最新版而手机里跑的是半年前下载的frida-server版本对不上连不上是必然的。我的建议是固定版本。例如统一用16.0.19PC端这样装pip install frida16.0.19 frida-tools12.4.2设备端也去对应的release版本找frida-server-16.0.19-android-arm64。把版本钉死环境一次到位省得后面各种玄学报错。2.2 设备端frida-server部署设备端需要一个root过的Android设备或者一个支持su的模拟器。真机和模拟器各有优劣真机上的游戏不会因为模拟器检测而拒绝运行但有的游戏会在arm64的真机上跑arm32的so调试时要注意架构模拟器调试起来方便但很多中重度游戏带了模拟器检测要么闪退要么限制功能新手容易误判成反调试。没特殊原因我建议用真机二手的Pixel或者老款骁龙机型都行主要图一个root方便、系统兼容性好。部署步骤查看设备架构adb shell getprop ro.product.cpu.abi从GitHub release页下载对应abi的frida-server例如frida-server-16.0.19-android-arm64.xz。推送到设备并启动adb push frida-server-16.0.19-android-arm64 /data/local/tmp/frida-server adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 验证连接frida-ps -U能列出设备上的进程说明连接成功。这条命令我几乎每天都会敲它是整个调试流程的“脉搏”一旦列不出进程后面什么都别谈。2.3 定位目标进程和引擎so游戏安装后包名和进程名不一定一样。例如包名是com.example.game但跑核心逻辑的进程可能是com.example.game:main或者干脆是主进程。先用frida-ps -U找到准确进程名进入Frida脚本后再用包名或进程名附加。引擎so是否加载一样可以在命令行确认adb shell cat /proc/$(pidof com.example.game)/maps | grep cocos如果输出里有libcocos2dlua.so说明引擎so已经加载到了进程空间。这一步的意义在于如果你的Hook没生效先怀疑是不是so还没加载而不是脚本写错。尤其是那些在启动后延迟加载脚本模块的游戏更需要先确认加载时机。3. 核心细节解析与实操要点3.1 Cocos2d-x的Lua加载链路找到最稳的Hook点要做动态解密先要在脑内建立一条Lua脚本的完整加载链路。我简化一下实际开发中需要沿着这条主链路去定位Cocos2dxActivity启动创建Cocos2dxRenderernative层创建LuaStack调用LuaStack::create()初始化Lua虚拟机引擎通过FileUtils读取src/main.lua这里读出来的是加密后的文件数据解密模块对文件数据做解密得到Lua源码或Lua字节码引擎调用luaL_loadbuffer()把已成明文的字符串或字节码交给Lua虚拟机编译执行。第5步的luaL_loadbuffer就是整条链路里最稳的Hook点。原因很简单它是Lua C API的公开导出函数所有Lua脚本无论加密方式多复杂最终都要从它这儿进入虚拟机。它的函数签名是int luaL_loadbuffer(lua_State *L, const char *buff, size_t size, const char *name);buff指向的就是解密后的明文数据size是它的长度。在这里Hook拿到的是“解密后、执行前”的完整数据时机完美。有些引擎不直接导出luaL_loadbuffer而是封装成了LuaStack::luaLoadBuffer之类的成员函数。你在导出表里搜luaLoad、loadBuffer关键词通常都能找到对应符号。再偏门一些的会把luaL_loadbuffer静态链接进引擎so且剥掉符号那就需要用特征码扫内存这个我放在3.2节讲。3.2 定位关键函数的三种方式方式一直接查导出表。so没有被strip的情况下用Frida的API能轻松找到导出函数const exports Module.enumerateExports(libcocos2dlua.so); exports.filter(e e.name.includes(luaL_loadbuffer)).forEach(e { console.log(e.name, e.address); });方式二无符号但有版本信息。很多游戏只strip了符号表但so里还保留着.dynsym之外的引擎版本字符串比如cocos2d-x-3.17.2。先定位版本号再去本地编译一个同版本的Cocos2d-x工程用同版本so的基础偏移加函数偏移来定位。这个方法比较麻烦但对付剥了符号但没改代码的引擎非常有效。方式三完全无符号且被混淆。这时候用内存特征码扫描。例如luaL_loadbuffer在arm64下的前几条指令相对固定可以通过Memory.scan在so的可执行段里搜特征字节。特征码的获取需要你手上有同版本的原版so做对比或者先用IDA/ghidra分析出函数首地址。这条路门槛稍高而且不同引擎版本特征不同属于“攻坚用”的方法。日常分析先走方式一能解决九成的问题。3.3 先插桩观察再动手写完整脚本新手最容易犯的错误是一上来就写一个上百行的脚本功能塞得满满当当结果挂上去直接闪退连哪里出错都不知道。我建议把调试过程拆成三步。第一步先hook底层libc函数观察文件读取顺序。比如openat、read、fopen这些系统函数稳定、参数简单能帮你快速确认哪些文件在什么时候被引擎加载。第二步hook引擎层的关键导出函数打印调用栈。可以用这样一段代码Interceptor.attach(Module.findExportByName(libcocos2dlua.so, luaL_loadbuffer), { onEnter(args) { console.log(loadbuffer called, name:, args[3].readCString()); console.log(Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress).join(\n)); } });调用栈能看到这个函数是被谁调起来的顺着栈往上翻很容易找到解密函数的位置。第三步确认链路没问题了再实现完整的解密和dump逻辑。观察和定位占到整个逆向周期的大半时间真正写脚本的时间其实很少。把观察做扎实后面就是水到渠成。3.4 处理C对象和std::string的细节Cocos2d-x是C写的Hook它的成员函数时必须处理this指针。在arm64架构下Frida回调中的args[0]就是this指针后续的参数依次后移。很多人写onEnter(args)时把第一个参数当成业务参数去解析结果数据全是乱的就是这个原因。还要注意std::string的读取方式。libc的std::string有小字符串优化机制短字符串一般不超过22个字节直接存在对象内部不需要堆分配长字符串则通过指针指向堆内存。如果直接按指针去读短字符串你会读到一段不相关的栈数据。我一般会先检查长度字段再决定用哪种方式读取。建议在脚本里封装一个readStdString(addr)函数根据内部布局返回正确的字符串内容。这块虽然繁琐但绕不开因为Lua脚本路径、文件内容、引擎日志很多都是std::string类型。踩过几次坑之后你会在任何C逆向里都先检查对象布局再动手读字段。4. 实操过程与核心环节实现4.1 lua字节码动态解密完整脚本前面铺垫了那么多现在给一套可以直接改着用的完整脚本。核心逻辑是HookluaL_loadbuffer在onEnter里把buff和size读取出来通过send发给Python端保存。// frida_lua_dump.js function readStdString(addr) { // libc std::string小字符串优化处理简化版 try { return addr.readCString(); } catch (e) { return null; } } function hexToBytes(hex) { var bytes []; for (var i 0; i hex.length; i 2) { bytes.push(parseInt(hex.substr(i, 2), 16)); } return bytes; } var target Module.findExportByName(libcocos2dlua.so, luaL_loadbuffer); console.log(luaL_loadbuffer at:, target); if (target) { Interceptor.attach(target, { onEnter(args) { this.buffPtr args[1]; this.size args[2].toUInt32(); if (this.size 0 this.size 0x200000) { // 限制大小防止读爆 var data Memory.readByteArray(this.buffPtr, this.size); var u8 new Uint8Array(data); send({ type: lua_chunk, size: this.size, data: Array.from(u8) }); console.log([dump] size:, this.size, name:, args[3].readCString()); } } }); }Python端监听消息把收到的字节流保存成文件import frida import sys import os import time DUMP_DIR dumped_lua os.makedirs(DUMP_DIR, exist_okTrue) def on_message(message, data): if message[type] send: payload message[payload] if payload[type] lua_chunk: chunk bytes(payload[data]) timestamp int(time.time() * 1000) filepath os.path.join(DUMP_DIR, f{timestamp}_{payload[size]}.luac) with open(filepath, wb) as f: f.write(chunk) print(f[saved] {filepath} ({len(chunk)} bytes)) def main(): device frida.get_usb_device(timeout10) pid device.spawn([com.example.game]) session device.attach(pid) script session.create_script(open(frida_lua_dump.js, encodingutf-8).read()) script.on(message, on_message) script.load() device.resume(pid) print(running...) sys.stdin.read() if __name__ __main__: main()这段代码可以直接用于大部分Cocos2d-x Lua游戏。需要说明的是device.spawn启动方式能保证从进程创建的第一行代码开始注入适合需要完整观察启动流程的场景。如果游戏本身启动过慢也可以改成device.attach(进程名)在运行时附加。4.2 如何判断dump出来的是源码还是字节码拿到dump文件之后别急着反编译先看一眼文件头。Lua源码本身就是文本直接能读到local xxx ...如果dump出来的是一堆二进制那就是Lua字节码。字节码又分两个流派文件头一眼就能区分标准Lua字节码开头是1B 4C 75 61也就是\x1bLuaLuaJIT字节码开头是1B 4C 4A也就是\x1bLJ。这两种格式使用的反编译工具完全不同。标准Lua 5.1字节码用unluac就能反编译LuaJIT字节码则要用luajit-decompiler而且LuaJIT 2.0和2.1的字节码格式也有差异。所以dump完成之后先识别格式再选工具能少走很多弯路。4.3 处理更复杂的资源解密图片、音频与自定义加密层有的游戏不加密Lua脚本文件本身而是把整个assets/res里的图片、音频都套了层自定义壳。这时候luaL_loadbuffer依然是突破口但对于图片这类资源更好的Hook点是引擎的Image::initWithImageData或者更底层的FileUtils::getFileData。我的做法是双管齐下。第一路Hookopenat监听所有资源文件的加载路径找到可疑的加密文件清单Interceptor.attach(Module.findExportByName(null, openat), { onEnter(args) { var path args[1].readCString(); if (path path.includes(assets)) { console.log(path); } } });第二路HookImage::initWithImageData在解密后、解码前转储图像数据。注意C成员函数的第一个参数是this指针第二个参数才是图像数据的指针。只要hook到并读出来一般就能直接落成PNG或JPEG保存。对于采用xxtea加密这类情况文件名会带着固定前缀或固定长度比如XXTEA开头的文件。实际上你不需要去逆xxtea的密钥只要等到引擎解密完成再把数据拿走就行。还是那句话动态解密的核心是“等它自己打开保险箱”。4.4 实操现场记录一次典型Cocos2d-x游戏解密过程这里分享一次实际分析过程的日志方便你对整个流程的时序有个直观概念。第一步启动脚本后捕获到的第一条关键消息[dump] size: 18432 name: src/main.lua [dump] size: 4096 name: src/config/game_config.lua [dump] size: 86912 name: src/battle/battle_scene.luasrc/main.lua优先被加载和引擎启动时序一致。dump文件大小从几KB到几百KB不等说明引擎是一次性把文件内容读进内存再交给luaL_loadbuffer而不是流式读取。第二步用十六进制查看器打开dump下来的main.lua文件头是1B 4C 75 61确认是标准Lua 5.1字节码。第三步用unluac反编译java -jar unluac.jar src_main_lua.luac main_recovered.lua打开反编译文件游戏主循环、界面逻辑、配置表结构全部清晰可见。整个流程从附加进程到拿到可读脚本耗时不到十分钟。这里要强调并不是所有游戏都这么顺利有的加了指令虚拟化有的改了Lua VM内部结构但基础链路和方法是一致的。5. 常见问题与排查技巧实录5.1 挂不上、注入失败与反调试我整理了一份常见问题速查表基本覆盖了实战中大部分场景现象原因解决办法ServerNotStartedErrorfrida-server未启动或版本不匹配确认进程存在检查PC端和设备端版本严格一致ProcessNotFoundError进程名错误或权限不足看不到目标用frida-ps -U先列进程确认准确的进程名脚本attach成功但无任何输出so尚未加载或Hook地址不对确认maps里是否已有libcocos2dlua.so用setTimeout延迟Hook游戏瞬间闪退提示检测到调试器反调试检测到Frida默认特征修改frida-server端口、进程名或改用gadget注入方式dump时脚本卡死读取了非法内存地址对size加阈值限制用try/catch包裹读取逻辑dump文件反编译失败时机不对dump到的是密文确认Hook在luaL_loadbuffer的onEnter而不是更上游的位置5.2 反调试绕过思路低配但实用游戏反Frida的常见招数有三种检查Frida默认端口27042是否开放、扫描/proc/self/maps里有没有frida特征字符串、检查/data/local/tmp下是否存在frida-server文件。对应的绕过方案并不复杂第一改端口启动frida-serveradb shell /data/local/tmp/frida-server -l 127.0.0.1:12345 adb forward tcp:12345 tcp:12345Python端用frida.get_device_manager().add_remote_device(127.0.0.1:12345)连接。第二改frida-server文件名改成ps、app_process之类的常见名称让它不容易被按文件名扫到。进程名对应地改成目标进程的子进程或伪装名。第三对于针对Frida端口扫描比较严格的情况可以考虑用Frida的gadget模式把libgadget.so塞进APK的lib目录通过配置文件在游戏启动时加载。这样不存在独立的frida-server进程也没有默认端口检测难度会大很多。唯一的代价是每次都要重新打包APK调试周期变长。5.3 几个容易踩的隐蔽坑第一个坑是Lua版本判断错误。Cocos2d-x 3.x系列默认Lua 5.1但有些魔改版引擎用的是LuaJIT两者字节码格式不一样。如果你用unluac去反编译LuaJIT的字节码工具会直接报错。dump下来先看头再选工具这个顺序不要乱。第二个坑是size参数的读取类型。luaL_loadbuffer的size类型是size_t在64位下是8字节无符号整数。我们常用的args[2].toInt32()在高位字节非0时会得到负值或截断值。稳妥的做法是用toUInt32()并且检查读出来的size是否合理比如小于0x80000000。有一次我忘了处理这个细节dump出来的文件全是0排查了半小时才意识到是size解析问题。第三个坑是启动方式的选择。spawn能从启动阶段就开始注入适合观察冷启动流程但偶尔会因为附加时机太早导致游戏逻辑异常。attach则适合运行时注入但可能会错过关键文件的加载。如果游戏加载很快attach进去时src/main.lua已经被读过了就什么都dump不到。我的经验是优先spawn实在不稳定再切attach并用setTimeout把Hook逻辑延迟几百毫秒执行让so先加载完。6. 实操心得与后续扩展方向做过的Cocos2d-x逆向项目多了之后我最大的感受是真正拉开效率差距的不是某个逆天脚本而是对整个运行时链路理解的深度。Frida只是工具luaL_loadbuffer只是一个点但当你把引擎加载流程、内存布局、脚本生命周期都串起来之后遇到新游戏基本就是按图索骥。这套方法论不只适用于Cocos2d-x换到Unity的IL2CPP、换到UE4的Blueprint思路都是一样的找加载临界点等在内存里拿明文。从应用价值来看动态解密和Frida Hook技术可以做三件很实际的事分析别人游戏的玩法逻辑做学习参考、验证自己的脚本加密方案是否可靠、以及在安全评估中定位游戏被篡改的入口。我自己现在做加固方案时都会用这套方法来检验加密强度。另外再分享一个小技巧Hook之前先看清楚引擎版本。不同Cocos2d-x版本的luaL_loadbuffer导出名可能带版本后缀比如luaL_loadbuffer_54在过滤导出表时用includes(luaL_loadbuffer)而不是等号判断能省下很多无谓的排查时间。
返回列表