浅谈Cocos2djs逆向

简单聊一下cocos2djs手游的逆向,有任何相关想法欢迎和我讨论。





一些概念


列出一些个人认为比较有用的概念:


◆Cocos游戏的两大开发工具工具分別是CocosCreatorCocosStudio,区别是前者是cocos2djs专用的开发工具,后者则是cocos2d-lua、cocos2d-cpp那些。


浅谈Cocos2djs逆向


◆使用Cocos Creator 2开发的手游,生成的关键so默认名称是libcocos2djs.so


◆使用Cocos Creator 3开发手游生成的关键so默认名称libcocos.so( 入口函数非applicationDidFinishLaunching)。


◆Cocos Creator在构建时可以选择是否对.js脚本进行加密&压缩,而加密算法固定是xxtea,还可以选择是否使用Zip压缩。


浅谈Cocos2djs逆向


libcocos2djs.so里AppDelegate::applicationDidFinishLaunching是入口函数,可以从这里开始进行分析。


◆Cocos2djs是Cocos2d-x的一个分支,因此https://github.com/cocos2d/cocos2d-x源码同样适用于Cocos2djs。





自己写一个Demo


自己写一个Demo来分析的好处是能够快速地判断某哥错误是由于被检测到?还是本来就会如此?


版本信息


尝试过2.4.2、2.4.6两个版本,都构建失败,最终成功的版本信息如下:


◆编辑器版本:Creator 2.4.13( 2系列里的最高版本,低版本在AS编译时会报一堆错误 )

◆ndk版本:23.1.7779620

project/build.gradleclasspath 'com.android.tools.build:gradle:8.0.2'

project/gradle/gradle-wrapper.propertiesdistributionUrl=https://services.gradle.org/distributions/gradle-8.0.2-all.zip


Cocos Creator基础用法


由于本人不懂cocos游戏开发,只好直接用官方的Hello World模板。


浅谈Cocos2djs逆向


首先要设置SDK和NDK路径


浅谈Cocos2djs逆向


然后构建的参数設置如下,主要需要设置以下两点:

◆加密脚本:全都勾上,密码用默认的。

◆Source Map:保留符号,这样IDA在打开时才能看到函数名。


浅谈Cocos2djs逆向


我使用Cocos Creator能顺利构建,但无法编译,只好改用Android Studio来编译。


使用Android Studio打开buildjsb-linkframeworksruntime-srcproj.android-studio,然后就可以按正常AS流程进行编译

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


浅谈Cocos2djs逆向





jsc脚本解密


上述Demo构建中有一个选项是【加密脚本】,它会将js脚本通过xxtea算法加密成.jsc


而游戏的一些功能就会通过js脚本来实现,因此cocos2djs逆向首要事件就是将.jsc解密,通常.jsc会存放在apk內的assets目录下。


浅谈Cocos2djs逆向


获取解密key


方法一:从applicationDidFinishLaunching入手


浅谈Cocos2djs逆向


方法二:HOOK


1.hookset_xxtea_key


// soName: libcocos2djs.so
function hook_jsb_set_xxtea_key(soName) {
let set_xxtea_key = Module.findExportByName(soName, "_Z17jsb_set_xxtea_keyRKNSt6__ndk112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE");
Interceptor.attach(set_xxtea_key,{
onEnter(args){
console.log("xxtea key: ", args[0].readCString())
},
onLeave(retval){

}
})
}


1.hookxxtea_decrypt


function hook_xxtea_decrypt(soName) {
let set_xxtea_key = Module.findExportByName(soName, "xxtea_decrypt");
Interceptor.attach(set_xxtea_key,{
onEnter(args){
console.log("xxtea key: ", args[2].readCString())
},
onLeave(retval){

}
})
}


python加解密脚本


一次性解密output_dir目录下所有.jsc,并在input_dir生成与output_dir同样的目录结构。


# pip install xxtea-py
# pip install jsbeautifier

import xxtea
import gzip
import jsbeautifier
import os

KEY = "abdbe980-786e-45"

input_dir = r"cocos2djs_demoassets" # abs path

output_dir = r"cocos2djs_demooutput" # 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()





jsc文件的2种读取方式


为实现对游戏正常功能的干涉,显然需要修改游戏执行的js腳本。而替换.jsc文件是其中一种思路,前提是要找到读取.jsc文件的地方。


方式一:从apk里读取


我自己编译的Demo就是以这种方式读取/data/app/XXX/base.apkassets目录內的.jsc文件。


cocos引擎默认使用xxtea算法來对.jsc等脚本进行加密,因此读取.jsc的操作定然在xxtea_decrypt之前。


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


浅谈Cocos2djs逆向


向上跟会发现它的bytes数据是由getDataFromFile函数获取


浅谈Cocos2djs逆向


继续跟getDataFromFile的逻辑,它会调用getContents,而getContents里是调用fopen来打开,但奇怪的是hookfopen却没有发现它有打开任何.jsc文件。


浅谈Cocos2djs逆向

浅谈Cocos2djs逆向


后来发现调用的并非FileUtils::getContents,而是FileUtilsAndroid::getContents


它其中一个分支是调用libandroid.soAAsset_read來读取.jsc数据,调用AAssetManager_open来打开.jsc文件。


浅谈Cocos2djs逆向


继续对AAssetManager_open进行深入分析(在线源码),目的是找到能够IO重定向的点:


AAssetManager_open里调用了AssetManager::open函數


// 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);
}


AssetManager::open调用openNonAssetInPathLocked


// frameworks/base/libs/androidfw/AssetManager.cpp
Asset* AssetManager::open(const char* fileName, AccessMode mode)
{
AutoMutex _l(mLock);
LOG_FATAL_IF(mAssetPaths.size() == 0, "No assets added to AssetManager");
String8 assetName(kAssetsRoot);
assetName.appendPath(fileName);

size_t i = mAssetPaths.size();
while (i > 0) {
i--;
ALOGV("Looking for asset '%s' in '%s'n",
assetName.string(), mAssetPaths.itemAt(i).path.string());
// here
Asset* pAsset = openNonAssetInPathLocked(assetName.string(), mode, mAssetPaths.itemAt(i));
if (pAsset != NULL) {
return pAsset != kExcludedAsset ? pAsset : NULL;
}
}

return NULL;
}


AssetManager::openNonAssetInPathLocked先判断assets是位于.gz还是.zip內,而.apk.zip基本等价,因此理应会走else分支。


奇怪的是當我使用frida hook驗證時,能順利hook到`openAssetFromZipLocked`,卻hook不到`getZipFileLocked`,顯然是不合理的。

// frameworks/base/libs/androidfw/AssetManager.cpp
Asset* AssetManager::openNonAssetInPathLocked(const char* fileName, AccessMode mode,
const asset_path& ap)
{
Asset* pAsset = NULL;

if (ap.type == kFileTypeDirectory) {
String8 path(ap.path);
path.appendPath(fileName);

pAsset = openAssetFromFileLocked(path, mode);

if (pAsset == NULL) {
/* try again, this time with ".gz" */
path.append(".gz");
pAsset = openAssetFromFileLocked(path, mode);
}

if (pAsset != NULL) {
//printf("FOUND NA '%s' on diskn", fileName);
pAsset->setAssetSource(path);
}

// run this branch
} else {
String8 path(fileName);
// here
ZipFileRO* pZip = getZipFileLocked(ap);
if (pZip != NULL) {

ZipEntryRO entry = pZip->findEntryByName(path.string());
if (entry != NULL) {

pAsset = openAssetFromZipLocked(pZip, entry, mode, path);
pZip->releaseEntry(entry);
}
}

if (pAsset != NULL) {
pAsset->setAssetSource(
createZipSourceNameLocked(ZipSet::getPathName(ap.path.string()), String8(""),
String8(fileName)));
}
}

return pAsset;
}


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


同樣用frida hook `getZip`,這次成功了,猜測是一些優化移除了`getZipFileLocked`而導致hook 失敗。

// frameworks/base/libs/androidfw/AssetManager.cpp
ZipFileRO* AssetManager::getZipFileLocked(const asset_path& ap)
{
ALOGV("getZipFileLocked() in %pn", this);

return mZipSet.getZip(ap.path);
}


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


// frameworks/base/libs/androidfw/AssetManager.cpp
ZipFileRO* AssetManager::ZipSet::getZip(const String8& path)
{
int idx = getIndex(path);
sp<SharedZip> zip = mZipFile[idx];
if (zip == NULL) {
zip = SharedZip::get(path);
mZipFile.editItemAt(idx) = zip;
}
return zip->getZip();
}

ZipFileRO* AssetManager::SharedZip::getZip()
{
return mZipFile;
}


寻找mZipFile赋值的地方,最终会找到是由ZipFileRO::open(mPath.string())赋值


// frameworks/base/libs/androidfw/AssetManager.cpp
AssetManager::SharedZip::SharedZip(const String8& path, time_t modWhen)
: mPath(path), mZipFile(NULL), mModWhen(modWhen),
mResourceTableAsset(NULL), mResourceTable(NULL)
{
if (kIsDebug) {
ALOGI("Creating SharedZip %p %sn", this, (const char*)mPath);
}
ALOGV("+++ opening zip '%s'n", mPath.string());
// here
mZipFile = ZipFileRO::open(mPath.string());
if (mZipFile == NULL) {
ALOGD("failed to open Zip archive '%s'n", mPath.string());
}
}

從`frameworks/base/libs/androidfw/Android.bp`可知上述代碼的lib文件是`libandroidfw.so`,位於`/system/lib64/`下。將其pull到本地然後用IDA打開,就能根據IDA所示的函數導出名稱/地址對這些函數進行hook。


方式二:从应用的数据目录里读取


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


getDataFromFile -> getContents


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


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


// https://github.com/cocos2d/cocos2d-x/blob/76903dee64046c7bfdba50790be283484b4be271/cocos/platform/android/CCFileUtils-android.cpp
FileUtils::Status FileUtilsAndroid::getContents(const std::string& filename, ResizableBuffer* buffer) const
{
static const std::string apkprefix("assets/");
if (filename.empty())
return FileUtils::Status::NotExists;

string fullPath = fullPathForFilename(filename);

if (fullPath[0] == '/')
// here
return FileUtils::getContents(fullPath, buffer);

// 方式一會走這裡....
}





替換思路


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


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

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

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

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


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


思路一實現


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


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


function hook_ZipFile_open(flag) {
let ZipFile_open = Module.getExportByName("libandroidfw.so", "_ZN7android9ZipFileRO4openEPKc");
console.log("ZipFile_open: ", ZipFile_open)
return Interceptor.attach(ZipFile_open,
{
onEnter: function (args) {
console.log("arg0: ", args[0].readCString());
},
onLeave: function (retval) {

}
}
);
}


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


浅谈Cocos2djs逆向


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


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


浅谈Cocos2djs逆向


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


思路二嘗試(失敗)


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


function hook_xxtea_decrypt() {
Interceptor.attach(Module.findExportByName("libcocos2djs.so", "xxtea_decrypt"), {
onEnter(args) {
let jsc_data = args[0];
let size = args[1].toInt32();
let key = args[2].readCString();
let key_len = args[3].toInt32();
this.arg4 = args[4];

let target_list = [0x15, 0x43, 0x73];
let flag = true;
for (let i = 0; i < target_list.length; i++) {
if (target_list[i] != Memory.readU8(jsc_data.add(i))) {
flag = false;
}
}
this.flag = flag;
if (flag) {
let new_size = size;
let newAddress = Memory.alloc(new_size);
Memory.protect(newAddress, new_size, "rwx")
Memory.protect(args[0], new_size, "rwx")
Memory.writeByteArray(newAddress, jsc_data.readByteArray(new_size))
args[0] = newAddress;
}

},
onLeave(retval) {
}
})

}


思路四實現


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


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


function saveJscode(jscode, path) {
var fopenPtr = Module.findExportByName("libc.so", "fopen");
var fopen = new NativeFunction(fopenPtr, 'pointer', ['pointer', 'pointer']);
var fclosePtr = Module.findExportByName("libc.so", "fclose");
var fclose = new NativeFunction(fclosePtr, 'int', ['pointer']);
var fseekPtr = Module.findExportByName("libc.so", "fseek");
var fseek = new NativeFunction(fseekPtr, 'int', ['pointer', 'int', 'int']);
var ftellPtr = Module.findExportByName("libc.so", "ftell");
var ftell = new NativeFunction(ftellPtr, 'int', ['pointer']);
var freadPtr = Module.findExportByName("libc.so", "fread");
var fread = new NativeFunction(freadPtr, 'int', ['pointer', 'int', 'int', 'pointer']);
var fwritePtr = Module.findExportByName("libc.so", "fwrite");
var fwrite = new NativeFunction(fwritePtr, 'int', ['pointer', 'int', 'int', 'pointer']);

let newPath = Memory.allocUtf8String(path);

let openMode = Memory.allocUtf8String('w');

let str = Memory.allocUtf8String(jscode);

let file = fopen(newPath, openMode);
if (file != null) {
fwrite(str, jscode.length, 1, file)
fclose(file);

}
return null;
}

function hook_evalString() {
Interceptor.attach(Module.findExportByName("libcocos2djs.so", "_ZN2se12ScriptEngine10evalStringEPKclPNS_5ValueES2_"), {
onEnter(args) {
let path = args[4].readCString();
path = path == null ? "" : path;
let jscode = args[1];
let size = args[2].toInt32();
if (path.indexOf("assets/script/index.jsc") != -1) {
saveJscode(jscode.readCString(), "/data/data/XXXXXXX/test.js");
}
}
})
}


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


function findReplaceAddr(startAddr, size, pattern) {
Memory.scan(startAddr, size, pattern, {
onMatch(address, size) {
console.log("target offset: ", ptr(address - startAddr))
return 'stop';
},
onComplete() {
console.log('Memory.scan() complete');
}
});
}

function hook_evalString() {
Interceptor.attach(Module.findExportByName("libcocos2djs.so", "_ZN2se12ScriptEngine10evalStringEPKclPNS_5ValueES2_"), {
onEnter(args) {
let path = args[4].readCString();
path = path == null ? "" : path;
let jscode = args[1];
let size = args[2].toInt32();
if (path.indexOf("assets/script/index.jsc") != -1) {
let pattern = "76 61 72 20 65 20 3D 20 64 2E 50 6C 61 79 65 72 41 74 74 72 69 62 75 74 65 43 6F 6E 66 69 67 2E 67 65 74 44 72 65 61 6D 48 6C 70 65 49 74 65 6D 44 72 6F 70 28 29 2C";
findReplaceAddr(jscode, size, pattern);
}
}
})
}


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


function replaceEvalString(jscode, offset, replaceStr) {
for (let i = 0; i < replaceStr.length; i++) {
Memory.writeU8(jscode.add(offset + i), replaceStr.charCodeAt(i))
}
}

// 例:
function cheatAutoChopTree(jscode) {
let replaceStr = 'true || " "';
replaceEvalString(jscode, 0x3861f6, replaceStr)
}





某砍樹手遊實踐


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


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


浅谈Cocos2djs逆向


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


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


浅谈Cocos2djs逆向





結語


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




浅谈Cocos2djs逆向


看雪ID:ngiokweng

https://bbs.kanxue.com/user-home-946537.htm

*本文为看雪论坛优秀文章,由 ngiokweng 原创,转载请注明来自看雪社区

浅谈Cocos2djs逆向



# 往期推荐

1、Alt-Tab Terminator注册算法逆向

2、恶意木马历险记

3、VMP源码分析:反调试与绕过方法

4、Chrome V8 issue 1486342浅析

5、Cython逆向-语言特性分析


浅谈Cocos2djs逆向


浅谈Cocos2djs逆向

球分享

浅谈Cocos2djs逆向

球点赞

浅谈Cocos2djs逆向

球在看



浅谈Cocos2djs逆向

点击阅读原文查看更多

原文始发于微信公众号(看雪学苑):浅谈Cocos2djs逆向

版权声明:admin 发表于 2024年9月18日 下午6:05。
转载请注明:浅谈Cocos2djs逆向 | CTF导航

相关文章