查看原文
其他

浅析 Android 手游 lua 脚本的加密与解密

2017-04-22 littleNA 看雪学院


0. 前言


这篇文章是本人在学习 Android 手游安全时总结的一篇关于 lua 的文章,不足之处欢迎指正,也欢迎大牛前来交流。本文目录如下:

0. 前言

1. lua脚本在手游中的现状

2. lua、luac、luaJIT三种文件的关系

3. lua脚本的保护

     3.1 普通的对称加密,在加载脚本之前解密

     3.2 将lua脚本编译成luaJIT字节码并且加密打包

     3.3 修改lua虚拟机中opcode的顺序

4. 获取lua代码的一般方法

     4.1 静态分析so解密方法

     4.2 动态调试:ida + idc + dump

     4.3 hook so

     4.4 分析lua虚拟机的opcode的顺序

5. 三个游戏的lua脚本解密过程

     5.1 54捕鱼

     5.2 捕鱼达人4

     5.3 梦幻西游手游

6. 总结

参考文章


主要用到的工具和环境: 

1. win7 系统一枚

2. quick-cocos2d-x 的开发环境(弄一个开发环境方便学习,而且大部分 lua 手游都是用的 cocos2d-x 框架,还有一个好处,可以查看源码关键函数中的特征字符串,然后在 IDA 定位到关键函数,非常方便)

3. IDA6.8(分析so文件+动态调试so)

4. vs2015(编写解密代码)这里建议用vs2013来编译运行cocos2d-x,vs2015太多坑要填了.....

5. AndroidKiller 1.3.1(反编译apk,其中apktool.exe是最新版)

6. luadec51(反编译 luac)

7. luajit-decomp(反编译 luaJIT)

等等...


1. lua 脚本在手游中的现状

     略。


2. lua、luac、luaJIT 三种文件的关系

 

在学习 lua 手游过程中,本人遇到的 lua 文件大部分是这 3 种。其中 lua 是明文代码,直接用记事本就能打开,luac 是 lua 编译后的字节码,文件头为 0x1B 0x4C 0x75 0x61 0x51,lua 虚拟机能够直接解析 lua 和 luac 脚本文件,而 luaJIT 是另一个 lua 的实现版本(不是原作者写的),JIT 是指Just-In-Time(即时解析运行),luaJIT 相比 lua 和 luac 更加高效,文件头是 0x1B 0x4C 0x4A。

luac:


luajit:


3. lua脚本的保护


一般有安全意识的游戏厂商都不会直接把 lua 源码脚本打包到 APK 中发布,所以一般对 lua 脚本的保护有下面3种:

3.1 普通的对称加密,在加载脚本之前解密

这种情况是指打包在APK中的lua代码是加密过的,程序在加载lua脚本时解密(关键函数luaL_loadbuffer ),解密后就能够获取lua源码。如果解密后获取的是luac字节码的话,也可以通过反编译得到lua源码,反编译主要用的工具有unluac和luadec51,后面会具体分析。

3.2 将 lua 脚本编译成luaJIT字节码并且加密打包

因为反编译的结果并不容易查看,所以这种情况能够较好的保护 lua 源码。这个情况主要是先解密后反编译,反编译主要是通过 luajit-decomp 项目,它能够将 luajit 字节码反编译成伪 lua 代码。

3.3 修改 lua 虚拟机中 opcode 的顺序

这种情况主要是修改 lua 虚拟机源码,再通过修改过的虚拟机将lua脚本编译成 luac 字节码,达到保护的目的。这种情况如果直接用上面的反编译工具是不能将 luac 反编译的,需要在程序中分析出相对应的opcode,然后修改lua项目的 opcode 的顺序并重新编译生成反编译工具,就能反编译了,后面会具体分析。    

 一般上面的情况都会交叉遇到。


4. 获取 lua 源码的一般方法


这里主要介绍 4 种方法,都会在第 5 节中用实例说明。

4.1 静态分析 so 解密方法

这种方法需要把解密的过程全部分析出来,比较费时费力,主要是通过 ida 定位到 luaL_loadbuffer 函数,然后往上回溯,分析出解密的过程。

4.2 动态调试:ida + idc + dump

这里主要通过 ida 动态调试 so 文件,然后是定位到 luaL_loadbuffer 地址,游戏会在启动的时候通过调用 luaL_loadbuffer 函数加载必要的 lua 脚本,通过在 luaL_loadbuffer 下断点 ,断下后就可以运行 idc 脚本将lua代码导出(程序调用一次 luaL_loadbuffer 加载一个lua脚本,不写 idc 脚本的话需要手动导N多遍.....)。

4.3 hook so

跟 4.2 原理一样,就是通过 hook 函数 luaL_loadbuffer 地址,将代码保存,相比4.2的好处是有些 lua 脚本需要在玩游戏的过程中才加载,如果用了 4.2 的方法,游戏过程中 中断一次就需要手动运行一次 idc 脚本,而且往往每次只加载一个 lua 文件,如果是 hook 的话,就不需要那么麻烦,直接玩一遍游戏,全部 lua 脚本就已经保存好了。

4.4 分析 lua 虚拟机的 opcode 的顺序

这里主要是 opcode 的顺序被修改了,需要用 ida 定位到虚拟机执行luac字节码的地方,然后对比原来 lua 虚拟机的执行过程,获取修改后的 opcode 顺序,最后还原 lua 脚本。


5. 三个游戏的 lua 脚本解密实例


好了,下面用3个例子来说明上面的情况。


5.1 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。

一直向上回溯(交叉引用 ),来到下图,发现解密的密钥和签名,其中xiaoxian为密钥,XXFISH为签名。

进去函数里面看看,其实会发现调用了XXTea算法,这里我们也可以直接分析loadChunksFromZIP函数的源码(所以配置一个cocos2d的开发环境还是非常有必要的)。查看源码里的lua_loadChunksFromZIP函数的原型:

int CCLuaStack::lua_loadChunksFromZIP(lua_State *L)

{

    if (lua_gettop(L) < 1)

    {     // 这里可以发现用字符串也可以定位到目标函数

        CCLOG("lua_loadChunksFromZIP() - invalid arguments");

        return 0;

    }

...

        if (isXXTEA)

        {

            // decrypt XXTEA

            // 这里调用了解密函数

            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项目里面写的解密函数,很多工具直接可以调用)

void decryptZipFile_54BY(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,           //6为签名XXFISH的长度

               (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 ffout(strFileName, ios::binary);

                       ffout.write((char *)szFileBuffer, sizeof(char) * (lFileBufferSize));

                       ffout.close();

                       delete[] szFileBuffer;

               }

               strFileName = zipFile->getNextFilename();

        }

        delete[] zipFileData;

}

解密后的文件如下:

这几个都是更新游戏的代码,是luajit的文件,所以接下来需要反编译。IDA中查看下lua版本和luajit版本,字符串分别搜索lua+空格和luajit+空格:

lua 版本为5.1

luajit 版本为2.1.0

反编译本人用到的是luajit-decomp,这里需要注意,luajit-decomp默认的lua版本为5.1,luajit版本为2.0.2,我们需要下载对应lua和luajit的版本,编译后替换luajit-decomp下的lua51.dll、luajit.exe、jit文件夹。反编译时需要注意的文件和文件夹:

这里需要下载版本为2.1.0-beta2的luajit,并且编译生成文件后,复制LuaJIT-2.1.0-beta2\src路径下的lua51.dll、luajit.exe文件和jit文件夹覆盖到luajit-decomp目录中。luajit-decomp用的是autolt3语言,原脚本默认是只反编译当前目录下的test.lua文件,所以需要改一下decoder.au3文件的代码。修改后的代码另存为jitdecomp.au3文件,编译后为jitdecomp.exe。并且增加了data目录,目录下有3个文件夹,分别为:

  • luajit:待反编译的luajit文件

  • asm:反汇编后的中间结果

  • out:反编译后的结果

将解密后的文件放到luajit文件夹,运行 jitdecomp.exe,反编译的结果在out目录下,结果如下:

这个反编译工具写得并不好,反编译后的文件阅读起来挺困难的,而且反编译的lua格式有问题,所以不能用lua编辑器格式化代码。

5.2 捕鱼达人4

这个游戏主要是用ida动态调试so文件,然后用idc脚本把lua文件全部dump下来的方法。首先用AndroidKiller加载apk,在lib目录下有3个文件夹,不同的手机cpu型号对应不同的文件夹 。本人的手机加载的目标so文件在armeabi-v7a文件下:

接着,ida加载libcocos2dlua.so文件,定位到函数luaL_loadbuffer,可以在函数中直接搜索,也可以字符串搜索"[LUA ERROR]"来定位到函数中,函数分析如下:

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 数据即可:

#include <idc.idc>

static main()

{

    auto code, bp_addrese,fp,strPath,strFileName;

    bp_addrese = 0x7573022C;                                                // luaL_loadbuffer函数地址 ,静态分析获取的函数地址+so文件的地址得到

    AddBpt(bp_addrese);                                                     // 下断点,也可以手动下断

    while(1)

    {

        code = GetDebuggerEvent(WFNE_SUSP|WFNE_CONT, 15);                   // 等待断点发生,等待时间为15秒

        if ( code <= 0 )

        {

            Warning("错误代码:%d",code);

            return 0;

        }

        Message ("地址:%a, 事件id:%x\n", GetEventEa(), GetEventId());      // 断点发生,打印消息

        strFileName = GetString(GetRegValue("R3"),-1,0);                    // 获取文件路径名

        strFileName = substr(strFileName,strrstr(strFileName,"/")+1,-1);    // 获取最后一个‘/’后面的名字(文件的名字)去掉路径

        strPath = sprintf("c:\\lua\\%s",strFileName);                       // 保存lua的本地路径

        fp = fopen(strPath,"wb");

        savefile(fp,0,GetRegValue("R1"),GetRegValue("R2"));

        fclose(fp);

        Message("保存文件成功: %s\n",strPath);

    }

}

//字符串查找函数,从后面向前查找,返回第一次查找的字符串下标

static strrstr(str,substr1)

{

    auto i,index;

    index = -1;

    while (1)

    {

        i = strstr(str,substr1);

        if (-1 == i) return index;

        str = substr(str,i+1,-1);

        index = index+i+1;

    };

}

ida 动态调试so文件网上有很多文章,这里就不详细说明了。通过idc脚本获取的部分数据如下:

虽然文件的后缀名是.luac,但其实都是明文的lua脚本。

5.3 梦幻西游手游

AndroidKiller 反编译 apk,查看lib下存在 libcocos2dlua.so,基本上确定是 lua 写的:

在 assets\HashRes 目录下,存在很多被加密的文件,这里存放的是 lua 脚本和游戏的其他资源文件

接着找 lua 脚本的解密过程,用 ida 加载 libcocos2dlua.so 文件,搜索 luaL_loadbuffer 函数,定位到关键位置,这里就是解密的过程了:

 分析解密lua文件过程如下:

这里需要实现 Lrc4 解密的相关函数,还有 Lzma 解压函数需要自己实现,其他几个都是 cocos2d 平台自带的函数,直接调用就可以了。上面的流程图实现的函数如下:

bool decryptLua_Mhxy(string strFilePath, string strSaveDir)

{

        bool bResult = false;

        char *szBuffer = NULL;

        int nBufferSize = 0;

        CCFileUtils *utils = CCFileUtils::sharedFileUtils();

        unsigned long ulFileSize = 0;

        char *szFileData = (char*)utils->getFileData(strFilePath.c_str(), "rb", &ulFileSize);

        if (strncmp(szFileData, "L:grxx", 6))

        {

               if (!strncmp(szFileData, "__sign_of_g18_enc__", 0x13))

               {

                       szBuffer = szFileData + 0x13;

                       nBufferSize = ulFileSize - 0x13;

                       bResult = decrypt((unsigned char*)szBuffer, nBufferSize);

               }

        }

        else if (!strncmp(szFileData + 6, "__sign_of_g18_enc__", 0x13))

        {

               unsigned char *pData = (unsigned char *)szFileData + 0x19;

               int nLen = ulFileSize - 0x19;

               bResult = decrypt(pData, nLen);

               if (ZipUtils::isGZipBuffer(pData, nLen))

               {

                       nBufferSize = ZipUtils::ccInflateMemory(pData, nLen, (unsigned char**)&szBuffer);

               }

               else if (ZipUtils::isCCZBuffer(pData, nLen))

               {

                       nBufferSize = ZipUtils::inflateCCZBuffer(pData, nLen, (unsigned char**)&szBuffer);

               }

               else if (LzmaUtils::isLzmaBuffer(pData, nLen))

               {

                       nBufferSize = LzmaUtils::inflateLzmaBuffer(pData, nLen, (unsigned char**)&szBuffer);

               }

               else

               {

                       bResult = false;

               }

        }

        if(bResult)

               saveLuaData(szBuffer, nBufferSize, strSaveDir);

        return bResult;

}

 解密函数过程如下:

decrypt() 实现代码如下:

bool decrypt(unsigned char *pData, int nLen)

{

        Lrc4 *pLrc4 = new Lrc4;

        Lrc4_lrc4(pLrc4);

        Lrc4_s(pLrc4, pData, nLen);

        return true;

}

 Lrc4结构如下:

#define DATA_SIZE 256

struct Lrc4

{

        unsigned char pData[DATA_SIZE];  //初始化时计算得到的256个字节

        int nIndex;                      //记录下标

        int nPreIndex;                   //记录前一个下标

};

其他函数的具体实现请看 DecryptData_Mhxy.cpp 文件,这里就不贴代码了。解密后的文件如下:

可以看出,解密后的文件为 luac 字节码,但是这里直接用反编译工具是不能反编译luac字节码的,因为游戏的 opcode 被修改过了,我们需要找到游戏 opcode 的顺序,然后生成一个对应 opcode 的 lua.exe 文件,替换反编译工具下的 lua.exe 才能反编译。下表为修改前后的 opcode:

lua 虚拟机的相关内容就不说明了,百度很多,这里说明下如何还原 opcode 的顺序。首先需要定位到 opmode 的地方,IDA搜索字符串"LOADK",定位到 opname 的地方,交叉引用到代码,找到 opmode:

off_B02CEC 为 opname 的地址,byte_A67C00 为 opmode 的地址,进入 opmode 地址查看:

这里没有把全部数据截图出来,可以看出,这里的 opmode 跟原 opmode 是不对应的。原 opmode 在 lua 源码中的 lopcodes.c 文件中:

源码用了宏,计算出来的结果就是上表中 opmode 的结果。这里对比 opmode 就可以快速对比出 opcode,因为 opmode 不相等,那么 opcode 也肯定不相等,到这一步,已经能还原部分 opcode 了,因为有一些 opmode 是唯一的。比如下面几个:

如SETLIST,原 opcode 为34,opmode 为 0x14,找到的 opmode 的第 8 个字节也为0x14,则实际上 SETLIST 的 opcode 为 8。

接下来就需要定位到 luaV_execute 函数,然后对比源码来还原其他的 opcode,直接 IDA 搜索字符串"initial value must be a number"可以定位到 luaV_execute 函数,再 F5一下。接着打开 lua 源码中的 lvm.c 文件,找到 luaV_execute 函数,就可对比还原了。lua 源码和IDA F5后的代码其实差别还是有的,而且源码用了大量的宏,所以源码只是用来参考、理解lua虚拟机的解析过程,本人在还原的过程中,会再打开一个没有修改 opcode 的 libcocos2dlua.so 文件,这样对比查找就方便多了。

最后修改lua源码 lopcodes.h 中的 opcode、lopcodes.c 的 opname 和 opmode,重新编译并生成 luadec51 .exe(需要将 lua 源码中的 src 目录放到 luadec51 的 lua 目录下才能编译),就 OK 了,写个批处理文件就可以批量反编译。一个文件反编译的结果:


6. 总结


总结一下解密lua的流程,拿到APK,首先反编译,查看lib目录下是否有libcocos2dlua.so,存在的话很大可能这个游戏就是lua编写,lib目录下文件最大的就是目标so文件,一般情况就是libcocos2dlua.so。接着再看assets文件夹有没有可疑的文件,cocos2dx框架都会把游戏的资源文件放到这个文件夹下,包括lua脚本。其次分析lua加密的方式并选择解密脚本的方式,如果可以ida动态调试,本人一般都会选择用idc脚本dump代码。最后如果得到的不是lua明文,还需要再反编译一下。


不足之处:第一个是此文是本人逆向lua 手游时的总结,而且本人逆向的手游可能不是很多,所以有些观点比较片面,不足之处请指正。第二个就是文章是事后写的,并且写文章的时间比较仓促,所以有些步骤写得可能不详细,欢迎讨论。如果有必要,会写一篇《如何一步一步还原梦幻手游 opcode》,但是如果看过lua源码,对lua比较熟悉的话,找出来我想应该不是问题的。第三个就是 luajit 的反编译并不完美,用的是 luajit-decomp 反编译工具,工具作者也说只是满足了他自己的需求,所以如果可以的话,想自己实现一个 luajit 的反编译工具,而且梦幻 luac 的反编译好像部分代码也反编译失败了,可能自己遗漏了点什么吧,就先这样吧.....


参考文章


  • 腾讯游戏安全中心《Lua 游戏逆向及破解方法介绍》 http://gslab.qq.com/portal.php?mod=view&aid=173

  • 云风《Lua源码欣赏》http://download.csdn.net/download/nomoonon/8551481

  • Kaitiren的专栏《Quick-cocos2d-x 与Cocos2dx 区别》http://blog.csdn.net/kaitiren/article/details/35276177



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存