February 7, 2026

2021 腾讯游戏安全 安卓初赛

2021 腾讯游戏安全 安卓初赛

本题难度适中,非常适合入门复现。
目标实现无敌版鼠鼠,效果图:img


0x00 识别架构

Unity 逆向,Mono 打包的 apk,找到 Assembly-CSharp.dll,确定为 Mono 类的逆向,关键的业务代码也就在这个 dll 里,然而一看发现不对劲,拉一个正常的对比。

img

既然是加密了,动态肯定会解密,于是就是了解一下 Unity 其 Mono 打包成 apk 的加载流程。

1
2
3
4
5
6
7
8
9
[ 安卓系统 (Android OS) ]

[ Unity 引擎层 (libunity.so) ]

[ Mono 虚拟机层 (libmono.so) ] <--- 你可以 Hook 这里

[ 托管代码层 (Assembly-CSharp.dll) ] <--- 你可以直接反编译这里

[ JIT 编译器 ] -> [ 生成内存中的 ARM 指令 ] -> [ CPU 执行 ]

0x01 字符串混淆处理

于是去查看 libmono.so 哪里动了手脚,尝试通过 frida hook mono_image_open_from_data_with_name,因为该函数是加载 Assembly-Csharp.dll 的函数。

1
2
3
4
var libbase = Module.findBaseAddress("libmono.so");  
console.log("libbase", libbase);
var addr = Module.findExportByName("libmono.so", "mono_image_open_from_data_with_name");
console.log("mono_image_open_from_data_with_name", addr);

但是有frida检测,似乎无法直接到达加载该dll的时机,所以现在去解决frida检测。

先看了 init_array 看看反调试是不是写这了,然后发现 0x1F120 的函数,显然是动态解密一些数据的函数,不管是调试还是模拟执行都行,不过通过 LLM 辅助解析,写个 idapython 脚本,该函数就是传一个常量,返回解密后的数据到 bss 数据段。

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
============================================================
doSome 字符串解密器 v1.1
============================================================

[*] 批量解密模式...
============================================================
doSome 字符串解密结果
============================================================
[ 28] @ 0x372c4: "Author: saitexie walterjxli"
[ 59] @ 0x372e3: "Do you know how unity mono works?"
[ 96] @ 0x37308: "res/drawable-xhdpi-v4/ -> assets/bin/Data/Managed/"
[ 150] @ 0x3733e: "initialize"
[ 164] @ 0x3734c: "()I"
[ 171] @ 0x37353: "com/tencent/games/sec2021/Sec2021Application"
[ 219] @ 0x37383: "com/tencent/games/sec2021/Sec2021IPC"
[ 259] @ 0x373ab: "hack detected, risk score:%d"
[ 291] @ 0x373cb: "getApplicationInfo"
[ 313] @ 0x373e1: "()Landroid/content/pm/ApplicationInfo;"
[ 355] @ 0x3740b: "getFilesDir"
[ 370] @ 0x3741a: "()Ljava/io/File;"
[ 390] @ 0x3742e: "sourceDir"
[ 403] @ 0x3743b: "packageName"
[ 418] @ 0x3744a: "nativeLibraryDir"
[ 438] @ 0x3745e: "getAbsolutePath"
[ 457] @ 0x37471: "/proc/self/status"
[ 478] @ 0x37486: "TracerPid:"
[ 492] @ 0x37494: "diediedie"
[ 505] @ 0x374a1: "/proc/self/maps"
[ 524] @ 0x374b4: "rb"
[ 530] @ 0x374ba: "delete"
[ 540] @ 0x374c4: "%zx-%zx %c%c%c%c %x %x:%x %u %s"
[ 575] @ 0x374e7: "android/os/Debug"
[ 595] @ 0x374fb: "isDebuggerConnected"
[ 618] @ 0x37512: "sec2021"
[ 629] @ 0x3751d: "getClass"
[ 641] @ 0x37529: "getName"
[ 652] @ 0x37534: "getSuperclass"
[ 669] @ 0x37545: "android/app/Application"
[ 696] @ 0x37560: "java/lang/Class"
[ 715] @ 0x37573: "()Ljava/lang/Class;"
[ 738] @ 0x3758a: "()Landroid/content/pm/ApplicationInfo;"
[ 780] @ 0x375b4: "()Ljava/io/File;"
[ 800] @ 0x375c8: "()Ljava/lang/String;"
[ 824] @ 0x375e0: "Assembly-CSharp.dll"
[ 847] @ 0x375f7: "Mono.Security.dll"
[ 868] @ 0x3760c: "mscorlib.dll"
[ 884] @ 0x3761c: "System.Core.dll"
[ 903] @ 0x3762f: "System.dll"
[ 917] @ 0x3763d: "UnityEngine.dll"
[ 936] @ 0x37650: "UnityEngine.Networking.dll"
[ 966] @ 0x3766e: "UnityEngine.PlaymodeTestsRunner.dll"
[1005] @ 0x37695: "UnityEngine.UI.dll"
[1027] @ 0x376ab: "base.apk"
[1039] @ 0x376b7: "android/content/Context"
[1066] @ 0x376d2: "()Ljava/lang/ClassLoader;"
[1095] @ 0x376ef: "()Ljava/lang/String;"
[1119] @ 0x37707: "zip file"
[1131] @ 0x37713: "libsec2021.so"
[1148] @ 0x37724: "%s/%s"
[1157] @ 0x3772d: "dalvik.system.PathClassLoader"
[1190] @ 0x3774e: "toString"
[1202] @ 0x3775a: "getClassLoader"
[1220] @ 0x3776c: "Ljava/lang/String;"
[1242] @ 0x37782: "%s%s"
[1250] @ 0x3778a: "/app/data/libbugly/crash.info"
[1283] @ 0x377ab: "can you crack me?"
[1304] @ 0x377c0: "__optional__"
[1320] @ 0x377d0: "cc/binmt/signature/PmsHookApplication"
[1361] @ 0x377f9: "com/cloudinject/feature/App"
[1392] @ 0x37818: "np/manager/FuckSign"
[1415] @ 0x3782f: "java/lang/ClassLoader"
[1440] @ 0x37848: "findClass"
[1453] @ 0x37855: "(Ljava/lang/String;)Ljava/lang/Class;"
[1494] @ 0x3787e: "getPackageManager"
[1515] @ 0x37893: "()Landroid/content/pm/PackageManager;"
[1556] @ 0x378bc: "getPackageName"
[1574] @ 0x378ce: "getPackageInfo"
[1592] @ 0x378e0: "(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;"
[1649] @ 0x37919: "signatures"
[1663] @ 0x37927: "[Landroid/content/pm/Signature;"
[1698] @ 0x3794a: "toByteArray"
[1713] @ 0x37959: "()[B"
[1721] @ 0x37961: "java/io/ByteArrayInputStream"
[1753] @ 0x37981: "<init>"
[1763] @ 0x3798b: "([B)V"
[1772] @ 0x37994: "java/security/cert/CertificateFactory"
[1813] @ 0x379bd: "getInstance"
[1828] @ 0x379cc: "(Ljava/lang/String;)Ljava/security/cert/CertificateFactory;"
[1891] @ 0x37a0b: "X.509"
[1900] @ 0x37a14: "generateCertificate"
[1923] @ 0x37a2b: "(Ljava/io/InputStream;)Ljava/security/cert/Certificate;"
[1982] @ 0x37a66: "getEncoded"
[1996] @ 0x37a74: "java/security/MessageDigest"
============================================================
共解密 85 个字符串
============================================================

同时写一段注释到每个字符串所在位置,完整脚本如下。

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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""
doSome 字符串解密脚本 - IDAPython
分析结论:
- data 数组: 0x372a8 (加密数据源)
- bss_data: 0x39998 (解密结果存储)
- key_table: unk_34EB7 (密钥指针), 使用负偏移访问

解密算法 (从汇编分析):
1. data[idx] = key1 (异或密钥, 存储在R8)
2. data[idx+1] = key2
3. length = key1 ^ key2
4. 对于每个字节 i:
- 计算 key_idx = (i / 17) * 17 (使用UMULL + LSR实现)
- key_byte = unk_34EB7[-key_idx] (负偏移, 并且R1在循环中递增)
- decrypted[i] = data[idx+2+i] ^ key1 ^ key_byte

关键汇编 (0x1F26C - 0x1F298):
UMULL R4, R6, R0, R7 ; R7 = 0xF0F0F0F1, 计算 i / 17
...
MOV R6, R6, LSR#4 ; R6 = i / 17
ADD R6, R6, R6, LSL#4 ; R6 = (i/17) * 17
LDRB R6, [R1, -R6] ; R1 = unk_34EB7, 取 unk_34EB7[-R6]
ADD R1, R1, #1 ; R1 递增

实际效果: key_table[i % 17], 从 unk_34EB7 开始, 顺序访问
"""

import idaapi
import idautils
import idc

# ============== 配置区 ==============
DATA_ADDR = 0x372a8 # data 数组基址
BSS_DATA_ADDR = 0x39998 # bss_data 数组基址
KEY_TABLE_REF = 0x34EB7 # unk_34EB7 - 密钥表引用点
KEY_TABLE_LEN = 17 # 密钥表长度 (0x11)

# ============== 辅助函数 ==============
def get_bytes_at(addr, size):
"""从IDB读取字节"""
return bytes([idc.get_wide_byte(addr + i) for i in range(size)])


def decrypt_string(idx):
"""
解密单个字符串 - 完全模拟汇编逻辑
@param idx: data数组中的起始索引 (传递给doSome的参数)
@return: (解密后的字符串, 原始长度, 下一个索引)

汇编逻辑模拟:
R1 初始化为 unk_34EB7
循环中:
key_idx = (i / 17) * 17
key_byte = R1[-key_idx], 然后 R1++

等价于: key_byte = unk_34EB7[i] - unk_34EB7[(i/17)*17] = unk_34EB7[i % 17]
"""
# 读取 key1 (data_171/R8) 和 key2
key1 = idc.get_wide_byte(DATA_ADDR + idx) # data[idx]
key2 = idc.get_wide_byte(DATA_ADDR + idx + 1) # data[idx+1]

# 计算字符串长度: v9 = v8 ^ data_171
length = key1 ^ key2

if length == 0:
return ("", 0, idx + 4) # 空字符串,跳过header + checksum

# 解密循环 - 精确模拟汇编
# R1 = unk_34EB7, 每次迭代 R1++
# key_idx = (i / 17) * 17
# key_byte = R1[-key_idx] = unk_34EB7[i - key_idx] = unk_34EB7[i % 17]

decrypted = bytearray()
for i in range(length):
enc_byte = idc.get_wide_byte(DATA_ADDR + idx + 2 + i)

# 模拟: UMULL R4, R6, R0, R7 (R7=0xF0F0F0F1) + LSR#4 + ADD
# 这是 ARM 除以 17 的优化: i / 17
key_idx = (i // KEY_TABLE_LEN) * KEY_TABLE_LEN

# LDRB R6, [R1, -R6] 其中 R1 = unk_34EB7 + i
# 所以实际是: unk_34EB7[i - key_idx] = unk_34EB7[i % 17]
key_offset = i - key_idx # = i % 17
key_byte = idc.get_wide_byte(KEY_TABLE_REF + key_offset)

# decrypted = enc ^ key1 ^ key_byte
dec_byte = enc_byte ^ key1 ^ key_byte
decrypted.append(dec_byte)

# 尝试解码为字符串
try:
result = decrypted.decode('utf-8')
except:
try:
result = decrypted.decode('latin-1')
except:
result = decrypted.hex()

# 下一个索引 = 当前索引 + 2(header) + length + 2(checksum + terminator)
next_idx = idx + 2 + length + 2

return (result, length, next_idx)


def calc_checksum(data_bytes):
"""计算XOR校验和"""
checksum = 0xFF
for b in data_bytes:
checksum ^= b
return checksum & 0xFF


def decrypt_all_strings(max_count=100, max_offset=2000):
"""
批量解密所有字符串
@param max_count: 最大解密数量
@param max_offset: 最大偏移量限制
"""
results = []
idx = 0
count = 0

print("=" * 60)
print("doSome 字符串解密结果")
print("=" * 60)

while count < max_count and idx < max_offset:
try:
decrypted, length, next_idx = decrypt_string(idx)

if length > 0 and length < 256: # 合理的字符串长度
# 过滤掉不可打印的垃圾数据
if decrypted and all(c.isprintable() or c in '\r\n\t' for c in decrypted):
results.append({
'index': idx,
'data_addr': hex(DATA_ADDR + idx),
'length': length,
'decrypted': decrypted
})
print(f"[{idx:4d}] @ {DATA_ADDR + idx:#x}: \"{decrypted}\"")
count += 1

# 移动到下一个条目
if next_idx <= idx:
idx += 1 # 防止死循环
else:
idx = next_idx

except Exception as e:
print(f"Error at idx {idx}: {e}")
idx += 1

print("=" * 60)
print(f"共解密 {len(results)} 个字符串")
print("=" * 60)

return results


def decrypt_by_param(param_value):
"""
根据 doSome 的参数值解密
这是最精确的方式,直接模拟 doSome(param) 的行为
@param param_value: 传递给 doSome 的参数值
"""
decrypted, length, _ = decrypt_string(param_value)
print(f"doSome({param_value}) = \"{decrypted}\" (len={length})")
return decrypted


def find_doSome_calls():
"""
查找所有调用 doSome 的位置并提取参数
"""
dosome_addr = idc.get_name_ea_simple("doSome")
if dosome_addr == idc.BADADDR:
print("未找到 doSome 函数")
return []

print(f"doSome 函数地址: {dosome_addr:#x}")
print("-" * 60)


calls = []
for xref in idautils.XrefsTo(dosome_addr):
caller_addr = xref.frm
caller_func = idaapi.get_func(caller_addr)

if caller_func:
func_name = idc.get_func_name(caller_func.start_ea)
else:
func_name = "unknown"

# 尝试获取参数值 (ARM/Thumb): 往前查找设置 R0 的指令
# 优化策略: 往前查找 10 条指令,根据指令长度自动回溯
param = None
curr_search_addr = caller_addr
for _ in range(10):
curr_search_addr = idc.prev_head(curr_search_addr)
if curr_search_addr == idc.BADADDR:
break

mnem = idc.print_insn_mnem(curr_search_addr)
# 特征识别:
# 1. MOV R0, #imm / MOVS R0, #imm
# 2. MOVW R0, #imm
# 3. LDR R0, [PC, #offset] (常量池加载)
if mnem in ('MOV', 'MOVS', 'MOVW', 'LDR'):
if idc.get_operand_type(curr_search_addr, 0) == idc.o_reg and idc.get_operand_value(curr_search_addr, 0) == 0: # 0 = R0
op_type = idc.get_operand_type(curr_search_addr, 1)
if op_type in (idc.o_imm, idc.o_mem):
op_value = idc.get_operand_value(curr_search_addr, 1)
if op_value != idc.BADADDR:
# 如果是 LDR R0, [PC, #off],get_operand_value 会直接返回常量池里的值
param = op_value
break

if param is not None:
try:
decrypted, length, _ = decrypt_string(param)
calls.append({
'call_addr': caller_addr,
'func_name': func_name,
'param': param,
'decrypted': decrypted
})
print(f"{caller_addr:#x} in {func_name}: doSome({param}) = \"{decrypted}\"")
except:
print(f"{caller_addr:#x} in {func_name}: doSome({param}) = <解密失败>")
else:
calls.append({
'call_addr': caller_addr,
'func_name': func_name,
'param': None,
'decrypted': None
})
print(f"{caller_addr:#x} in {func_name}: doSome(?) = <参数未知>")

return calls


def patch_comments():
"""
在所有 doSome 调用点添加解密后的注释
"""
calls = find_doSome_calls()

for call in calls:
if call['decrypted']:
# 添加注释
comment = f"doSome({call['param']}) = \"{call['decrypted']}\""
idc.set_cmt(call['call_addr'], comment, 0)
print(f"已添加注释 @ {call['call_addr']:#x}: {comment}")

print(f"\n共添加 {sum(1 for c in calls if c['decrypted'])} 个注释")


# ============== 主入口 ==============
if __name__ == "__main__":
print("\n" + "=" * 60)
print("doSome 字符串解密器 v1.1")
print("=" * 60 + "\n")


# 方式1: 批量解密所有字符串
# print("[*] 批量解密模式...")
results = decrypt_all_strings(max_count=100)


# 方式2: 查找调用点并解密
print("\n[*] 查找 doSome 调用点...")
# find_doSome_calls()


# 方式3: 添加注释到IDB
patch_comments()


# 方式4: 单个解密测试
# decrypt_by_param(0)
# decrypt_by_param(100)

执行完所有字符串所在位置就确定了
img


0x02 结束了?

再回到frida检测时候弹出的字符串是 hack detected, type:frida,然后就程序就退出了,那么通过该字符串定位到此处,不难就发现了

img

既然确定了退出的地方,那么其实可以跳过如何检测的逻辑,直接结束本题了。

P.Z的复现碎碎念

但现在毕竟是在复现题目,是为了学到更多东西,我开始去理清整个加载流程,因为还有很多检测加载细节。

所以现在就是分岔口,由于已经知道检测后退出的地方,一种选择可以直接去 patch 重签名,直接跟着其他 wp 即可,另一种选择就是去认真审计一下加载的流程,检测的流程,更加仔细感受一下整个题目。

再者我在这题中不想调试,原因也是很多时候其实不好调试,一般都是通过模拟执行或者 Hook 框架来确定一些值,当然这题其实能调,过往我也喜欢调试来确定,但是这题我想锻炼通过审计、模拟执行以及 Hook 框架来处理问题。

我认为看一篇文章最核心的还是思考作者如何想到这一点,往往文章中缺失的一点是作者在写这篇文章的思考过程与试错点,这些过程写起来很麻烦而且因人而异,但我写 write up 会更多的写是怎么思考以及试错后得到信息与解决方法,如果只是这里 patch 一下,那里跑个脚本,其实学不到什么东西,我的整个分析也许做不到足够细,但我想我在这篇文章中尽量把大局整理明白,还有些细节与思考需要读者自行去尝试。


0x03 检测处理

程序加载流程

先开始理清整个加载流程

  1. Zygote 进程:

    • 它是所有 Android 应用的父进程。
    • 当你运行 frida -f 时,Frida 会在 Zygote fork 出子进程的一瞬间注入 frida-agent.so。此时应用的代码还没开始跑。
  2. 应用进程启动 (Java 早期):

    • ActivityThread 启动。
    • 根据 AndroidManifest.xml 实例化 Application 对象,在这题就是 Sec2021Application。
  3. System.loadLibrary(“sec2021”) 触发 (Java 层):

    • 这是在 static 块里执行的,意味着只要 Sec2021Application 类被加载,它就会跑。
    • System.loadLibrary 内部会调用 Runtime.nativeLoad()。
  4. 进入 Native 层 (Linker 阶段):

    • Runtime.nativeLoad() 最终调用 C 库里的 dlopen(“libsec2021.so”)。
    • Linker (ld-android.so) 开始工作:
    • 它把 libsec2021.so 映射到内存。
    • .init_array 执行:这是 SO 文件里定义的 C++ 全局构造函数。注意:此时 JNI_OnLoad 还没跑! 很多硬核检测(比如检测 Frida 端口、检测断点)会写在这里。
  5. JNI 握手 (Native 层):

    • Linker 完成后,虚拟机(ART)会寻找并执行 SO 里的 JNI_OnLoad(JavaVM* vm, void* reserved)。
    • 通常在这里进行动态注册(RegisterNatives),将 Java 层的 native int initialize() 绑定到 Native 层的函数地址。
  6. 回到 Java 层 (attachBaseContext):

    • SO 加载完毕,static 块执行完。
    • 系统调用 attachBaseContext(Context context)。
    • **执行 initialize()**:这是分析的 initialize_process 真正被触发的地方。

现在给出每个细节依据,先从 AndroidManifest.xml 找到了
android:name=”com.tencent.games.sec2021.Sec2021Application”,确定了这是apk启动的入口点,在该类中。

img

先是 System.loadLibrary(“sec2021”),那么运行了 init_array,去 ida 审计发现有两个函数,第一个 init 函数其实就是初始化 method(第二个 init 函数不重要)。

img

随后去看 JNIOnload,通过 java 层会调用 native 层的 initialize 函数,而且 init_array 会初始化了 methods,不难想象 JNIOnload 里是动态注册该函数的过程。

img

JNIOnload 执行完后,返回运行到 attachBaseContext 方法,其中执行 initialize naitive 函数。

至此,整个运行流程就明白了,不难猜测检测函数都在 initialize native 函数进行初始化的(毕竟 init_array 与 JNIOnload 无事发生),这里 frida 等 libsec2021.so 加载了 hook 上即可。

intialize 函数

开始审计 initialize 函数,该函数地址在 init_array 初始化 methods 的时候就可以找到是在 5464 地址,这里 coreObj 初始化了一个对象,后面经常使用。

img

经过初始化后可以发现各种检测函数的注册是在 initialize_process,在函数刚开始的地方,将一些函数注册到了刚刚初始化的对象上,且这些注册函数还上了虚假控制流和控制流平坦化。

img

显然是里面有好康的但出题人不让康,所以上了不好康的混淆,这里直接猜测里面就是检测 frida ida 这些,因为在上面的小节我们已经把字符串混淆都处理差不多了,没有发现 frida、ida 这些字符串,说明大概率就在这里面了。

接下来也都是一些函数注册到了刚刚的结构体上,手动翻了这些函数,不过逆向的角度来说不太重要,我们关注点是哪里检测的问题,直到翻到 initialize_process 末尾,调用到了之前标记的函数。

img

这里我的标记链是 alreadyChecked_c -> alreadyChecked_b -> alreadyChecked_a,而 alreadyChecked_a 就是检测到 hack detected, type:%s 退出的地方。

Frida 过检测

刚刚的理清流程、审计函数主要是分析了

  1. 理清整个程序加载流程,为了适当的时机 hook
  2. 定位检测函数注册流程,思考如何处理
    那么现在的目标就是查看哪里还调用了 alreadyChecked_c 这个函数,因为一旦进入该函数说明已经被检测到,然后即将要退出了。

img

于是可以发现这几个调用点,基本上都可以回溯到 intialize 函数进行了注册,作为逆向可以不进行关注注册细节,我们只需要分析其中一个调用逻辑即可,就拿刚刚 initialize_process 的末尾来举例,我们只需要把 BEQ 直接 patch 成 B,恒跳转到不会失败的分支即可。

img

同样处理其他几个调用点,写一个 frida 脚本(frida版本 16.1.4,Android 10,Pixel 3XL)

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
var g_libsec_hooked = false;


function hook_dlopen() {
// Hook 两个加载函数,因为不同 Android 版本/场景使用不同的函数
var dlopen_funcs = ["dlopen", "android_dlopen_ext"];

dlopen_funcs.forEach(function(func_name) {
var addr = Module.findExportByName(null, func_name);
if (addr != null) {
console.log("[*] Hooking " + func_name + " at", addr);
Interceptor.attach(addr, {
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
console.log("[" + func_name + "]", path);
this.path = path;
}
},
onLeave: function (retval) {
if (this.path && this.path.indexOf("libsec2021.so") >= 0) {
console.log("[!] libsec2021.so loaded via " + func_name);
hook_sec2021();
}
}
});
}
});
}


/**
* Patch initialize_process 中的安全检查分支
* 将条件跳转 (BEQ/BNE 等) 改为无条件跳转 (B)
* 效果:无论检查函数返回什么,都跳过失败处理逻辑
*/
function hook_sec2021() {
if (g_libsec_hooked) {
console.log("[*] libsec2021.so already hooked, skipping");
return;
}

var libbase = Module.findBaseAddress("libsec2021.so");
if (libbase == null) {
console.log("[-] libsec2021.so not found");
return;
}

g_libsec_hooked = true;
console.log("[+] libsec2021.so base:", libbase);

// 需要 patch 的地址列表
// 将条件跳转指令的条件码改为 0xEA (Always),使其变为无条件跳转
var patch_offsets = [
0x1CFA4,
0x1D064,
0x20918,
0x20930,
0x2070C,
0x20A4C
];

patch_offsets.forEach(function(patch_offset) {
var patch_addr = libbase.add(patch_offset);

console.log("[*] Patching at offset 0x" + patch_offset.toString(16) + " -> ", patch_addr);

// 读取原始字节确认
var original_bytes = patch_addr.readByteArray(4);
console.log("[*] Original bytes:", hexdump(original_bytes, { length: 4, header: false }));

// 修改内存保护并写入
Memory.protect(patch_addr, 4, 'rwx');

// 将条件码改为 EA (Always)
// ARM 指令是小端序,条件码在最高字节 (offset +3)
patch_addr.add(3).writeU8(0xEA);

// 验证修改
var patched_bytes = patch_addr.readByteArray(4);
console.log("[+] Patched bytes:", hexdump(patched_bytes, { length: 4, header: false }));
});

console.log("[+] All " + patch_offsets.length + " security check bypasses applied!");
}


function main() {
console.log("[*] Script loaded, waiting for libsec2021.so...");
hook_dlopen();
}


setImmediate(main);
// frida -U -f com.personal.rocketmouse -l sec2021.js

成功 patch,也不会被检测到退出了!

img


0x04 整体逻辑梳理

既然检测已过,且该程序的加解密都是落地的,所以基本上接下来没有什么困难的了。

获取 Assembly-CSharp

先去获得正确的 Assembly-CSharp.dll,同样开始文章刚开始的思路,去 hook libmono.so 的 mono_image_open_from_data_with_name 函数。

img

去看了眼该函数,发现该函数不能正常反编译(这属于系统函数所以不正常),但也不是花指令之类,所以估计是sec里对mono还进行了修改(从之前字符串解密的的text与libmono也有感觉)。

但具体如何修改mono其实也不用在意,只需要找一个合适的时机 dump 出 mono 即可看到 libsec 是修改后的 mono 了,也就是只要落地,那么不管咋加密都能 dump 出来,毕竟 frida 检测都过了。

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
function dumpSo(soName) {
var lib = Module.findBaseAddress(soName);
if (lib == null) {
console.error("找不到该 SO: " + soName);
return;
}

// 1. 获取模块详情
var module = Process.getModuleByName(soName);
console.log("正在 Dump " + soName + " 基址: " + module.base + " 大小: " + module.size);

var currentApplication = Java.use("android.app.ActivityThread").currentApplication();
var dir = currentApplication.getApplicationContext().getFilesDir().getPath();
var fileName = dir + "/dumpmemory.bin";
var file = new File(fileName, "wb");

// 3. 遍历所有内存分页,只读取属于该 SO 且有读取权限的部分
Process.enumerateRanges('r--').forEach(function (range) {
if (range.base.compare(module.base) >= 0 &&
range.base.compare(module.base.add(module.size)) < 0) {

console.log("正在读取段: " + range.base + " 权限: " + range.protection);

try {
// 使用 readByteArray 之前可以先验证一次权限
var buf = range.base.readByteArray(range.size);
file.write(buf);
} catch (e) {
console.warn("跳过不可读段: " + range.base);
}
}
});

file.flush();
file.close();
console.log("Dump 完成!文件保存在: " + fileName);
}

dump 出来(可以拿SoFixer修一下,但只是看看函数也可以不修),如果对比正常的 libmono.so 会发现多个跳转,但之后的代码就一样了。

img

所以直接 hook sub_190C74,再根据函数原型。

1
2
3
4
5
6
7
8
MonoImage* mono_image_open_from_data_with_name(
char *data, // args[0]: 指向内存中 DLL 数据的指针
uint32_t data_len, // args[1]: DLL 数据的字节长度
mono_bool need_copy, // args[2]: 是否需要 Mono 内部拷贝这份数据
MonoImageOpenStatus *status, // args[3]: 返回加载状态的指针(用于错误排查)
mono_bool refonly, // args[4]: 是否以仅反射模式打开
const char *name // args[5]: 关键!DLL 的名称或路径
);

正常的 hook dump Assembly-CSharp.dll 出来即可。

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
function dump_memory(base, size) {  
Java.perform(function () {
var currentApplication = Java.use("android.app.ActivityThread").currentApplication();
var dir = currentApplication.getApplicationContext().getFilesDir().getPath();
var file_path = dir + "/dumpDLL.bin";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
Memory.protect(ptr(base), size.toInt32(), 'rwx');
var libso_buffer = ptr(base).readByteArray(size.toInt32());
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump] ", file_path);
}
});
}


function hook_mono() {
var libbase = Module.findBaseAddress("libmono.so");
console.log("libbase", libbase);
var addr = Module.findExportByName("libmono.so", "mono_image_open_from_data_with_name");
console.log("mono_image_open_from_data_with_name", addr);

Interceptor.attach(Module.findExportByName("libmono.so", "mono_image_open_from_data_with_name").add(4), {
onEnter: function (args) {
var data = args[0];
var data_len = args[1];
if (data_len == 0x2800) {
dump_memory(data, data_len);
}
console.log("mono_image_open_from_data_with_name_ori() called!", data, data_len);
},
onLeave: function (retval) {
}
});
}

神秘的跳转

但先等等看游戏逻辑,上面分析有些细节没讲

  1. 为什么是dump的大小限定在 0x2800?
  2. 这个跳转到底是什么,为什么直接判定 hook +4的位置就可以?
    先再来复述一下游戏运行流程
1
2
3
4
5
6
7
8
9
[ 安卓系统 (Android OS) ]

[ Unity 引擎层 (libunity.so) ]

[ Mono 虚拟机层 (libmono.so) ] <--- 你可以 Hook 这里

[ 托管代码层 (Assembly-CSharp.dll) ] <--- 你可以直接反编译这里

[ JIT 编译器 ] -> [ 生成内存中的 ARM 指令 ] -> [ CPU 执行 ]

真相是该流程下 libmono.so 在正常调用到 mono_image_open_from_data_with_name 时候,做了一个跳转到 sec2021(可以 frida 看参数或者调试确定),处理过 dll 再跳回正常执行。

其实也是收回伏笔了,一开始我们找到的 Assembly-CSharp 静态是给加密的,所以正常读取肯定会做过处理后再使用,具体就是跳到了 0001CEE0 地址位置。

该函数审计可得

  1. 区分目标 dll 和正常 dll
  2. 检测环境
  3. 回跳 mono_image_open_from_data_with_name
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
int __fastcall hook_mono_image(unsigned __int8 *dll_data, int a2, int a3, int a4, int a5, char *name)
{
int (__fastcall *org_mono_image)(unsigned __int8 *, int, int, int, int, char *); // r8
int dll_0; // r0
bool check_MZ; // zf
_BYTE *v13; // r0
_BYTE *v14; // r4
unsigned __int8 *sec2021_png_str; // r0
_DWORD *v16; // r0
int (__fastcall *v17)(int); // r0
int v18; // r4
int len; // r4
unsigned __int8 *enc1; // r0
unsigned __int8 *enc; // r5
int *v23; // r0
char key; // r0
_DWORD *v25; // r0
int (__fastcall *execute_check)(int); // r0
char *s_sharpDll; // r0
_QWORD png[2]; // [sp+8h] [bp-30h] BYREF

org_mono_image = (int (__fastcall *)(unsigned __int8 *, int, int, int, int, char *))g_sec2021_o_array;
dll_0 = *dll_data;
check_MZ = dll_0 == 'M'; // 前两位检测MZ
if ( dll_0 == 'M' ) // Check PE Header (MZ)
check_MZ = dll_data[1] == 'Z';
if ( check_MZ ) // 前两位是MZ
{
if ( !name )
goto check_and_load;

s_sharpDll = doSome(824); // Get string "Assembly-CSharp.dll"
if ( !strcasestr(name, s_sharpDll) )
goto check_and_load;
}


v13 = (_BYTE *)*((_DWORD *)getGloabCtx() + 11);
v14 = &unk_327A8;
if ( v13 )
v14 = v13;
if ( !*v14 )
goto check_and_load;


init(png);
sec2021_png_str = (unsigned __int8 *)doSome(2137);// Get resource path "res/drawable-xhdpi-v4/sec2021.png"
if ( sub_6D48((int)v14, sec2021_png_str, (int)png)
|| (len = may_cal_len((int)png) - 0x410B, (enc1 = (unsigned __int8 *)malloc(len)) == 0) )
{
free_png((int)png);
check_and_load:
v16 = (_DWORD *)sub_18B00();


if ( sub_18CEC(v16, (unsigned __int8 *)name, a2, dll_data) )// 检测
{


v17 = alreadyChecked_c();


v17(2048);
}


return org_mono_image(dll_data, a2, a3, a4, a5, name);
}

enc = enc1;
qmemcpy(enc1, (const void *)(sub_722C(png) + 0x410B), len);// Copy encrypted DLL data from resource (offset 16651)
v23 = (int *)sub_15FF4();
key = init_key(v23);
xor_decrypt_data(enc, len, key); // Decrypt the DLL data

v25 = (_DWORD *)sub_18B00();


if ( sub_18CEC(v25, (unsigned __int8 *)name, len, enc) )// 检测
{


execute_check = alreadyChecked_c();


execute_check(2048);
}


v18 = org_mono_image(enc, len, a3, a4, a5, name);// Call original loader with replaced DLL
free_png((int)png);
return v18;
}

至此,整理一下目前所得的信息

  1. JNIOnload 动态注册 initialize
  2. Java 层调用 initialize
  3. initialize 里注册了各种检查函数
  4. mono_image_open_from_data_with_name 开头做了一个 hook 跳到 libsec 的函数做了处理 dll
  5. 处理 dll 的函数加载了正确的 Assembly-CSharp,使得整个游戏正常运行

0x05 实现破解版

将 dll 拖进 dnSpy,游戏逻辑很简单,直接 nop 掉检测碰到激光的分支即可。

img

现在要想一下如何处理 libsec 了,首先是之前在 frida 修改的检测跳转,手动去把 BEQ 改成 B 就可以。

1
2
3
4
5
6
7
8
var patch_offsets = [
0x1CFA4,
0x1D064,
0x20918,
0x20930,
0x2070C,
0x20A4C
];

检测干掉了还差一处地方,也就是 mono_image_open_from_data_with_name 入口的跳转,是特别处理了开头不是 MZ 的 dll,由于我们将 patch 好的 Assembly-CSharp,且已经实现了解密,所以不需要走 libsec 中解密的流程。

这里直接将过了 MZ 检测的 dll 直接去加载 dll,不去做解密操作了,同样是把这里的 BEQ 改成 B 即可。

img

然而在重打包的地方有处小坑,猜测是由于 apktool b 重打包的时候会尝试编译所有的 XML、resoureces.arsc 重新生成资源索引表,导致 libsec 读取某些资源的偏移量出问题了。

其实具体的检测函数我没在文中细写(分析的 i64 看附件),实际上很多检测函数要读 assets 或是其他内容进行 crc 校验等,估计是在这个过程如果用 apktool 会导致无法对应内容从而用 apktool 重打包会导致运行失败。(当然这是猜测,如果有师傅有其他理解欢迎讨论)

于是我们就直接用压缩软件直接打开 apk,不去解压,将目标 Assembly-CSharp 和 libsec2021 换成我们 patch 好的,再重新签名。

1
java -jar uber-apk-signer-1.3.0.jar --apks RocketMouse.apk

没关就是开了!

img

参考

https://blog.xhyeax.com/2021/04/04/gslab2021-pre-android/
https://www.52pojie.cn/forum.php?extra=page%3d1%26filter%3ddigest%26orderby%3dviews%26typeid%3d342&mod=viewthread&tid=1420775
https://linkleyping.top/gslab2021-pre/#app%E5%88%86%E6%9E%90

About this Post

This post is written by P.Z, licensed under CC BY-NC 4.0.