[原创] 一个 Cocos for Lua 伪网游的破解实战
1. 前言
最近闲来无事,花了5个小时的时间学习破解了1款基于cocos lua写的android游戏,最终得到了这个游戏的源代码。第一次尽然轻松成功了,经验教训分享一下。
2. 准备知识
阅读当前教程,你需要掌握一下的一些技术和方法。
- Ida 逆向动态链接库
- Lua
- C++
- Java
- APK重打包
3. 程序结构研究
首先拿到APK后,使用ZIP打开查看APP内部所包含的文件。大部分游戏都是使用开源游戏引擎开发完成,所以发现所使用的引擎就是第一目标了。

解压之后首先对classes.dex进行反编译操作(简你单起见,使用APKDB操作),得到java层的代码。将dex转换成jar之后,使用jd-gui查看代码:

从上图可知,当前的游戏使用了cocos2d-x 游戏引擎。因此我们的目标锁定到了native-code上。同时对APK目录结构的分析,发现当前游戏使用了脚本语言完成。

使用Hex Editor拖入任意一个nts,发现当前内容为乱码,可以认定当前脚本已经编译过或者加密过。

既然脚本编译过或者加密过,因此破解的任务首先是确定脚本的类型,然后还原脚本所加密的内容。因此打开IDA PRO,将当前的程序的so拖入到IDA中。

从左边的窗口中可以清楚的看到当前native代码的函数名(这个开发比较那啥,没对so做任何安全措施)。
反编译轻轻松松了,通过对函数名称的排序和阅读:

发现当前游戏所使用的脚本为lua。因此下一步操作目的就是定位lua解密和lua加载的方法。
在cocos游戏的启动过程,以下几个函数尤为重要,本文不涉及cocos的启动流程,具体流程百度介绍。当cocos初始化完成之后会调用applicationDidFinishLaunching()。因此定位到当前的函数查看程序行为。


进入当前函数,对当前函数进行反编译,可以看到大致的程序行为。
其中的
sub_CD11F958(&v11, (int)"Script/Main.nts", (int)&v10, *(_DWORD *)v3, (int)v10, v11, v12, v13);
这个函数为当前游戏的入口,所有的逻辑通过该入口进入脚本层。
同时我们可以看到还有一个很关键的函数叫做
AppDelegate::InitLuaStatus(v3);
从字面意思可以看出这个函数初始了lua的上下文环境。跟踪进去继续查看。

发现当前App通过这个函数
cocos2d::extension::ExportToLuaInit(*((cocos2d::extension **)v1 + 11), v5);

将所有运行时的一些方法导入到了脚本环境中。
当前函数做了四个操作,
cocos上下文环境初始化
BOX2D环境初始化
自定义的一些初始化
文件回调方法注册。(这个方法最为关键)
LuaPlus::SetFileGetWay(
(int (__fastcall *)(_DWORD, _DWORD, _DWORD))MallocFile,
(int (__fastcall *)(_DWORD))FreeFile);

当前方法像成员变量注册了两个回调方法,一个为分配内存是调用,一个为删除脚本是调用;
由于lua执行之前需要调用luaL_loadbuffer 或者luaL_loadbufferx 方法,前者读取的buffer未编译过的lua脚本,后者调用luac编译后的脚本。因此在代码中定位这两个方法,找到脚本执行之前的操作。

搜索符号表发现存在luaL_loadbufferx方法,在当前方法上按X进入调用栈

发现以下方法调用了luaL_loadbufferx,进入LuaPlus::ExecuteBufferRet函数中,

在当前的函数中,调用了luaL_loadbufferx,通过官方文档luaL_loadbufferx的函数原型为。其中第二个参数为要执行的Buffer。
luaL_loadbufferx (lua_State *L,
const char *buff,
size_t sz,
const char *name,
const char *mode);
同时在当前函数中,尚未发现任何于加解密有关的操作,因此判断加密操作在当前函数之前调用,按X查看LuaPlus::ExecuteBufferRet 函数的调用关系。我们来到了

LuaPlus::ExecuteFile(int a1, int a2, int *a3, int a4)
通过对这段代码的阅读,发现LuaPlus::g_pMallocFileData(a3, &buffer, &size);这个函数参加了文件读取与解密的操作。
上文提到,LuaPlus::g_pMallocFileData实际上是一个函数指针,因此回到LuaPlus::SetFileGetWay这个函数我们定位到了真正读文件和加密的函数。

进入当前函数,

大致阅读,发现红框中的函数为解密函数,其中byte_CD184CB 为一个常量数组,点击进入到常量段可以发现

当前的数组值为:
uint8_t keyBox[] =
{
0xA1, 0xA4, 0xA7, 0xAA, 0xAD, 0xB1, 0xB4, 0xB7, 0xBA,
0xBD, 0xC1, 0xC4, 0xC7, 0xCA, 0xCD, 0xD1, 0xD4
};
根据上述伪代码写出解密代码(java 实现):

即可得出真正的明文数据。
对其中的一个文件进行解密操作:

很明显的看出已经得到了近似的代码。
继续分析:
通过上述截图,我们可以看出前面的四个字节以此为:
0x1B 0x4C 0x75 0x61 0x53
翻阅lua文档我们看出,前面四个字节为lua 的magic number,第五个字节为当lua的版
本,即lua5.3
typedef struct {
char signature[4]; //".lua"
uchar version;
uchar format;
uchar endian;
uchar size_int;
uchar size_size_t;
uchar size_Instruction;
uchar size_lua_Number;
uchar lua_num_valid;
uchar luac_tail[0x6];
} GlobalHeader;
因此使用unluac 工具我们可以得到源码。

最后发现,大功告成!
4. 修改
上一张提到了逆向+解密+反编译获取源码,这一张讲一下获得源码之后如何修改。
4.1. 分析游戏行为
反编译源码之后,对立面。一些源码进行查看,发现App使用以下的行为对App自身进行保护。
签名校验
当加载进从 Script/Main.nts 进入lua 脚本之后

Main脚本主要加载公共的脚本进行初始化之后,加载游戏世界。
进入Include.nts进行查看。

这个里面初始化了很多公共的脚本,其中一个关键的脚本为Security。这个脚本做了App自保操作。

在上图红色的地方进行了签名校验,如果签名不对游戏退出。
同时在代码里面搜索AppSignature()发现在其他的地方加载了当前的函数。

由于World.lua 可能由于热更新加载到外部的脚本,因此这个地方不做剔除操作。
4.2. Hook点
从上面的大致逻辑可以看出游戏的整个执行过程。

上图为简化的游戏启动流程,其中红色的节点是有可能动态的从外部加载的。
因此在红色节点之前和之后进行hook 改掉内置的签名即可。
内置的签名存放于Config中。在Config.nts中导出了一个Global的对象,因此在Security.nts中加载自定义的脚本。

在Hook.lua中对Config.Signature进行修改,修改为新的签名文件的签名指纹

当所有的Include.nts中的脚本初始化化完成之后,在进入游戏世界之前进行Hook

至此,在自己的HookMain中修改编写自己的函数和逻辑,同时可以修改很多导出的全局对象。游戏Hook 搞定。
最后将修改的过的脚本进行编译加密替换到app 中,然后重新打包。整个游戏破解完成。
5. 网络数据模拟
当前游戏实际上是一个半联网的游戏,服务端保存了用户的账号信息,和道具信息,从业务复杂度上来说,这属于一个非常低级的软件。因此通过一定的手段我们可以模拟出后台的数据,达到完全掌握和控制这个游戏的目的。
5.1. 网络行为分析
从逆向出来的lua代码中我们可以看到,有一个AdventureSDK类,这个里面封装了所有请求网络的操作。

分析可以看出,后台使用的一个传统的HTTP服务器。在阅读源代码的过程中,有两个非常重要的方法

这两个方法指定了,数据包的加解密操作。跟进源代码可以看出,这个两个方法调用了两个Native方法(DataEncodeBase64, DataDecodeBase64),打包到了C++层。所以打开IDA,对当前的方法进行查找。(IDA 查找方式暂不介绍)
在IDA中可以发现,这两个方法通过LuaPlus导出为了一个lua的全局方法,

因此跟进这两个方法,可以得到当前数据包的加解密方式。

通过当前IDA中的逆向出来的代码,我们可以很轻松的写出C++的代码

从上图我们可以看到,加密的时候首先创建一个数组,并将对二进制进行如下操作。
encodeData[0] = rand1 ^ 0xA1
encodeData[1] = funneyBox[rand1 % 0x11]^ rand2
剩余的字段使用循环进行填充
for (int32_t i = 2;i<length2;i++)
{
encodeData[i] = luaStr[i - 2] ^ funneyBox[rand2 % 0x11];
}
至此,加解密部分完成,进入下一个章节
5.2. 数据包格式获取
整个游戏的数据请求和响应都采用http的方式进行,在客户端使用的cocos2d-x自带的HttpClient进行请求数据。其中请求的第一个字段的key为请求的数据。
请求的数据采用上文的的自定义方式进行加密,因此使用传统的嗅探器无法获取其明文内容,
因此使用一下方式进行操作。
重定向客户端到本地服务器,然后本地服务器作为代理访问目标服务器,然后返回给客户端。

同时根据上文代码写出对应的Decoder,客户端把数据发给本地服务器解密获取请求格式,同时后台服务器返回数据时解码获取数据格式

5.2.1 数据格式
通过上述操作之后便可以的到当前客户端的数据格式,请求方式为采用RESTful风格进行,
从目前的操作,客户端有一下几个接口
API :/system/version/ 获取当前服务器的版本信息。
请求格式 :{"version":105003}
响应格式:
{
"code": 400800,
"data": {
"database": [
"insert.into.LevelExp{serial = 10000,level = 1,exp = 15,}",
这里可以插入自定义数据长度
"insert.into.LotteryItem{serial = 10000,item_id = 2001,amount = 1,type = 'body',extra = 0,}",
]
},
"mask": 2052972415
}
API :/system/snapshoot 上传截图
请求格式 :unknown
API :/account/authorize 登录
请求格式:{"name":"account","password":"SHA1(pwd)","time":1526291705,"version":105003}
响应格式:{
"code": 0,
"data": [
"insert.into.Account{serial = 10058,name = '421525157@qq.com',password = '17330F18BDC8483700B1A55E10248899BF1DF243',invitation = 'RAv31s1',sex = 1,gm = false,ip = '183.56.156.202',version = 100003,ctime = 1520178329,sign_time = 1526256000,last_time = 1526289910,ban = false,ban_time = 0,check_code = '4be8d05a2da2ff48ce6b4ba56375ea7bf5ef2b05',}",
"insert.into.Userinfo{serial = 10062,name = '\u53c1\u6c1b\u58ca\u6c17',belong = 10058,hp = 3992,mp = 420,exp = 0,body = 2000,hair = 35550,face = 20035,eardrop = 1032021,cap = 1004472,longcoat = 1053116,shoes = 1072189,glove = 1082685,cape = 1102801,weapon = 1702170,job = 100,map = 100000000,rmap = 100000100,ctime = 0,mtime = 1526039913,check_code = '26e29d525b6dba77177f0e30be10f2e554542830',}",
"insert.into.Userinfo{serial = 10115,name = '\u56db\u6c1b\u58ca\u6c17',belong = 10058,hp = 727,mp = 85,exp = 61793,body = 2000,hair = 35280,face = 20000,eardrop = 1032003,cap = 1003847,coat = 1040041,pants = 1060060,shoes = 1072007,glove = 1082001,weapon = 1432013,job = 530,map = 105070300,ctime = 0,mtime = 1522595861,check_code = '26ebe5dfdddb3b918264b906ccd881733dc947a3',}",
"insert.into.Integral{serial = 10025, belong = 10058, quantity = 2910, limit = 0, provenance = 'system', check_code = '0044bd555a0551f169b88fd280c45a6866e4f8e0'}",
"insert.into.Integral{serial = 10026, belong = 10058, quantity = 8772, limit = 1, provenance = 'system', check_code = '17b025264b0684c42dbbd27791ce99c5c3cc19e2'}",
"update.config.value{SystemExpMultiple = 0, SystemDropMultiple = 0}"
],
"mask": 1368326100
}
API :/account/data 获取角色列表
请求格式:{"token":"0ca9d29e5790e925cc2656415ea4fee4f929131c"}
{
"code": 0,
"data": [
"insert.into.Account{serial = 10058,name = '421525157@qq.com',password = '17330F18BDC8483700B1A55E10248899BF1DF243',invitation = 'RAv31s1',sex = 1,gm = false,ip = '183.56.156.202',version = 100003,ctime = 1520178329,sign_time = 1526256000,last_time = 1526289910,ban = false,ban_time = 0,check_code = '4be8d05a2da2ff48ce6b4ba56375ea7bf5ef2b05',}",
"insert.into.Userinfo{serial = 10062,name = '\u53c1\u6c1b\u58ca\u6c17',belong = 10058,hp = 3992,mp = 420,exp = 0,body = 2000,hair = 35550,face = 20035,eardrop = 1032021,cap = 1004472,longcoat = 1053116,shoes = 1072189,glove = 1082685,cape = 1102801,weapon = 1702170,job = 100,map = 100000000,rmap = 100000100,ctime = 0,mtime = 1526039913,check_code = '26e29d525b6dba77177f0e30be10f2e554542830',}",
"insert.into.Userinfo{serial = 10115,name = '\u56db\u6c1b\u58ca\u6c17',belong = 10058,hp = 727,mp = 85,exp = 61793,body = 2000,hair = 35280,face = 20000,eardrop = 1032003,cap = 1003847,coat = 1040041,pants = 1060060,shoes = 1072007,glove = 1082001,weapon = 1432013,job = 530,map = 105070300,ctime = 0,mtime = 1522595861,check_code = '26ebe5dfdddb3b918264b906ccd881733dc947a3',}",
"insert.into.Integral{serial = 10025, belong = 10058, quantity = 2910, limit = 0, provenance = 'system', check_code = '0044bd555a0551f169b88fd280c45a6866e4f8e0'}",
"insert.into.Integral{serial = 10026, belong = 10058, quantity = 8772, limit = 1, provenance = 'system', check_code = '17b025264b0684c42dbbd27791ce99c5c3cc19e2'}",
"update.config.value{SystemExpMultiple = 0, SystemDropMultiple = 0}"
],
"mask": 1368326100
}
API:/account/switch 切换角色 进入游戏
请求格式:{"token":"091a4ada3d999d32b38cae4de091542633124782","user":10115}
{
"code": 0,
"data": [
"insert.into.Possession{serial = 11795, belong = 10062, quantity = 53506100, limit = 0, provenance = 'system', check_code = '34bf8892162e48c60d1abfcca6645e50958b91bc'}",
"insert.into.Possession{serial = 11796, belong = 10062, quantity = 4728043, limit = 1, provenance = 'system', check_code = '3719769c236b8e567e15edf3ce696f17ba3563a4'}",
"insert.into.Levels{serial = 10079, belong = 10062, level = 80, check_code = 'd5cc47c5dcf6a4f4b5ff3aa88660e03d23d28c46'}",
"update.increment.Items \"11832\"",
"insert.into.Items{identify = 10558,serial = 10003,belong = 10062,item_id = 1142079,amount = 1,site = 'equips',level = 0,slevel = 0,provenance = 'cdkey',comment = 'Z0F0VAKYiE',ctime = 0,mtime = 1526013658,check_code = 'f0abd03df42a21f373d03644879a3b45b8a429a9',}"
"insert.into.Setting{serial = 10000,belong = 10062,name = 'consume',data = {[1] = 2000002, [2] = 2001001},check_code = '1f3d9a1f63f01576fc83337cdf47aa31a6691fca',}",
"insert.into.Setting{serial = 10001,belong = 10062,name = 'mexp',data = 1525399951,check_code = 'ac16a184a71570e1ac4595385eab6cdb58031fd4',}"
],
"mask": 1100030688
}
5.2.2 后台设计
后台使用SpringMVC + Spring 实现 使用RESTful接口进行封装。具体的逻辑参考Github实现。
https://github.com/haha02hah02/tech_talk/
6.总结
整个游戏的破解从分析游戏结构入手,得到当前游戏使用cocos for lua 进行编写,找到对应的lua脚本之后发现脚本进行一次自定加密的操作。通过IDA 进行逆向静态分析得到了加密的密钥,为解密提供方法。
分析当前的游戏行为,发现整个游戏的所有逻辑都是基于lua脚本进行,虽然是个所谓联网的游戏,不过只是个骗钱的噱头而已,在这种游戏架构下根本无法实现真正的联网操作。
在游戏中,怪物的爆率,商店的道具、抽奖等都是基于本地化的操作,在这种情况下,反编译lua脚本可以完全实现获取自定义道具的功能。
最后HTTP联网实际上只是保存了用户的一个状态而已,分析这个游戏联网通信的接口不超过15个,并且在得到了数据包加解密操作之后,写出后台也只是时间问题。
以上,从做游戏角度来看这是一个失败的作品,游戏的安全性不高,游戏体验差,玩法少,充分体现了这个开发者的无能。因此开发产品时,如何提高产品的安全性,如何提高游戏的玩法是游戏开发中最要思考的问题。
