首页
社区
课程
招聘
[原创] 淺談Cocos2djs逆向
发表于: 2024-9-5 22:10 22262

[原创] 淺談Cocos2djs逆向

2024-9-5 22:10
22262

簡單聊一下cocos2djs手遊的逆向,有任何相關想法歡迎和我討論^^

列出一些個人認為比較有用的概念:

自己寫一個Demo來分析的好處是能夠快速地判斷某個錯誤是由於被檢測到?還是本來就會如此?

嘗試過2.4.2、2.4.6兩個版本,都構建失敗,最終成功的版本信息如下:

由於本人不懂cocos遊戲開發,只好直接用官方的Hello World模板。

首先要設置SDK和NDK路徑

然後構建的參數設置如下,主要需要設置以下兩點:

我使用Cocos Creator能順利構建,但無法編譯,只好改用Android Studio來編譯。

使用Android Studio打開build\jsb-link\frameworks\runtime-src\proj.android-studio,然後就可以按正常AS流程進行編譯

Demo如下所示,在中心輸出了Hello, World!

上述Demo構建中有一個選項是【加密腳本】,它會將js腳本通過xxtea算法加密成.jsc

而遊戲的一些功能就會通過js腳本來實現,因此cocos2djs逆向首要事件就是將.jsc解密,通常.jsc會存放在apk內的assets目錄下

方法一:從applicationDidFinishLaunching入手

方法二:HOOK

一次性解密output_dir目錄下所有.jsc,並在input_dir生成與output_dir同樣的目錄結構。

為實現對遊戲正常功能的干涉,顯然需要修改遊戲執行的js腳本。而替換.jsc文件是其中一種思路,前提是要找到讀取.jsc文件的地方。

我自己編譯的Demo就是以這種方式讀取/data/app/XXX/base.apkassets目錄內的.jsc文件。

cocos引擎默認使用xxtea算法來對.jsc等腳本進行加密,因此讀取.jsc的操作定然在xxtea_decrypt之前。

cocos2d-x源碼,找使用xxtea_decrypt的地方,可以定位到LuaStack::luaLoadChunksFromZIP

向上跟會發現它的bytes數據是由getDataFromFile函數獲取

繼續跟getDataFromFile的邏輯,它會調用getContents,而getContents裡是調用fopen來打開,但奇怪的是hook fopen卻沒有發現它有打開任何.jsc文件

後來發現調用的並非FileUtils::getContents,而是FileUtilsAndroid::getContents

它其中一個分支是調用libandroid.soAAsset_read來讀取.jsc數據,調用AAssetManager_open來打開.jsc文件。

繼續對AAssetManager_open進行深入分析( 在線源碼 ),目的是找到能夠IO重定向的點:

AAssetManager_open裡調用了AssetManager::open函數

AssetManager::open調用openNonAssetInPathLocked

AssetManager::openNonAssetInPathLocked先判斷assets是位於.gz還是.zip內,而.apk.zip基本等價,因此理應會走else分支。

嘗試繼續跟剛剛hook失敗的AssetManager::getZipFileLocked,它調用的是AssetManager::ZipSet::getZip

ZipSet::getZip會調用SharedZip::getZip,後者直接返回mZipFile

尋找mZipFile賦值的地方,最終會找到是由ZipFileRO::open(mPath.string())賦值。

無論是方式一還是方式二,.jsc數據都是通過getDataFromFile獲取。而getDataFromFile裡調用了getContents

在方式一中,我一開始看的是FileUtils::getContents,但其實是FileUtilsAndroid::getContents才對。

只有當fullPath[0] == '/'時才會調用FileUtils::getContents,而FileUtils::getContents會調用fopen來打開.jsc

正常來說有以下幾種替換腳本的思路:

找到讀取.jsc文件的地方進行IO重定向。

直接進行字節替換,即替換xxtea_decypt解密前的.jsc字節數據,或者替換xxtea_decypt解密後的明文.js腳本。

這裡的替換是指開闢一片新內存,將新的數據放到這片內存,然後替換指針的指向。

直接替換apk裡的.jsc,然後重打包apk。

替換js明文,不是像2那樣開闢一片新內存,而是直接修改原本內存的明文js數據。

經測試後發現只有134是可行的,2會導致APP卡死( 原因不明??? )。

從上述可知第一種.jsc讀取方式會先調用ZipFileRO::open(mPath.string())來打開apk,之後再通過AAssetManager_open來獲取.jsc

hook ZipFileRO::open看看傳入的參數是什麼。

可以看到其中一條是當前APK的路徑,顯然assets也是從這裡取的,因此這裡是一個可以嘗試重定向點,先需構造一個fake.apk push 到/data/app/XXX/下,然後hook IO重定向到fake.apk實現替換。

對我自己編譯的Demo而言,無論是以apktool解包&重打包的方式,還是直接解壓縮&重壓縮&手動命名的方式來構建fake.apk都是可行的,但要記得賦予fake.apk最低644的權限。

以下是我使用上述方法在我的Demo中實踐的效果,成功修改中心的字符串。

但感覺這種方式的實用性較低( 什至不如直接重打包… )

連這樣僅替換指針指向都會導致APP卡死??

參考這位大佬的文章可知cocos2djs內置的v8引擎最終通過evalString來執行.jsc解密後的js代碼。

在正式替換前,最好先通過hook evalString的方式保存一份目標js( 因為遊戲的熱更新策略等原因,可能導致evalString執行的js代碼與你從apk裡手動解密.jsc得到的js腳本有所不同 )。

利用Memory.scan來找到修改的位置

最後以Memory.writeU8來逐字節修改,不用Memory.writeUtf8String的原因是它默認會在最終添加'\0'而導致報錯。

以某款砍樹遊戲來進行簡單的實踐。

遊戲有自動砍樹的功能,但需要符合一定條件

如何找到對應的邏輯在哪個.jsc中?直接搜字符串就可以。

利用上述替換思路4來修改對應的js判斷邏輯,最終效果:

思路4那種替換手段有大小限制,不能隨意地修改,暫時還未找到能隨意修改的手段,有知道的大佬還請不嗇賜教,有任何想法也歡迎交流^^

在評論區的一位大佬指點下,終於是找到一種更優的替換方案,相比起思路4來說要方便太多了。
最開始時我其實也嘗試過這種直接的js明文替換,但APP會卡死/閃退,現在才發現是frida的api所致,那時在開辟內存空間時使用了Memory.alloc、Memory.allocUtf8String,改成使用libc.so的malloc就不會閃退了,具體為什麼會這樣我也不清楚,看看以後有沒有機會研究下frida的源碼吧^^

# pip install xxtea-py
# pip install jsbeautifier
 
import xxtea
import gzip
import jsbeautifier
import os
 
KEY = "abdbe980-786e-45"
 
input_dir = r"cocos2djs_demo\assets" # abs path
 
output_dir = r"cocos2djs_demo\output" # abs path
 
def jscDecrypt(data: bytes, needJsBeautifier = True):
    dec = xxtea.decrypt(data, KEY)
    jscode = gzip.decompress(dec).decode()
 
    if needJsBeautifier:
        return jsbeautifier.beautify(jscode)
    else:
        return jscode
 
def jscEncrypt(data):
    compress_data = gzip.compress(data.encode())
    enc = xxtea.encrypt(compress_data, KEY)
    return enc
 
def decryptAll():
    for root, dirs, files in os.walk(input_dir):
         
        # 創建與input_dir一致的結構
        for dir in dirs:
            dir_path = os.path.join(root, dir)
            target_dir = output_dir + dir_path.replace(input_dir, "")
            if not os.path.exists(target_dir):
                os.mkdir(target_dir)
 
        for file in files:
            file_path = os.path.join(root, file)
        
            if not file.endswith(".jsc"):
                continue
             
            with open(file_path, mode = "rb") as f:
                enc_jsc = f.read()
             
            dec_jscode = jscDecrypt(enc_jsc)
             
            output_file_path = output_dir + file_path.replace(input_dir, "").replace(".jsc", "") + ".js"
 
            print(output_file_path)
            with open(output_file_path, mode = "w", encoding = "utf-8") as f:
                f.write(dec_jscode)
 
def decryptOne(path):
    with open(path, mode = "rb") as f:
        enc_jsc = f.read()
     
    dec_jscode = jscDecrypt(enc_jsc, False)
 
    output_path = path.split(".jsc")[0] + ".js"
 
    with open(output_path, mode = "w", encoding = "utf-8") as f:
        f.write(dec_jscode)
 
def encryptOne(path):
    with open(path, mode = "r", encoding = "utf-8") as f:
        jscode = f.read()
 
    enc_data = jscEncrypt(jscode)
     
    output_path = path.split(".js")[0] + ".jsc"
 
    with open(output_path, mode = "wb") as f:
        f.write(enc_data)
 
if __name__ == "__main__":
    decryptAll()
# pip install xxtea-py
# pip install jsbeautifier
 
import xxtea
import gzip
import jsbeautifier
import os
 
KEY = "abdbe980-786e-45"
 
input_dir = r"cocos2djs_demo\assets" # abs path
 
output_dir = r"cocos2djs_demo\output" # abs path
 
def jscDecrypt(data: bytes, needJsBeautifier = True):
    dec = xxtea.decrypt(data, KEY)
    jscode = gzip.decompress(dec).decode()
 
    if needJsBeautifier:
        return jsbeautifier.beautify(jscode)
    else:
        return jscode
 
def jscEncrypt(data):
    compress_data = gzip.compress(data.encode())
    enc = xxtea.encrypt(compress_data, KEY)
    return enc
 
def decryptAll():
    for root, dirs, files in os.walk(input_dir):
         
        # 創建與input_dir一致的結構
        for dir in dirs:
            dir_path = os.path.join(root, dir)
            target_dir = output_dir + dir_path.replace(input_dir, "")
            if not os.path.exists(target_dir):
                os.mkdir(target_dir)
 
        for file in files:
            file_path = os.path.join(root, file)
        
            if not file.endswith(".jsc"):
                continue
             
            with open(file_path, mode = "rb") as f:
                enc_jsc = f.read()
             
            dec_jscode = jscDecrypt(enc_jsc)
             
            output_file_path = output_dir + file_path.replace(input_dir, "").replace(".jsc", "") + ".js"
 
            print(output_file_path)
            with open(output_file_path, mode = "w", encoding = "utf-8") as f:
                f.write(dec_jscode)
 
def decryptOne(path):
    with open(path, mode = "rb") as f:
        enc_jsc = f.read()
     
    dec_jscode = jscDecrypt(enc_jsc, False)
 
    output_path = path.split(".jsc")[0] + ".js"
 
    with open(output_path, mode = "w", encoding = "utf-8") as f:
        f.write(dec_jscode)
 
def encryptOne(path):
    with open(path, mode = "r", encoding = "utf-8") as f:
        jscode = f.read()
 
    enc_data = jscEncrypt(jscode)
     
    output_path = path.split(".js")[0] + ".jsc"
 
    with open(output_path, mode = "wb") as f:
        f.write(enc_data)
 
if __name__ == "__main__":
    decryptAll()
// frameworks/base/native/android/asset_manager.cpp
AAsset* AAssetManager_open(AAssetManager* amgr, const char* filename, int mode)
{
    Asset::AccessMode amMode;
    switch (mode) {
    case AASSET_MODE_UNKNOWN:
        amMode = Asset::ACCESS_UNKNOWN;
        break;
    case AASSET_MODE_RANDOM:
        amMode = Asset::ACCESS_RANDOM;
        break;
    case AASSET_MODE_STREAMING:
        amMode = Asset::ACCESS_STREAMING;
        break;
    case AASSET_MODE_BUFFER:
        amMode = Asset::ACCESS_BUFFER;
        break;
    default:
        return NULL;
    }
 
    AssetManager* mgr = static_cast<AssetManager*>(amgr);
    // here
    Asset* asset = mgr->open(filename, amMode);
    if (asset == NULL) {
        return NULL;
    }
 
    return new AAsset(asset);
}
// frameworks/base/native/android/asset_manager.cpp
AAsset* AAssetManager_open(AAssetManager* amgr, const char* filename, int mode)
{
    Asset::AccessMode amMode;
    switch (mode) {
    case AASSET_MODE_UNKNOWN:
        amMode = Asset::ACCESS_UNKNOWN;
        break;
    case AASSET_MODE_RANDOM:
        amMode = Asset::ACCESS_RANDOM;
        break;
    case AASSET_MODE_STREAMING:
        amMode = Asset::ACCESS_STREAMING;
        break;
    case AASSET_MODE_BUFFER:
        amMode = Asset::ACCESS_BUFFER;
        break;
    default:
        return NULL;
    }
 
    AssetManager* mgr = static_cast<AssetManager*>(amgr);
    // here
    Asset* asset = mgr->open(filename, amMode);
    if (asset == NULL) {
        return NULL;
    }
 

[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)

最后于 2024-9-23 10:05 被ngiokweng编辑 ,原因: no.
收藏
免费 4
支持
分享
最新回复 (37)
雪    币: 116
活跃值: (1012)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
感谢分享 学习了
2024-9-6 00:40
0
雪    币: 414
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
我用脚本hook evalstring没有反应,就attach上了然后就一直没反应了,是什么原因
2024-9-11 22:25
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
4
mb_eunzmkbp 我用脚本hook evalstring没有反应,就attach上了然后就一直没反应了,是什么原因
哪個APP
2024-9-12 09:10
0
雪    币: 414
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5
ngiokweng 哪個APP
就是文章最后提到的砍树手游
2024-9-12 17:48
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
6
mb_eunzmkbp 就是文章最后提到的砍树手游
可能是版本不一樣, 我測試用的是台版的
2024-9-12 20:33
0
雪    币: 414
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
ngiokweng 可能是版本不一樣, 我測試用的是台版的
好的感谢
2024-9-13 12:26
0
雪    币: 45
活跃值: (1359)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
ngiokweng 哪個APP
寻道大千吧
2024-9-19 12:36
0
雪    币: 45
活跃值: (1359)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9
ngiokweng 哪個APP

evalString的第3个参数就是大小吧。通过send把文件名发送给电脑的python,返回文件后再进行替换。

const malloc = new NativeFunction(Module.findExportByName('libc.so', 'malloc'), 'pointer', ['int']);
    const free = new NativeFunction(Module.findExportByName('libc.so', 'free'), 'void', ['pointer']);
    const memset = new NativeFunction(Module.findExportByName('libc.so', 'memset'), 'pointer', ['pointer', 'int', 'int']);
    const Cocos2dxActivity = Java.use('org.cocos2dx.lib.Cocos2dxActivity'); //要hook的类名完整路径

    Cocos2dxActivity.onLoadNativeLibraries.implementation = function() { 
        this.onLoadNativeLibraries();
        // const evalString = Module.findExportByName("libcocos2djs.so", "_ZN2se12ScriptEngine10evalStringEPKciPNS_5ValueES2_")
        // const bufList = [];
        // Interceptor.attach(evalString, {
        //     onEnter: function(args){
        //         var script = args[1].readCString();
        //         var codeSize = args[2];
        //         var pathName = args[4].readCString();
        //         if(pathName){
        //             console.log("load file: " + pathName);
        //             send({type:"get_script", filename:pathName});
        //             recv(function(data){
        //                if (data.content){
        //                    const content = data.content;
        //                    const buf = malloc(data.length+1);       //new NativePointer(malloc...)
        //                    bufList.push(buf);
        //                    memset(buf, 0, data.length+1);
        //                    buf.writeUtf8String(content);
        //                    args[1] = buf;
        //                    args[2] = new NativePointer(data.length);
        //                    send({type: "done", filename:pathName});
        //                }
        //             }).wait();
        //         }else{
        //             console.log("load script: " + script)
        //         }
        //     },
        //     onLeave: function (retval) {
        //         const freePtr = bufList.pop();
        //         if (freePtr) {
        //             free(freePtr);
        //         }
        //     }
        // })
    };


最后于 2024-9-19 12:54 被樊辉编辑 ,原因:
2024-9-19 12:53
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
10
樊辉 ngiokweng 哪個APP evalString的第3个参数就是大小吧。通过send把文件名发送给电脑的python,返回文 ...
是大小沒錯,至於替換的方式有很多種,能成功都好說
2024-9-19 21:31
0
雪    币: 45
活跃值: (1359)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
11
ngiokweng 是大小沒錯,至於替換的方式有很多種,能成功都好說
上面我发的代码能成功,你可以试试
2024-9-20 14:38
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
12
樊辉 上面我发的代码能成功,你可以试试
成功是指hook evalString嗎?這個我也可以,我失敗的那種情況是我想直接替換.jsc,但會閃退
2024-9-20 18:25
0
雪    币: 45
活跃值: (1359)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
13
ngiokweng 成功是指hook evalString嗎?這個我也可以,我失敗的那種情況是我想直接替換.jsc,但會閃退

指的是替换jsc的明文成功,失败可能是因为编码的原因,计算的文件长度不对导致的。

def on_message(self, message, data):
        if message["type"] == "send":
            payload = message["payload"]
            type = payload["type"]
            filename = payload["filename"]
            if type == "get_script":
                try:
                    text = open(os.path.join("js", filename), "r", encoding="utf-8").read()
                    self.script.post({"content": text, "length": len(text.encode('utf-8'))})    #因为有中文,必须是utf-8的字节长度
                except FileNotFoundError as e:
                    if not self.stop:
                        self.script.post({})

            elif type == "done":
                print("The " + filename + " file has been replaced!")
                self.stop = True

        elif message["type"] == "error":
            print(message["stack"])
            self.stop = True


2024-9-20 18:33
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
14
樊辉 指的是替换jsc的明文成功,失败可能是因为编码的原因,计算的文件长度不对导致的。def&nbsp;on_message(self,&nbsp;message,&nbsp;dat ...
感謝分享, 我有空試試。還想請問下有沒有直接替換密文.jsc的方法?
2024-9-20 19:06
0
雪    币: 45
活跃值: (1359)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
15
ngiokweng 感謝分享, 我有空試試。還想請問下有沒有直接替換密文.jsc的方法?
我没试过直接替换密文,应该得往前翻源码了。
2024-9-22 07:43
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
16
如果是用Java开发的要怎么弄
2024-9-24 23:15
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
17
mb_rdqahqsz 如果是用Java开发的要怎么弄
沒看懂你的意思
2024-9-25 10:24
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
18
3.x试过没有 方法三的. 另外查key的so代码跟2.x有区别没
2024-10-9 22:49
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
19
2666fff 3.x试过没有 方法三的. 另外查key的so代码跟2.x有区别没
有樣本嗎, 我沒遇過3.x的
2024-10-10 09:35
0
雪    币: 414
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
20
ngiokweng 有樣本嗎, 我沒遇過3.x的
libcocos.so的对吧,我不知道能不能直接发游戏名字,密钥倒是能直接找到就是hook不出来,不知道啥原因
2024-10-10 14:31
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
21
mb_eunzmkbp libcocos.so的对吧,我不知道能不能直接发游戏名字,密钥倒是能直接找到就是hook不出来,不知道啥原因
你把APP名字base64編碼下再發出來就好了
2024-10-10 15:21
0
雪    币: 414
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
22
ngiokweng 你把APP名字base64編碼下再發出來就好了
taptap上下载的,56WW5a6X5qih5ouf5Zmo77ya5Lyg5om/
2024-10-10 16:29
0
雪    币: 1212
活跃值: (1050)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
23
mb_eunzmkbp taptap上下载的,56WW5a6X5qih5ouf5Zmo77ya5Lyg5om/

你這個樣本不是3.X吧, 我說的3.X是指Cocos Creator的版本, 生成的so名字也不叫libcocos2djs.so

最后于 2024-10-10 22:58 被ngiokweng编辑 ,原因:
2024-10-10 22:56
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
24
ngiokweng 有樣本嗎, 我沒遇過3.x的
编了一个3.8.2 的小游戏,. 最新版本是.3 应该差别不大, 我看了下so大概都被混淆了. 下面是base64编码下载地址.
6ZO+5o6lOiBodHRwczovL3Bhbi5iYWlkdS5jb20vcy8xMXEzNDZHa21ONGVXYVpTQUgtNWJ6UT9wd2Q9bXBnMiDmj5Dlj5bnoIE6IG1wZzIg5aSN5Yi26L+Z5q615YaF5a655ZCO5omT5byA55m+5bqm572R55uY5omL5py6QXBw77yM5pON5L2c5pu05pa55L6/5ZOmIAotLeadpeiHqueZvuW6pue9keebmOi2hee6p+S8muWRmHY555qE5YiG5Lqr
2024-10-10 23:54
0
雪    币: 414
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
25
ngiokweng mb_eunzmkbp taptap上下载的,56WW5a6X5qih5ouf5Zmo77ya5Lyg5om/ 你這個樣本不是3 ...
是的呀,zuzong模拟器:传承,是libcocos.so,不是libcocos2djs
2024-10-11 01:27
0
游客
登录 | 注册 方可回帖
返回
//