浅析android手游lua脚本的加密与解密
<p> 博客刚刚弄完善,把去年发在看雪的一篇精华帖转了过来,文章稍微修改了下,并且增加了后续<a href="https://litna.top/2018/07/08/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86%EF%BC%88%E5%90%8E%E7%BB%AD%EF%BC%89/" target="_blank" rel="noopener noreferrer">文章</a>,希望能够吸引点人气。这篇文章是我在学习android手游安全时总结的一篇关于lua的文章,不足之处欢迎指正,也欢迎各位大佬前来交流。</p>
主要用到的工具和环境:
lua 现状分析
去年的那篇文章这一章没有写的,今年补上了一篇lua加解密的相关工作,请看:《浅析android手游lua脚本的加密与解密(前传)》
lua 各文档关系
在学习lua手游解密过程中,遇到的lua文档不外乎就3种。其中.lua后缀的文档是明文代码,直接用记事本就能打开,.luac是lua脚本编译后的字节码文档,文档头为0x1B 0x4C 0x75 0x61,lua虚拟机能够直接解析lua和luac脚本文档,而.luaJIT是另一个lua的实现版本(不是lua的原作者写的),JIT是指Just-In-Time(即时解析运行),luaJIT相比lua和luac更加高效,文档头是0x1B 0x4C 0x4A:
luac 文档头如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/luac.png)
luaJIT 文档头如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/luajit.png)
lua 脚本保护
一般有安全意识的游戏厂商都不会直接把lua源码脚本打包到APK中发布,所以一般对lua脚本的保护有下面3种:
1. 普通的对称加密,在加载脚本之前解密
这种情况是指打包在APK中的lua代码是加密过的,进程在加载lua脚本时解密(加载脚本的关键函数luaL_loadbuffer),解密后就能够获取lua源码。如果解密后获取的是luac字节码的话,也可以通过对应的反编译得到lua源码,反编译主要用的工具有unluac和luadec51,后面会具体分析。
2. 将lua脚本编译成luaJIT字节码并且加密打包
因为cocos2d-x使用的luaJIT,而且luaJIT反编译后的结果阅读起来比较麻烦,所以这种情况能够较好的保护lua源码。这个情况主要是先解密后反编译,反编译主要是通过luajit-decomp项目,它能够将luajit字节码反编译成伪lua代码。
3. 修改lua虚拟机中opcode的顺序
这种情况主要是修改lua虚拟机源码,再通过修改过的虚拟机将lua脚本编译成luac字节码,达到保护的目的。这种情况如果直接用上面的反编译工具是不能将luac反编译的,需要在虚拟机的引擎中分析出相对应的opcode,然后修复反编译工具luadec 源码中的 opcode 并重新编译,编译后的文档就能进行反编译了,后面会具体分析。
在破解手游过程中,上面的三种情况可能会交叉遇到。
获取lua源码的一般方法
这里主要介绍4种方法,都会在后面用实例说明。
1. 静态分析so解密方法
这种方法需要把解密的过程全部分析出来,比较费时费力,主要是通过ida定位到luaL_loadbuffer函数,然后往上回溯,分析出解密的过程。
2. 动态调试:ida + idc + dump
游戏会在启动的时候通过调用 luaL_loadbuffer函数加载必要的lua脚本,我们可以通过ida动态调试so文档,然后是定位到luaL_loadbuffer地址,再下断点 ,断下后就接着运行idc脚本(或者python脚本)将lua代码导出(进程调用一次luaL_loadbuffer只加载一个lua脚本,所以需要编写idc脚本自动保存lua代码)。
3. hook so
跟4.2原理一样,就是通过hook函数luaL_loadbuffer地址,将lua代码保存,相比4.2的好处是有些lua脚本需要在玩游戏的过程中才加载,如果用了4.2的方法,那么在游戏过程中需要加载新的lua文档就会中断一次,我们就需要手动运行一次idc脚本,如果是hook的话,就不需要那么麻烦,直接玩一遍游戏,全部lua脚本就已经保存好了。
4. 分析lua虚拟机的opcode的顺序
这里主要是opcode的顺序被修改了,需要用ida定位到虚拟机执行luac字节码的地方,然后对比原来lua虚拟机的执行过程,获取修改后的opcode顺序,最后还原lua脚本。
综上,静态分析费时费力但是能够解密全部的lua脚本,而通过动态获取的方法虽然方便,但是只能获取游戏当前需要加载的lua脚本。具体选择哪种方法,需要衡量时间成本等。
lua脚本解密实例分析
接着用3个游戏作为实例说明上面分析的情况。
54捕鱼
首先用AndroidKiller 加载,然后查看lib目录下的so文档,发现libcocos2dlua.so文档,基本可以确定是lua脚本编写的了。这里有个小技巧,当有很多so文档的时候,一般最大的文档是我们的目标(文档大是因为集成了lua引擎,既然有lua引擎,那么肯定有lua脚本了)。接着找lua脚本,资源文档和lua脚本文档都是在assets目录下。我们发现这个游戏的资源文档和配置文档都是明文,这里直接修改游戏的配置文档就可以作弊(比如修改升级炮台所需的金币和钻石,就可以达到快速升级炮台的目的),然后并没有发现类似lua脚本的文档。
顺手解压了一下res目录下的liveupdate_precompiled.zip,发现解压失败,看来是加密了(看文档名字知道是更新游戏的代码)这里说明一下,一般遇到xxxx_precompiled.zip的这种文档,都是quick-cocos2d-x框架(quick简单来说就是对lua的拓展实现),在quick-cocos2d-x框架下可以用compile_scripts命令将lua文档加密打包成xxxx_precompiled.zip,游戏运行时再解密加载。注意,这种方式打包的lua脚本一般都会被编译成luaJIT字节码,加载的关键函数是loadChunksFromZIP,可以在IDA中直接搜索该函数,如果找不到可以搜索字符串luaLoadChunksFromZIP来定位到函数
OK,了解了原理接下来开始动手分析,将libcocos2dlua.so拖到IDA中加载,函数中直接搜索loadChunksFromZIP,定位后F5分析。
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.1_1.png)
对该函数一直向上回溯(交叉引用 ),来到下图,发现解密的密钥和签名,其中xiaoxian为密钥,XXFISH为签名
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.1_2.png)
进去函数里面看看,其实会发现调用了XXTea算法,这里我们也可以直接分析loadChunksFromZIP函数的源码(所以配置一个cocos2d的开发环境还是非常有必要的)。查看源码里的lua_loadChunksFromZIP函数的原型:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25int CCLuaStack::lua_loadChunksFromZIP(lua_State *L)
{
if (lua_gettop(L) < 1)
{
CCLOG("lua_loadChunksFromZIP() - invalid arguments");
return 0;
}
...
if (isXXTEA)
{
xxtea_long len = 0;
buffer = xxtea_decrypt(zipFileData + stack->m_xxteaSignLen,
(xxtea_long)size - (xxtea_long)stack->m_xxteaSignLen,
(unsigned char*)stack->m_xxteaKey,
(xxtea_long)stack->m_xxteaKeyLen,
&len);
delete []zipFileData;
zipFileData = NULL;
zip = CCZipFile::createWithBuffer(buffer, len);
}
...
}
接下来直接写解密函数(在cocos2d-x项目里面写的解密函数,很多任务具类直接可以调用)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40void (string strZipFilePath)
{
CCFileUtils *utils = CCFileUtils::sharedFileUtils();
unsigned long lZipFileSize = 0;
unsigned char *szBuffer = NULL;
unsigned char *zipFileData = utils->getFileData(strZipFilePath.c_str(), "rb", &lZipFileSize);
xxtea_long xxBufferLen = 0;
szBuffer = xxtea_decrypt(zipFileData + 6,
(xxtea_long)lZipFileSize - (xxtea_long)6, //减去签名的长度
(unsigned char*)"xiaoxian", //xiaoxian为密钥
(xxtea_long)8, //密钥的长度
&xxBufferLen);
//获取zip里面的所有文档
CCZipFile *zipFile = CCZipFile::createWithBuffer(szBuffer, xxBufferLen);
int count = 0;
string strFileName = zipFile->getFirstFilename();
while (strFileName.length())
{
cout << "filename:" << strFileName << endl;
unsigned long lFileBufferSize = 0;
unsigned char *szFileBuffer = zipFile->getFileData(strFileName.c_str(), &lFileBufferSize);
if (lFileBufferSize)
{
++count;
ofstream (strFileName, ios::binary);
ffout.write((char *)szFileBuffer, sizeof(char) * (lFileBufferSize));
ffout.close();
delete[] szFileBuffer;
}
strFileName = zipFile->getNextFilename();
}
delete[] zipFileData;
}
解密后的文档如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.1_3.png)
这几个都是更新游戏的代码,是luajit的文档,所以接下来需要反编译。反编译需要确定lua和luajit的版本,我们通过IDA查看下lua版本和luajit版本,字符串中分别搜索lua+空格和luajit+空格:
lua版本为5.1
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.1_4.png)
luajit版本为2.1.0
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.1_5.png)
这篇文章反编译用到的是luajit-decomp,这里需要注意,luajit-decomp默认的lua 5.1 和luajit 2.0.2,我们需要下载对应lua和luajit的版本,编译后替换luajit-decomp下的lua51.dll、luajit.exe、jit文档夹。反编译时需要替换的文档和文档夹如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.1_6.png)
对于这个游戏,我们需要下载版本为2.1.0-beta2的luajit,并且编译生成文档后,复制LuaJIT-2.1.0-beta2src路径下的lua51.dll、luajit.exe文档和jit文档夹覆盖到luajit-decomp目录中。luajit-decomp用的是autolt3语言,原脚本默认是只反编译当前目录下的test.lua文档,所以需要修改decoder.au3文档的代码。修改后的代码另存为jitdecomp.au3文档,接着编译au3代码为jitdecomp.exe。我这里还增加了data目录,该目录下有3个文档夹,分别为:
luajit:待反编译的luajit文档
asm:反汇编后的中间结果
out:反编译后的结果
将解密后的文档放到luajit文档夹,运行 jitdecomp.exe,反编译的结果在out目录下,结果如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.1_7.png)
这个反编译工具写得并不好,反编译后的lua文档阅读起来相对比较困难,而且反编译的lua格式有问题,所以不能用lua编辑器格式化代码。
捕鱼达人4
这个游戏主要是用ida动态调试so文档,然后用idc脚本把lua文档全部dump下来的方法。首先用AndroidKiller加载apk,在lib目录下有3个文档夹,不同的手机cpu型号对应不同的文档夹 。本人的手机加载的目标so文档在armeabi-v7a文档下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.2_1.png)
接着,ida加载libcocos2dlua.so文档,定位到函数luaL_loadbuffer,可以在函数中直接搜索,也可以字符串搜索 “[LUA ERROR]” 来定位到函数中,函数分析如下:
1 | LUALIB_API int luaL_loadbuffer (lua_State *L, const char *buff, size_t size,const char *name) |
所以在ARM汇编中,参数R0为lua_State指针,参数R1为脚本内容,R2为脚本大小,R3为脚本的名称,写一段IDC脚本dump数据即可:
1 |
|
ida动态调试so文档网上有很多文章,这里就不详细说明了。通过idc脚本获取的部分数据如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.2_2.png)
虽然文档的后缀名是.luac,但其实都是明文的lua脚本。
梦幻西游手游
AndroidKiller反编译apk,查看lib下存在libcocos2dlua.so,基本上确定是lua写的:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.3_1.png)
在assetsHashRes目录下,存在很多被加密的文档,这里存放的是lua脚本和游戏的其他资源文档:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.3_2.png)
接着找lua脚本的解密过程,用ida加载libcocos2dlua.so文档,搜索luaL_loadbuffer函数,定位到关键位置,这里就是解密的过程了:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.3_3.png)
分析解密lua文档过程如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.3_4.png)
这里需要实现Lrc4解密的相关函数,还有Lzma解压函数需要自己实现,其他几个都是cocos2d平台自带的函数,直接调用就可以了。上面的流程图实现的函数如下:
1 | bool decryptLua_Mhxy(string strFilePath, string strSaveDir) |
解密函数过程如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.3_5.png)
decrypt()实现代码如下:
1 | bool decrypt(unsigned char *pData, int nLen) |
Lrc4结构如下:
1 |
|
其他函数的具体实现请看DecryptData_Mhxy.cpp文档,这里就不贴代码了。解密后的文档如下:
![](https://litna.top/2018/07/07/%E6%B5%85%E6%9E%90android%E6%89%8B%E6%B8%B8lua%E8%84%9A%E6%9C%AC%E7%9A%84%E5%8A%A0%E5%AF%86%E4%B8%8E%E8%A7%A3%E5%AF%86/5.3_6.png)
可以看出,解密后的文档为luac字节