2021 腾讯游戏安全 安卓初赛
本题难度适中,非常适合入门复现。 目标实现无敌版鼠鼠,效果图:
0x00 识别架构 Unity 逆向,Mono 打包的 apk,找到 Assembly-CSharp.dll,确定为 Mono 类的逆向,关键的业务代码也就在这个 dll 里,然而一看发现不对劲,拉一个正常的对比。
既然是加密了,动态肯定会解密,于是就是了解一下 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 执行 ]
libunity.so 是 Unity 的核心引擎库
libmono.so 是 Mono 的虚拟机核心
libmono.so 激活后就会去找基础库 mscorlib.dll 和业务代码 Assembly-CSharp.dll
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 idaapiimport idautilsimport idcDATA_ADDR = 0x372a8 BSS_DATA_ADDR = 0x39998 KEY_TABLE_REF = 0x34EB7 KEY_TABLE_LEN = 17 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 = idc.get_wide_byte(DATA_ADDR + idx) key2 = idc.get_wide_byte(DATA_ADDR + idx + 1 ) length = key1 ^ key2 if length == 0 : return ("" , 0 , idx + 4 ) decrypted = bytearray () for i in range (length): enc_byte = idc.get_wide_byte(DATA_ADDR + idx + 2 + i) key_idx = (i // KEY_TABLE_LEN) * KEY_TABLE_LEN key_offset = i - key_idx key_byte = idc.get_wide_byte(KEY_TABLE_REF + key_offset) 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 () 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" 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) 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 : 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: 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" ) results = decrypt_all_strings(max_count=100 ) print ("\n[*] 查找 doSome 调用点..." ) patch_comments()
执行完所有字符串所在位置就确定了
0x02 结束了? 再回到frida检测时候弹出的字符串是 hack detected, type:frida,然后就程序就退出了,那么通过该字符串定位到此处,不难就发现了
既然确定了退出的地方,那么其实可以跳过如何检测的逻辑,直接结束本题了。
P.Z的复现碎碎念 但现在毕竟是在复现题目,是为了学到更多东西,我开始去理清整个加载流程,因为还有很多检测加载细节。
所以现在就是分岔口 ,由于已经知道检测后退出的地方,一种选择可以直接去 patch 重签名,直接跟着其他 wp 即可,另一种选择就是去认真审计一下加载的流程,检测的流程,更加仔细感受一下整个题目。
再者我在这题中不想调试,原因也是很多时候其实不好调试,一般都是通过模拟执行或者 Hook 框架来确定一些值,当然这题其实能调,过往我也喜欢调试来确定,但是这题我想锻炼通过审计、模拟执行以及 Hook 框架来处理问题。
我认为看一篇文章最核心的还是思考作者如何想到这一点,往往文章中缺失的一点是作者在写这篇文章的思考过程与试错点,这些过程写起来很麻烦而且因人而异,但我写 write up 会更多的写是怎么思考以及试错后得到信息与解决方法,如果只是这里 patch 一下,那里跑个脚本,其实学不到什么东西,我的整个分析也许做不到足够细,但我想我在这篇文章中尽量把大局整理明白,还有些细节与思考需要读者自行去尝试。
0x03 检测处理 程序加载流程 先开始理清整个加载流程
Zygote 进程:
它是所有 Android 应用的父进程。
当你运行 frida -f 时,Frida 会在 Zygote fork 出子进程的一瞬间注入 frida-agent.so。此时应用的代码还没开始跑。
应用进程启动 (Java 早期):
ActivityThread 启动。
根据 AndroidManifest.xml 实例化 Application 对象,在这题就是 Sec2021Application。
System.loadLibrary(“sec2021”) 触发 (Java 层):
这是在 static 块里执行的,意味着只要 Sec2021Application 类被加载,它就会跑。
System.loadLibrary 内部会调用 Runtime.nativeLoad()。
进入 Native 层 (Linker 阶段):
Runtime.nativeLoad() 最终调用 C 库里的 dlopen(“libsec2021.so”)。
Linker (ld-android.so) 开始工作:
它把 libsec2021.so 映射到内存。
.init_array 执行 :这是 SO 文件里定义的 C++ 全局构造函数。注意:此时 JNI_OnLoad 还没跑! 很多硬核检测(比如检测 Frida 端口、检测断点)会写在这里。
JNI 握手 (Native 层):
Linker 完成后,虚拟机(ART)会寻找并执行 SO 里的 JNI_OnLoad (JavaVM* vm, void* reserved)。
通常在这里进行动态注册(RegisterNatives ),将 Java 层的 native int initialize() 绑定到 Native 层的函数地址。
回到 Java 层 (attachBaseContext ):
SO 加载完毕,static 块执行完。
系统调用 attachBaseContext(Context context)。
**执行 initialize()**:这是分析的 initialize_process 真正被触发的地方。
现在给出每个细节依据,先从 AndroidManifest.xml 找到了 android:name=”com.tencent.games.sec2021.Sec2021Application”,确定了这是apk启动的入口点,在该类中。
先是 System.loadLibrary(“sec2021”),那么运行了 init_array,去 ida 审计发现有两个函数,第一个 init 函数其实就是初始化 method(第二个 init 函数不重要)。
随后去看 JNIOnload,通过 java 层会调用 native 层的 initialize 函数,而且 init_array 会初始化了 methods,不难想象 JNIOnload 里是动态注册该函数的过程。
JNIOnload 执行完后,返回运行到 attachBaseContext 方法,其中执行 initialize naitive 函数。
至此,整个运行流程就明白了,不难猜测检测函数都在 initialize native 函数进行初始化的(毕竟 init_array 与 JNIOnload 无事发生),这里 frida 等 libsec2021.so 加载了 hook 上即可。
intialize 函数 开始审计 initialize 函数,该函数地址在 init_array 初始化 methods 的时候就可以找到是在 5464 地址,这里 coreObj 初始化了一个对象,后面经常使用。
经过初始化后可以发现各种检测函数的注册是在 initialize_process,在函数刚开始的地方,将一些函数注册到了刚刚初始化的对象上,且这些注册函数还上了虚假控制流和控制流平坦化。
显然是里面有好康的但出题人不让康,所以上了不好康的混淆,这里直接猜测里面就是检测 frida ida 这些,因为在上面的小节我们已经把字符串混淆都处理差不多了,没有发现 frida、ida 这些字符串,说明大概率就在这里面了。
接下来也都是一些函数注册到了刚刚的结构体上,手动翻了这些函数,不过逆向的角度来说不太重要,我们关注点是哪里检测的问题,直到翻到 initialize_process 末尾,调用到了之前标记的函数。
这里我的标记链是 alreadyChecked_c -> alreadyChecked_b -> alreadyChecked_a,而 alreadyChecked_a 就是检测到 hack detected, type:%s 退出的地方。
Frida 过检测 刚刚的理清流程、审计函数主要是分析了
理清整个程序加载流程,为了适当的时机 hook
定位检测函数注册流程,思考如何处理 那么现在的目标就是查看哪里还调用了 alreadyChecked_c 这个函数,因为一旦进入该函数说明已经被检测到,然后即将要退出了。
于是可以发现这几个调用点,基本上都可以回溯到 intialize 函数进行了注册,作为逆向可以不进行关注注册细节,我们只需要分析其中一个调用逻辑即可,就拿刚刚 initialize_process 的末尾来举例,我们只需要把 BEQ 直接 patch 成 B,恒跳转到不会失败的分支即可。
同样处理其他几个调用点,写一个 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 ( ) { 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(); } } }); } }); } 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); 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' ); 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);
成功 patch,也不会被检测到退出了!
0x04 整体逻辑梳理 既然检测已过,且该程序的加解密都是落地的,所以基本上接下来没有什么困难的了。
获取 Assembly-CSharp 先去获得正确的 Assembly-CSharp.dll,同样开始文章刚开始的思路,去 hook libmono.so 的 mono_image_open_from_data_with_name 函数。
去看了眼该函数,发现该函数不能正常反编译 (这属于系统函数所以不正常),但也不是花指令之类,所以估计是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 ; } 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" ); 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 { 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 会发现多个跳转,但之后的代码就一样了。
所以直接 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 ) { } }); }
神秘的跳转 但先等等看游戏逻辑,上面分析有些细节没讲
为什么是dump的大小限定在 0x2800?
这个跳转到底是什么,为什么直接判定 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 地址位置。
该函数审计可得
区分目标 dll 和正常 dll
检测环境
回跳 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 *); int dll_0; bool check_MZ; _BYTE *v13; _BYTE *v14; unsigned __int8 *sec2021_png_str; _DWORD *v16; int (__fastcall *v17)(int ); int v18; int len; unsigned __int8 *enc1; unsigned __int8 *enc; int *v23; char key; _DWORD *v25; int (__fastcall *execute_check)(int ); char *s_sharpDll; _QWORD png[2 ]; 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' ; if ( dll_0 == 'M' ) check_MZ = dll_data[1 ] == 'Z' ; if ( check_MZ ) { if ( !name ) goto check_and_load; s_sharpDll = doSome(824 ); 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 ); 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); v23 = (int *)sub_15FF4(); key = init_key(v23); xor_decrypt_data(enc, len, key); 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); free_png((int )png); return v18; }
至此,整理一下目前所得的信息
JNIOnload 动态注册 initialize
Java 层调用 initialize
initialize 里注册了各种检查函数
mono_image_open_from_data_with_name 开头做了一个 hook 跳到 libsec 的函数做了处理 dll
处理 dll 的函数加载了正确的 Assembly-CSharp,使得整个游戏正常运行
0x05 实现破解版 将 dll 拖进 dnSpy,游戏逻辑很简单,直接 nop 掉检测碰到激光的分支即可。
现在要想一下如何处理 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 即可。
然而在重打包的地方有处小坑,猜测是由于 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
没关就是开了!
参考
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