分析

查看lib目錄,發現libil2cpp.solibunity.solibtolua.so,由此可以判斷是Unity + lua的組合。

第一步必然是要將dump.cs搞下來,以下兩種方式都可以:

  1. 常規操作 ( 這樣方式可以獲得更多信息 )
  2. frida-il2cpp-bridge ( 只能dump下來dump.cs )

Untitled

配合dump.cs的信息嘗試trace libil2cpp.so的一些類和方法,但發現具體邏輯應該是調用lua腳本實現的。

嘗試尋找APK目錄下是否存在lua腳本,發現/assests/lua

Untitled

assets目錄下有一些.assetbundle文件,這些是Unity的一些資源打包成assetbundle ( 簡稱ab包 )的形式。

.assetbundle文件開頭是"UnityFS"標誌。 ( 後面會用到這點 )

Untitled

進入lua目錄,也有一堆.assetbundle文件,但是用010Editor來查看會發現與上述正常的.assetbundle文件完全不一致。

因此合理懷疑這些就是被加密打包後的lua腳本。

Untitled

Untitled

解密lua腳本

思路一:hook open

lua.assetbundle等加密打包後的lua腳本,在加載前必然需要解密,理論上也很大機率會調用如open函數來打開文件。

hook libc的open函數,保險起見兩個版本都要hook。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
function test() {
let __open_2 = Module.getExportByName("libc.so", "__open_2"); // __open_2 是 Android NDK 提供的函數, .cpp裡會調用這個
let open = Module.getExportByName("libc.so", "open"); // open是標準的C庫函數, .c裡會調用這個

Interceptor.attach(open,
{
onEnter: function (args) {
let fileName = args[0].readCString();
if(fileName.indexOf("lua.assetbundle") != -1){
console.log("[open] ", fileName);
console.log('[open] called from:\n' +
Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join('\n') + '\n');
}
},
onLeave: function (retval) {
}
}
);

Interceptor.attach(__open_2,
{
onEnter: function (args) {
let fileName = args[0].readCString();
if(fileName.indexOf("lua.assetbundle") != -1){
console.log("[__open_2] ", fileName)
}
},
onLeave: function (retval) {
}
}
)
}

function main(){
test()
}

setImmediate(main)

打印如下,的確是調用open來打開。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[open]  /storage/emulated/0/Android/data/com.wycx.tw/files/tgame_android/lua/lua.assetbundle
[open] called from:
0x782aeeb2dc libil2cpp.so!0x14202dc
0x782aeeb2dc libil2cpp.so!0x14202dc
0x782aeffa14 libil2cpp.so!0x1434a14
0x782a759bd4 libil2cpp.so!0xc8ebd4
0x782a75a1c8 libil2cpp.so!0xc8f1c8
0x782a228e7c libil2cpp.so!0x75de7c
0x782a229aec libil2cpp.so!0x75eaec
0x782a21e7d4 libil2cpp.so!0x7537d4
0x782a21e65c libil2cpp.so!0x75365c
0x782a0d70a0 libil2cpp.so!0x60c0a0
0x782ade6528 libil2cpp.so!0x131b528
0x782a076378 libil2cpp.so!0x5ab378
0x782aee9738 libil2cpp.so!0x141e738
0x782e217d30 libunity.so!0x490d30
0x782e220888 libunity.so!0x499888
0x782e225ff0 libunity.so!0x49eff0

然後就是慢慢分析調用棧,最終會在0x75de7c那一層找到如下十分可疑的地方,

Untitled

hook驗證猜想,在DDUtil__packXor的leave時機打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
let hasHook = false;
let savepath = "/sdcard/dumpLua"

function hook_dlopen(soName, callback) {
Interceptor.attach(Module.findExportByName(null, "dlopen"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
if (path.indexOf(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook && !hasHook) {
console.log("hook start...");
callback();
hasHook = true;
}
}
}
);

Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
if (path.indexOf(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook && !hasHook) {
console.log("hook start...");
callback();
hasHook = true;
}
}
}
);
}

let fileIdx = 0;

function hook_DDUtil__packXor(){
let baseAddr = Module.findBaseAddress("libil2cpp.so")
console.log("base: ", baseAddr)

// DDUtil__packXor
Interceptor.attach(baseAddr.add(0x6224C0),{
onEnter: function(args){
this.args1 = args[1];
this.size = args[2].toUInt32()


},
onLeave: function(retval){
console.log("[DDUtil__packXor] args1: ", hexdump(this.args1));

}
})

}

function onIl2CppLoaded(){
hook_DDUtil__packXor();

console.log("hook success");
}

function main() {
hook_dlopen("libil2cpp.so", onIl2CppLoaded)
}

setImmediate(main)

可以看到明顯的.assetbundle特徵。

Untitled

進一步驗證,先將解密後的.assetbundle文件dump下來

1
2
3
4
5
6
7
8
9
10
11
12
// this.args1是System_Byte_array結構, Process.pointerSize * 4 取其items屬性
let buf = this.args1.add(Process.pointerSize * 4);

let path = savepath + '/' + fileIdx + '.assetbundle';
fileIdx++;
let dexFile = new File(path,"wb");

dexFile.write(Memory.readByteArray(buf, this.size));
dexFile.flush();
dexFile.close();

console.log("decode assetbundle ->", path);

注:像System_Byte_array這樣的結構,可以在il2cpp.h裡查看( 由il2cppdumper dump出來的 )

il2cpp.rar

查看結構後,就能手動計算出具體屬性的內存偏移,然後通過這樣的方式在內存中手動定位this.args1.add(Process.pointerSize * 4)

Untitled

Untitled

使用https://github.com/Perfare/AssetStudio 工具,將dump下來的文件拉入AssertStudio,在Asset List裡查看。

可以看到明文的Lua腳本,右鍵可以直接導出。

這樣就完全可以確定DDUtil__packXor就是解密函數

Untitled

分析DDUtil__packXor邏輯,其實就是簡單的異或解密。

hook System_String__get_Chars看具體異或值是什麼。

Untitled

1
2
3
4
5
6
7
8
9
// 在 hook DDUtil__packXor的 onEnter裡進行如下hook
Interceptor.attach(baseAddr.add(0x9B39D0),{
onEnter: function(args){

},
onLeave: function(retval){
console.log("xor val: ", retval)
}
})

發現是循環異或[0x6b,0x6c,0x77,0x6b,0x6a]這幾個值

如何確定哪個值是第一個?將dump下來的文件與原文件的第一個字節進行異或,會發現是0x6b

Untitled

解密腳本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

import os

xorTable = [0x6b,0x6c,0x77,0x6b,0x6a]
def decrypt(fileName, outputPath):
tidx = 0
with open(fileName, mode="rb") as f:
cipherBytes = f.read()

plainBytes = b""
for byte in cipherBytes:
plainBytes = plainBytes + bytes([byte ^ xorTable[tidx%5]])
tidx = tidx + 1

with open(outputPath, mode="wb") as f:
f.write(plainBytes)



if __name__ == "__main__":
# decrypt("lua.assetbundle")

targetDir = "./lua"
outputDir = "./output"
fileNames = os.listdir(targetDir)

for fileName in fileNames:
if not fileName.endswith(".assetbundle"):
continue

decrypt(f"{targetDir}/{fileName}", f"{outputDir}/{fileName}")
print(f"{targetDir}/{fileName} 解密完成!!")

思路二:利用stringliteral.json

stringliteral.json這是il2cppdumper工具dump出來的,保存了所有字符串常量。

直接搜加密文件的文件名,就能直接定位到這個字符串出現的地址( offset ),從這裡開始分析可以更快找到解密函數。

Untitled

替換Lua腳本

方法一:不落地替換

思路:https://gslab.qq.com/portal.php?mod=view&aid=173

沿著lua引擎加載腳本的函數鏈進行分析,找到Lua腳本的加載時機,目標是在加載前實現替換。

**luaL_loadbuffer**是一個走得比較頻繁的點,嘗試在il2cpp.so裡找,果然發現了該函數。

Untitled

Untitled

以下是dump的腳本,在加載前將要被加載的buff保存下來,看看是否正常。

注:相關數據結構的偏移同上所述是在il2cpp.h裡查看的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function getString(sPtr){
let fields = sPtr.add(Process.pointerSize * 2);
let start_char = fields.add(4).readUtf16String();

return start_char;

}

let fileIdx = 0;
function dumpLua() {
let baseAddr = Module.findBaseAddress("libil2cpp.so")

// luaL_loadbuffer
Interceptor.attach(baseAddr.add(0xD9DC4C),{
onEnter(args){
let max_length = args[2].add(Process.pointerSize * 3).readU32();
let buf = args[2].add(Process.pointerSize * 4);
let name = getString(args[4]);
name = name.replaceAll('/','_')

console.log(name)

let path = savepath + '/' + name;
fileIdx++;
let dexFile = new File(path, "wb");

dexFile.write(Memory.readByteArray(buf, max_length));
dexFile.flush();
dexFile.close();

console.log("lua ->",path);
},
onLeave(retval){

}
})

}

dump出來的東西有2種,一是像如下這樣的數據:

Untitled

另一種是Lua腳本:

Untitled

如此一來便確定了是正常的( 即是明文,無需再解密 )。

替換lua的邏輯如下,參考了這位大佬的博客。

注:APP要有讀取/sdcard的權限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
function getReplaceData(path, origBuff){
/**
* struct Il2CppObject
{
Il2CppClass *klass;
void *monitor;
};

struct System_Byte_array {
Il2CppObject obj;
Il2CppArrayBounds *bounds;
il2cpp_array_size_t max_length;
uint8_t m_Items[65535];
};
*/
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']);

let newLuaPath = Memory.allocUtf8String(path);

let openMode = Memory.allocUtf8String('rb');
let file = fopen(newLuaPath, openMode);
if (file != null) {
// 獲取newBuffer的大小
fseek(file, 0, 2);
let newSize = ftell(file);
// reset
fseek(file, 0, 0);

let newBuffer = Memory.alloc(newSize + 1 + Process.pointerSize * 4);
// 先將原buff的前Process.pointerSize * 4字節copy到newBuffer ( 具體原因看System_Byte_array結構 )
newBuffer.writeByteArray(origBuff.readByteArray(Process.pointerSize * 4))
// 將我們修改後的lua腳本寫入newBuffer
fread(newBuffer.add(Process.pointerSize * 4), newSize, 1, file);

fclose(file);

return {
"buff": newBuffer,
"size": newSize
}

}
return null;
}

function hookLuaLoad() {
let baseAddr = Module.findBaseAddress("libil2cpp.so")
let sleep = new NativeFunction(Module.getExportByName(null, "sleep"), "void", ["int"]);

let luaL_loadbuffer_addr = baseAddr.add(0xD9DC4C);
let luaL_loadbuffer = new NativeFunction(luaL_loadbuffer_addr, "int", ["pointer", "int64", "pointer", "int", "pointer", "pointer"])

Interceptor.replace(luaL_loadbuffer_addr, new NativeCallback(function(thiz, luaState, buff, size, name, method){

let cName = getString(name);

if(cName.indexOf("JingJieLevelDef") != -1){
console.log(cName);
let rData = getReplaceData('/sdcard/tmp/Table_JingJieLevelDef.lua', buff)
buff = rData["buff"]
size = rData["size"]

}

if(cName.indexOf("@UI/Main/MainPanel") != -1){
console.log(cName);
let rData = getReplaceData('/sdcard/tmp/@UI_Main_MainPanel', buff)
buff = rData["buff"]
size = rData["size"]

}

if(cName.indexOf("@UI/Main/DuJiePanel") != -1){
console.log(cName);
let rData = getReplaceData('/sdcard/tmp/@UI_Main_DuJiePanel', buff)
buff = rData["buff"]
size = rData["size"]

}

if(cName.indexOf("LunHuiPanel") != -1){
console.log(cName);
let rData = getReplaceData('/sdcard/tmp/@UI_LiLian_LunHuiPanel', buff)
buff = rData["buff"]
size = rData["size"]

}

if(cName.indexOf("PlayerLevelMgr") != -1){
console.log(cName);
let rData = getReplaceData('/sdcard/tmp/@UI_Manager_PlayerLevelMgr', buff)
buff = rData["buff"]
size = rData["size"]

}

if(cName.indexOf("Network") != -1){
console.log(cName);
let rData = getReplaceData('/sdcard/tmp/@Logic_Network', buff)
buff = rData["buff"]
size = rData["size"]

}

return luaL_loadbuffer(thiz, luaState, buff, size, name, method);

}, "int", ["pointer", "int64", "pointer", "int", "pointer", "pointer"]))

}

能成功替換lua腳本後,嘗試修改遊戲的【渡劫】邏輯,目標是不需要消耗經驗就可以直接渡劫。

通過在dump出的lua腳本中不斷搜索相關的字符串,最終定位到以下lua函數,易知有網路請求( socket ),因此只在本地修改純粹是在搞笑…

Untitled

只能通過分析協議 & 攔截Socket通信的方式才可能實現修改,有機會再嘗試下……

Untitled

方法二:落地替換

參考這篇文章:https://blog.csdn.net/linxinfa/article/details/122390621

利用Unity Addressables來將lua腳本打包成.assetbundle,然後加密打包後的.assetbundle文件,再重打包進AP裡。

結果最後會進不去遊戲的主界面,進度條卡死在外面…

注:lua腳本的後綴要是.bytes才能順利打包

參考/更多資料

frida-il2cpp-bridge:

lua:

assetbundle: