
做Cocos2d-x手游逆向这几年给我最大的感受就是引擎本身的复杂度没那么高真正的分水岭全在对运行时行为的理解上。同样是拿Frida做Hook新手喜欢满世界抄脚本找偏移老手则是先问一句——我到底要在哪个环节动手数据和逻辑在这个环节是以什么形态存在的。今天就把我实际处理过的一个Cocos2d-x手游案例从思路到实操拆给你看重点放在Frida高阶Hook和动态解密上全程都是可以直接落地的经验。这个内容是写给谁的如果你已经会一点Frida基础但面对Cocos2d-x游戏时总感觉无从下手或者你正卡在“静态看半天、运行时不知道怎么切”的阶段那这篇文章能帮你把思路捋顺。当然如果你刚接触逆向里面的环境搭建和Hook写法也能当一份可复用的操作手册。老规矩先说清楚边界所有技术内容仅用于CTF学习、安全研究、私有设备调试等合法场景别拿去做不该做的事。1. 先认清目标Cocos2d-x游戏的突破口在哪1.1 为什么说Cocos2d-x是逆向里最“友善”的引擎Cocos2d-x能在移动平台活这么多年一大优势是跨平台但也正是这个特性让它在逆向面前比Unity透明得多。Unity的游戏逻辑主要跑在IL2CPP或Mono上前者要面对函数名混淆、后者干脆就是元数据一坨而Cocos2d-x的主流开发方式分两种纯C写逻辑或者用Lua脚本驱动。纯C方案里核心玩法代码编译进so文件符号如果没strip你就等于拿到了地图strip了也有迹可循典型的create、addChild、update这些函数在导出表里频繁出现顺着函数间调用关系基本能定位到关键逻辑。Lua方案更直白脚本文件虽然经常被加密或者打包成自定义格式但运行时终究要经过加载、解密、执行三步每一步都是天然的Hook点。另一个特征是它的组件树结构。Cocos2d-x的UI和游戏对象是通过Node树组织的你看到的每个按钮、精灵、标签在内存里都是一棵节点树上的节点。操作UI、修改数值、跳过弹窗本质上就是找到这棵树然后改节点属性。这种结构化程度比纯OpenGL自定义渲染的游戏友好太多了。所以我的结论是Cocos2d-x逆向的核心难点不在于“找不到”而在于“找错方向”。与其把时间花在硬啃汇编上不如想清楚它属于哪种架构再决定Hook的切入点。1.2 先做三件事定版本、找so库、摸清脚本架构拿到一个Cocos2d-x手游的APK我一般不动手就Hook先花10分钟做静态摸底。这个步骤省掉之后大概率会瞎忙。第一确定引擎版本。解压APK在lib文件夹里找so常见的名字有libcocos2dlua.so、libcocos2djs.so、libgame.so等。版本信息通常在so的字符串资源里用strings libcocos2dlua.so | grep -i cocos能看到类似cocos2d-x version 3.17.2的字符串。版本定了很多API的内存布局、虚函数表偏移都可以直接参考同版本的源码去算。第二确认脚本类型。看assets目录下脚本文件的后缀和头几个字节。.lua结尾但头部不是正常Lua字节码大概率是加密或者加了自定义头.luac标准Lua字节码个别版本有XXTEA加密.csb、.plist之类的是场景和配置资源不太需要逆向逻辑但后面对理解界面结构有帮助。如果APK里压根没有脚本文件、还打包成了zip或者pak说明是有自定义资源系统目标就转到FileUtils相关的读取函数上。第三看so是否被加固。用readelf -s确认导出表如果发现so被加壳典型特征是导出表异常精简、内存中出现多段可执行区域先处理壳的问题否则所有偏移计算都会在运行时失真。这套摸底流程走完你对游戏的技术底牌就有了基本认知。下面再谈Hook就有了明确的对象。2. Frida高阶Hook从函数拦截到类型操作2.1 Frida就该这么用attach与spawn的选择题很多刚上手Frida的朋友对attach和spawn的使用场景一直模糊。简单说attach是附加到一个已经在运行的目标进程spawn是启动进程并在入口点暂停。对游戏逆向来说spawn通常是更稳的选择尤其目标启动后马上初始化游戏逻辑、注册各种回调的场景如果等游戏跑起来再attach早错过了解密函数被调用的时机。frida -U -f com.example.game -l hook.js --no-pause上面的命令用-f指定包名--no-pause表示附加后在入口点继续运行。这种模式下注入脚本会在进程最早期执行能拦截到JNI_OnLoad、Java_com_example_game_...之类的初始化函数。但有一个反直觉的坑spawn模式下有些自定义so的加载顺序会影响到你的Hook是否生效。比如加密逻辑放在构造函数中执行而构造函数在.init_array里Frida标准注入时机又比.init_array晚这时要用到Interception早期的能力。我的处理办法是分开两个阶段spawn启动后先探测关键模块何时被加载用Process.findModuleByName轮询或者Interceptor.attach到dlopen上等so加载完再附加真正的Hook。attach模式下也有它的用武之地比如游戏已经跑起来了、只改内存里的某个节点属性或者服务器下发道具后即时修改数据。这种场景attach比spawn快不用重启游戏调试效率高很多。2.2 面向Cocos2d-x的Hook点位设计定位Hook点是逆向工程中最见功力的环节。Cocos2d-x本身提供了几个“天生”的Hook候选位置。先说Lua脚本系统。Lua加载执行代码最关键的函数是luaL_loadbuffer。无论是明文脚本还是运行时解密的字节码最终都要走这个函数把数据交给Lua虚拟机执行。所以Hook这里就能拿到“运行时真实加载的代码数据”。对应的Frida代码可以这样写var luaL_loadbuffer Module.findExportByName(libcocos2dlua.so, luaL_loadbuffer); if (luaL_loadbuffer) { Interceptor.attach(luaL_loadbuffer, { onEnter: function (args) { this.size args[2].toInt32(); this.buf Memory.readByteArray(args[1], this.size); console.log(luaL_loadbuffer, size this.size); send(dump_buffer, {data: Array.from(new Uint8Array(this.buf))}); } }); }注意libxxx.so这个模块名不固定取决于游戏具体怎么命名你需要用Process.enumerateModules()打印所有模块名再确认。另外3.x版本的Cocos2d-x还会用luaL_loadbufferx、lua_load等变体最好一并Hook。再一个就是资源读取层。Cocos2d-x的FileUtils类会负责读取文件很多加密方案的解密点就藏在自定义的FileUtils子类里。典型的Hook对象是cocos2d::FileUtils::getStringFromFile这是获取文件内容的通用入口。脚本加密也好、资源加密也好文件内容最终会通过它或者它的内部实现变成内存里的字符串/字节数组。在这个函数上Hook配合修改返回值可以实现“给游戏喂自定义文件内容”的效果——比如替换关卡配置、修改加密脚本这在调试期非常好用。最后是用户数据层。Cocos2d-x的UserDefault相当于引擎自带的KV存储很多游戏的存档、设置、新手引导状态都存在这里。对应函数是cocos2d::UserDefault::getStringForKey、getIntegerForKey通过参数看出读取的键名返回值看到对应的值。改存档直接改这个函数的返回值就行不用去翻SQLite文件。2.3 高阶技巧跨so调用、构造器Hook与返回值改写基础会了之后想应付更复杂的情况还得掌握几个高阶技巧。第一个是Hook C对象成员函数时要处理this指针。Frida的Interceptor.attach传给回调的args[0]就是this指针拿到它之后才能正确读取成员变量。举个例子假设某个类的对象保存了解密密钥在成员函数入口处就能通过this偏移量把密钥读出来。第二个是主动调用函数。有时候光Hook不够你要直接调用目标函数来触发某个逻辑。用NativeFunction可以很方便地做到var funcPtr Module.findBaseAddress(libcocos2dlua.so).add(0x123456); var myFunc new NativeFunction(funcPtr, void, [pointer, int]); myFunc(thisPtr, 42);这里计算偏移时最终地址是基址 函数相对偏移。如果so被加载到非预期地址请用Module.getBaseAddress动态获取别写死。第三个是构造器Hook。C对象的构造逻辑通常在so的init段但构造函数本身不好直接通过导出表找到名字因为很多关键类的构造函数被编译器优化成了内联或符号被strip了。我的做法是先从字符串引用找线索。比如内存里出现某个类特有的字符串用Frida扫描引用该字符串的代码段附近就是构造或初始化函数的位置。两种手段配合比写死VTable偏移要稳妥。第四个是返回值改写的进阶玩法。基础改返回值大家都明白但针对字符串返回值要处理指针生命周期问题。如果目标函数返回的是std::string或std::vector直接改远程指针是危险的要么用Memory.alloc分配一段内存再把内容写进去要么干脆重定向到自己的函数逻辑。这点在做协议解密时尤其要小心。3. 动态解密实战Lua脚本与资源文件的运行时还原3.1 定位解密逻辑从“加密点”推“解密点”手游防破解的常见思路是给资源和脚本加密但无论怎么加运行时总存在一个“解密的时刻”。你要做的不是逆向加密算法本身而是找到解密算法被调用的地方然后在这个位置把内存中的明文截下来。怎么找两个常用方向。方向一是从“加密数据的使用方”反推。如果你是Lua脚本加密后的脚本文件一般在assets目录但Lua虚拟机不认识加密数据必须经过解密才能交给luaL_loadbuffer。所以直接HookluaL_loadbuffer如果发现传入的buf不是标准Lua字节码格式标准格式以\x1bLua开头说明它之前还有一层解密。顺着它往上层看找到将buf解密成标准字节码的位置——这个位置往往就是自定义的加载器。方向二是从FileUtils的子类重写函数入手。Cocos2d-x允许开发者继承FileUtils并改写所有读取函数多数自定义加密就藏在这里。HookgetStringFromFile或者getDataFromFile的返回值看看返回数据是否已经解密。如果返回值看起来可读说明解密发生在更早的解析层如果返回值是密文就往下找谁消费了这份密文。实际处理时我喜欢在脚本中把两层Hook同时打开一层在getStringFromFile一层在luaL_loadbuffer。一个负责看文件读取层一个负责看Lua执行层对比两层的数据变化就能准确判断解密位置落在哪一段区间。3.2 用Frida完整走一遍动态解密流程接下来用一个实际场景演示假设游戏把Lua脚本加密成了自定义格式后缀是.lua但其实内容被做了XOR混淆运行时需要解密后加载执行。第一步用Frida脚本HookluaL_loadbuffer打印出每次传给它的数据长度和开头几十个字节。跑一把游戏看到输出里出现了非标准Lua头的数据这时就能确定传入的buf是“解密后的明文还是仍加密的密文”要看约定。如果头部已经是\x1bLua说明解密发生在更前面如果不是那luaL_loadbuffer之前必定有解密环节。第二步HookgetStringFromFile或者其内部对应的读取实现。模块函数签名一般是var FileUtils_getStringFromFile Module.findExportByName(libcocos2dlua.so, _ZN7cocos2d10FileUtils17getStringFromFileERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE);如果找不到符号就退而求其次Hookcocos2d::FileUtils::getDataFromFile用Module.enumerateSymbols筛选出包含FileUtils和getData的符号。第三步在这两个Hook点都打印数据对比。当你发现getStringFromFile返回的是一串看起来可读但又不是Lua字节码的内容而luaL_loadbuffer拿到的还是这串内容就说明解密在中间某步往往是自定义的LuaStack初始化流程里对getStringFromFile的返回值做了处理。第四步在这个中间步骤上定位解密函数。常见的做法是Hook常见的字符串处理函数族如fopen、fread或者直接扫描libcocos2dlua.so里引用Lua加载器相关函数的位置。更高效的方式是用Frida的Stalker跟踪CPU指令但游戏对性能敏感且Stalker开销大建议先尝试从调用栈理解逻辑Interceptor.attach(luaL_loadbuffer, { onEnter: function (args) { console.log(Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress).join(\n)); } });调用栈会直接告诉你谁调用了luaL_loadbuffer顺着栈回溯往往能直接看到解密函数的名字。3.3 动态Dump把解密后的内容原样导出找到了解密位置下一步就是把它dump下来。这里分享一个通用的做法直接在解密函数的返回值上做文章。假设解密后的Lua字节码会被返回那么在返回处读取内存并保存成文件。Interceptor.attach(decryptFunc, { onLeave: function (retval) { var len 0x10000; // 实际长度可能要根据上下文判断 var data Memory.readByteArray(retval, len); send(dump, {data: Array.from(new Uint8Array(data))}); } });不过这种方式对长度判断比较麻烦。更稳妥的方案是HookluaL_loadbuffer因为它能拿到明确的size参数。在这个函数里把传入的buf完整保存var fd new File(/sdcard/dump/ Date.now() .luac, wb); fd.write(this.buf); fd.close();注意这里写文件用的是Frida的File API在Android上需要确保app有写外部存储的权限如果没权限就把数据通过send发送到PC端由Python脚本写文件。我个人喜欢之后这种做法不污染目标进程的文件系统也更隐蔽。Dump出来的字节码用luajit -bl或unluac去反编译就能看到Lua源代码级别的逻辑。这一步做完游戏的脚本逻辑基本就摊在你面前了。之所以强调“动态解密”而不是“静态分析解密算法”是因为游戏的解密函数可能存在多次变换、随机密钥、甚至服务器下发密钥的情况静态分析要到死磕算法的程度才能还原数据而运行时拦截根本不关心算法过程数据总是在某个瞬间变成明文的那个瞬间就是你的机会。4. 常见问题与排查技巧实录4.1 常见问题速查为什么Hook不到、脚本注入失败、dump数据不对做逆向调试遇到的问题永远比预想多。我把自己踩过的高频坑整理成一张速查表你实战时可以直接对照排查。现象原因排查与解决Frida attach时报unable to connect to remote frida-serverFrida server版本与PC端不匹配或server未以root运行确认版本一致adb shell su后启动frida-server并保持后台运行Hook的luaL_loadbuffer从不触发游戏没有走这个函数可能用lua_load、luaL_loadfile或so名称不同Process.enumerateModules()确认模块名符号表里搜所有luaload函数Thread.backtrace打印的栈全是地址没有符号so被strip或Frida没有正确解析符号使用DebugSymbol.fromAddress配合Module.getExportByName必要时用偏移手动计算写文件到/sdcard失败目标app没有存储权限使用frida -U -f ... --runtimev8通过send回调给PC端落地数据Hook的偏移量在每次启动时都变化so基址每次加载不一样代码里写死了绝对地址用Module.getBaseAddress动态算偏移Interceptor.attach到构造函数后崩溃构造函数在so初始化早期某些依赖还没ready改用Process.findModuleByName轮询或Hookdlopen延迟附加dump出来的luac反编译乱码数据不一定是Lua字节码可能是压缩数据、资源文件或其他格式先看头部特征Lua 5.1字节码以\x1bLua开头再确认是5.1/5.2/5.3反编译器要对应版本游戏检测到Frida后退闪目标做了Frida检测先定位检测函数搜frida字符串、hookpthread_create替换检测逻辑后再继续4.2 独家避坑经验Hook生命周期、反调试绕过的思路关于Hook时机有个很常见的误区认为在脚本开头Interceptor.attach一次就高枕无忧了。实际上Cocos2d-x游戏里的对象经常是池化的——比如UI界面切换时旧的节点销毁、新的节点创建构造/析构函数会被高频调用。如果你想拦截某个UI按钮的点击事件只靠一次Hook可能没问题但如果想拿到不同场景下的状态就得把Hook设计成“从实例对象上读取成员”并配合对象生命周期来处理。再一个经验是游戏的反调试不完全等于反Frida。很多Cocos2d-x游戏会做两件比较脏的事一个是定期检测/proc/self/status里的TracerPid另一个是轮询/proc/self/maps里出现敏感模块。我的经验是遇到这种检测第一反应不是去inline hook检测函数而是把检测函数本身找到看它是哪个线程在跑、什么频率触发然后针对性替换定时器回调或直接改检测函数的返回值。拿Frida的Interceptor.replace去替换一个返回int的函数是最直接有效的。还有一点很多资料不提Frida的Interceptor.attach本身也可能被检测因为inline hook会修改指令码。如果目标游戏对指令完整性做校验你的Hook刚挂上就会被发现。这种情况可以改用稳定的内存镜像对比方案或者用Frida的Memory.patchCode配合自校验修正。当然更省事的办法是先从模拟器或备用机上完成逻辑梳理再去真机上处理反调试。最后分享一个关于动态解密的经验解密函数往往不是只有一处调用。游戏加载主菜单调一次、进入战斗调一次、抽卡动画调一次不同时机解密的密钥可能不同。所以一个完整的动态解密脚本最好做成“常驻监听”把每个时机的明文都dump出来用文件名带上时间戳别指望一次跑通就把所有数据拿到手。多跑几轮、多触发几个场景你手里的数据才会完整。整体上Cocos2d-x手游逆向的核心思路就是四步静态摸底确认技术栈动态Hook找准关键入口解密过程在运行时拿出来最后再围绕业务逻辑做针对性修改。Frida在这个过程中既是放大镜也是手术刀用得顺手很多看起来复杂的保护手段都能绕过去。我在实际项目里最大的体会是别急着写Hook脚本先花半小时把对象模型和调用链想明白后面能少走很多弯路。