簡單聊一下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.apk裡assets目錄內的.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.so的AAsset_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數據。
經測試後發現只有1、3、4是可行的,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的源碼吧^^
import xxtea
import gzip
import jsbeautifier
import os
KEY = "abdbe980-786e-45"
input_dir = r"cocos2djs_demo\assets"
output_dir = r"cocos2djs_demo\output"
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):
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()
import xxtea
import gzip
import jsbeautifier
import os
KEY = "abdbe980-786e-45"
input_dir = r"cocos2djs_demo\assets"
output_dir = r"cocos2djs_demo\output"
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):
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()
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);
Asset* asset = mgr->open(filename, amMode);
if (asset == NULL) {
return NULL;
}
return new AAsset(asset);
}
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);
Asset* asset = mgr->open(filename, amMode);
if (asset == NULL) {
return NULL;
}
[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!
最后于 2024-9-23 10:05
被ngiokweng编辑
,原因: no.