0%

BlueArchive国服逆向

我爱玩碧蓝档案,鏖战了两三天,可以把frida打进去了,平心而论我觉得技术含量还是非常高的,对抗强度最高的一次,学到的非常多,这里记录一下。

起点

和之前il2cpp分析的一样,要想拿到静态的资源表、结构体、函数签名之类的,就需要通过il2cpp中的meta_init,il2cpp dump的原理就是这样,meta_init需要通过global-metadata.dat来获取全局的字符串信息,然后把C#转译出来的Cpp跑起来,这个转移出来的CPP本身就有一定的混淆效果,像是写代码时用到的字符串之类的信息都会被转储到global-metadata.dat中。(这一段准备工作,网上有大量的资料和工具)

而BA的global-metadat是加密了的(通过魔术头和熵值就能分析出来)、直接上手il2cpp.so去分析,也找不到解密的逻辑在哪里,最离谱的是我拿着Unity的源码找特征字符串引用、也是一无所获,找不到关键的逻辑,这就是第一步难关了。

合理地怀疑应该是libtprt.so,一搜发现旧的处理逻辑还有别人的分析,最终发现确实啊 global-metadata.dat是交由这个函数库解密后,提供给il2cpp使用的,接下来的操作就比较骚了(我都想笑了):

  • 看见别人分析(25年的帖子、不是同一款游戏),有一些特征字符串,我顺着去在libtprt里面搜,发现真能找到对应的函数。神奇的是BA的global-metadata.dat的魔术头和他分析的版本一样,虽然内容不同。

  • 在他帖子里看见,,他用了frida去做各种调试,在我这里是不行的,我没法动态调(这个下文会说BA的动态反作弊、难度真的非常大),最重要的是漏了一个mmap函数,我一想觉得就非常合理啊、载入静态资源分配内存,然后对内存做处理,提供给后续使用。

  • 而这个函数在我这边一搜索发现:引用数量不多,可以分析!随手找了下真撞见了:有个很明显的魔术头匹配逻辑,最重要的是没多少混淆,往下追就是解密的逻辑,流程:识别魔术头→走解密分支→再做头部抹除,抄了一下解密的逻辑如下(其实也就是AES解密、密钥都写在里面):

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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import struct
from pathlib import Path

try:
from Crypto.Cipher import AES # pip install pycryptodome
except Exception as e:
raise SystemExit("Need pycryptodome: pip install pycryptodome") from e


MAGIC = b"\x94\x43\x72\x12" # 0x12724394 (LE)
ARG3_CONST = 0xD96603C0
IV_16 = bytes(range(0x02, 0x12)) # 02..11

# sub_5670f8 checks these 32-bit signed fields are < file_size
CHECK_OFFSETS = [
0x08, 0x0C, 0x10, 0x14, 0x18, 0x1C, 0x20, 0x24, 0x28, 0x2C, 0x30, 0x34,
0x58, 0x5C, 0x60, 0x64, 0x68, 0x6C,
0x88, 0x8C, 0x90, 0x94, 0x98, 0x9C, 0xA0, 0xA4,
]


def xor_region(buf: bytearray, start: int, length: int, key_byte: int) -> None:
end = min(len(buf), start + max(0, length))
for i in range(max(0, start), end):
buf[i] ^= key_byte


def check_offsets(buf: bytes) -> bool:
n = len(buf)
for off in CHECK_OFFSETS:
if off + 4 > n:
return False
v = struct.unpack_from("<i", buf, off)[0] # signed int32
if v >= n:
return False
return True


def transform_0x4000_area(buf: bytearray, aes_key_16: bytes, use_decrypt: bool) -> None:
# sub_5537f8(mode=2): 0x1000..0x4FFF, split into 0x800 chunks
base = 0x1000
total = 0x4000
if len(buf) < base:
return
real_total = min(total, len(buf) - base)
pos = 0
while pos < real_total:
chunk_len = min(0x800, real_total - pos)
# AES-CBC needs block aligned; this region should be aligned in sample logic
if chunk_len % 16 != 0:
break
st = base + pos
ed = st + chunk_len
chunk = bytes(buf[st:ed])

cipher = AES.new(aes_key_16, AES.MODE_CBC, iv=IV_16)
out = cipher.decrypt(chunk) if use_decrypt else cipher.encrypt(chunk)
buf[st:ed] = out
pos += 0x800


def core_decrypt_try(src: bytes, use_decrypt_for_aes: bool) -> bytes:
b = bytearray(src)
size = len(b)

key_byte = (ARG3_CONST >> 16) & 0xFF
if key_byte == 0:
key_byte = 0x87

if size >= 0x100000:
# 1) XOR [8, 0x1000)
xor_region(b, 8, 0x1000 - 8, key_byte)

# 2) key = "%08x%08x" % (size, 0xD96603C0)
aes_key = f"{size:08x}{ARG3_CONST:08x}".encode("ascii") # 16 bytes
transform_0x4000_area(b, aes_key, use_decrypt_for_aes)

# 3) XOR from 0x11000 onward:
# each full 0x10000 block: XOR first 0x4000
# then XOR remaining tail bytes continuously
rem_total = size - 0x11000
if rem_total > 0:
full = rem_total // 0x10000
for blk in range(full):
start = 0x11000 + blk * 0x10000
xor_region(b, start, 0x4000, key_byte)

done = full << 16
tail = rem_total - done
if tail > 0:
xor_region(b, 0x11000 + done, tail, key_byte)
else:
# small path: XOR [8, size)
xor_region(b, 8, size - 8, key_byte)

return bytes(b)


def decrypt_like_sub_5674cc(src: bytes) -> tuple[bytes, str]:
if len(src) < 8:
raise ValueError("file too small")
if src[:4] != MAGIC:
raise ValueError("magic mismatch (not encrypted target format)")

# Try both AES directions; pick one that passes sub_5670f8-style checks.
for mode in (True, False): # True=decrypt, False=encrypt
out = core_decrypt_try(src, use_decrypt_for_aes=mode)
if check_offsets(out):
# header wipe exactly as binary:
# [0..3]=0, [5..7]=0, keep byte[4]
out_b = bytearray(out)
out_b[0:4] = b"\x00\x00\x00\x00"
out_b[5:8] = b"\x00\x00\x00"
return bytes(out_b), ("aes-decrypt" if mode else "aes-encrypt")

raise ValueError("no AES mode passed offset checks")


def main():
ap = argparse.ArgumentParser()
ap.add_argument("input", type=Path)
ap.add_argument("-o", "--output", type=Path, default=None)
args = ap.parse_args()

data = args.input.read_bytes()
dec, mode = decrypt_like_sub_5674cc(data)

out = args.output or args.input.with_suffix(args.input.suffix + ".dec")
out.write_bytes(dec)
print(f"[+] ok -> {out} ({mode})")


if __name__ == "__main__":
main()

魔术头这个:

解密后:

往下一翻就是各种明文字符串,至此也就正式迈出了第一步,不过试了下il2cpp dump是不支持这个版本的unity还是怎么样不知道,我感觉il2cpp dump其实也没那么好用,因为导出的符号表、再倒回IDA里面非常非常慢,大型游戏一堆资源、我又不一定每个函数都看,就不太喜欢那个方案。

所以这里还有第二种办法:Zygisk il2cpp dump,Github搜搜就有,从内存中dump下来,结果是一样的:

虽然我觉得拿到这些静态符号一样都是没卵用,里面可能也有些有意思的东西吧,接下来就是注入、调试。

开日

要日就必须调试、要调试就必须动态、要动态就必然需要通过某些工具、某些手段来调试、这些都是痕迹,反作弊就是阻止工具的使用、拖慢逆向进度就行,工具都附加不上、手动调的话得调到天荒地老,我对frida比较熟就一定要用frida!

前段子分析过别人的作弊手段,其实漏了不少的东西,例如他对libc还做了一点手脚,通过dobbyhook来inlinehook的内容之类的,应该是用来过掉保护的,我写了个Zygisk模块来仿造那个功能,不过策略更加激进一点:我想要把frida-gadget打入游戏内部爽调,像这些静态资源表对我而言其实没有Stalker好用。

首先就是注入Frida啊、肯定失败了。翻看libtprt.so里面又有一堆混淆,CFF、CFG、FLA一堆,梦到什么往里加什么混淆,继续翻看前辈们的日法,自己也写了个去控制流平坦化的,不过只能去CFF,后面两种我还没研究明白。
原理:CFF依赖分发块来执行不同的真实块逻辑、然后真实块尾部回到分发块继续跑下一个真实块,而分发依赖的就是某个寄存器变量,通过binary ninja + unidbg可以看的非常清楚。

所以处理也挺简单的,选定分发器的寄存器变量遍历所有真实块,再通过真实块尾部下一个跳转的区域判定下一个连接的真实块就行。脚本、看以后再说要不要放出来吧,写得很乱,我都不想看第二遍。

处理部分之后全部跑了一遍函数,才发现的CFG、FLA之类的一堆混淆,就摆了、直接硬来吧,结果发现真行:

  • 入手点:Frida检测、首先我通过Frida-sever的办法去注入Frida-agent挂掉了,第一时间想到的就是检测127.0.0.1、/data/local/tmp这种特征嘛:

  • 一找一堆,有些混淆我是去过了的,有些是没有,不过不影响:

噩梦の起点

看过前几篇博客的话,针对Frida、我看了源码,里面的特征比想象中要多得多,一个一个去改、专门适配这个tprt,我觉得根本就不现实:

只有Frida-gadget的改动量能够接受、不过还是会因为大大小小未知的问题导致无法使用的,今天是以后也肯定会是。

也去尝试了自己改源码编译,后来还是不行,差点就放弃了。他比想象中的问题还要多得多,一定要hook libart、一定要通过libc来初始化,还有几个线程名是在依赖库里面起的,改完源码得把依赖库的源码下载下来编译一轮,再重新编译frida,因为Stalker功能太好用、付出点代价是正常的。

正常人都会选择自己手动来调试更加方便,但我偏不哈哈、后来干脆换了个思路:ZygiskIl2cpp能起作用?ZygiskFrida难道不行?但一样失败了。

写到这里有点困了,有空再继续写吧。总之下半部分都是围绕:攻守互换、我来检测你的检测来的、写了个行为分析ptrace+secommap来专门针对安卓反调试检测,太长了一时半会说不完,有空再继续写。