淺析android手游lua腳本的加密與解密


2018.05.02更新

        這段時間在翻備份的硬盤,突然發現了以前的分析項目和代碼,從里面提取了之前附件的內容,現在上傳給大家,真是柳暗花明又一村啊。附件包括201703版本的夢幻手游里面提取的so文件和一些加密后的資源文件(包括lua腳本),並包括了2個撲魚APK文件,最后還打包了解密代碼,供大家參考。

        附件太大,快100MB,上傳不來論壇,我又放到百度網盤了......

        鏈接:https://pan.baidu.com/s/1DVgH0qHYPkiHBIiV2UsU7g 密碼:ipt3

 

2018.04.09更新

附件是真的找不到了, 大家主要理解思路吧。百度網盤的附件好多朋友都下載和保存過,能不能發一份到論壇上傳?感謝感謝~

 

2017.04.15更新

1. 在編輯過程中,5.1后半段內容(解密和反編譯部分)被刪除了,現在補上。

2. 在3.3里說到,“修改lua項目中的opcode后,編譯生成lua.exe再替換到反編譯目錄下,就可以反編譯”,這一句是錯誤的,正確是“修改lua項目中的opcode后,重新編譯反編譯工具luadec51項目,就可以反編譯了”,已經修改。

 

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的luadec.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的反編譯好像部分代碼也反編譯失敗了,可能自己遺漏了點什么吧,就先這樣吧.....(2018/07/10 增加:夢幻西游手游lua代碼反編譯失敗的修復 請點這里

 

參考文章

騰訊游戲安全中心《Lua游戲逆向及破解方法介紹》  http://gslab.qq.com/portal.php?mod=view&aid=173

Kaitiren的專欄《Quick-cocos2d-x 與Cocos2dx 區別》http://blog.csdn.net/kaitiren/article/details/35276177

littleNA《夢幻手游部分Luac反編譯失敗的解決方法》 https://litna.top/2018/07/08/夢幻手游部分Luac反編譯失敗的解決方法/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM