首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
逆向工程
发新帖
11
11
[原创]某校园题材二游逆向记录
发表于: 2026-5-30 16:42
6341
[原创]某校园题材二游逆向记录
Searchstars
2026-5-30 16:42
6341
# 某校园题材二游逆向记录 受朋友委托,对某二游进行逆向工程。 经了解,目标游戏使用Unity引擎开发,同时登录PC、Android、iOS平台。这里为了方便绕过反作弊和反调试,选用Android端作为逆向对象。 ## 初步分析 针对客户端进行解包,可以看到非常熟悉的Unity il2cpp打包结构: <img src="upload/attach/202605/1009406_5SJV83BQXMC88HW.webp" width="300" /> <br/> <img src="upload/attach/202605/1009406_G875PUY5QXQXFG9.webp" width="300" /> 对于这种情况,不必多说,先尝试直接使用il2cppdumper开干: <img src="upload/attach/202605/1009406_NPBFR5XS7H3DZ25.webp" width="700" /> 不过很显然事情并不会这么简单,意料之中地报错了,从报错信息中可以得到,`libil2cpp.so`本身应该不存在保护,问题主要集中在`global-metadata.dat`。分析错误可知崩溃点在MapVATR读CodeRegistration/MetadataRegistration的数组时抛EndOfStreamException,这基本是非常明显的il2cpp元数据结构魔改特征。 不过也不必先急于对魔改后的结构进行完整逆向,我们可以使用容错能力更强的cpp2il在加载StrippedCodeRegSupport插件的情况下跑diffable-cs和isildump得到结构和机器码: <img src="upload/attach/202605/1009406_P9WFA3GT9JKJZFN.webp" width="300" /> <br/> <img src="upload/attach/202605/1009406_ZCMPKSSAMDNXJQ5.webp" width="300" /> ## metadata usage token布局分析 虽然cpp2il已经能跑出diffable-cs和isildump,但il2cppdumper本身仍然无法正常解析。既然崩溃点和metadata usage解析强相关,那就继续顺着相关调用往下看。 先照重定位表随手还原几个metadata usage slot,再顺着读对应位置的内容,可以拿到一些看起来像metadata usage token的值。照常见il2cpp解法来解token: ```c kind = token >> 29; index = token & 0x1FFFFFFF; ``` 结果明显异常,有的index直接越界,有的虽然没越界,但解出来也是无关文本: 可以判断这次魔改动的就是token的编码方式,跟通用公式对不上。 继续分析相关汇编,可以很容易发现相关位置在赋值前会先调`0x6353FAC`去解这些metadata usage,那就直接跟进该函数: <img src="upload/attach/202605/1009406_BSFQ53Y6MY5T8XJ.webp" width="500" /> 这层只是个跳板,顺着`sub_6363BF4`的尾调用进到`sub_63E2AA0`,关键逻辑就在这里: ```c unsigned __int64 __fastcall sub_63E2AA0(unsigned __int64 *a1, char a2, int a3, int a4, int a5, int a6, int a7, int a8) { unsigned __int64 v9; // x20 __int64 v10; // x21 __int64 v11; // x0 unsigned int *v12; // x20 int *v13; // x10 unsigned __int64 v14; // x0 unsigned __int64 *v15; // x9 unsigned __int64 v16; // x8 int v17; // w9 unsigned int *v18; // x20 __int64 v19; // x0 int v21; // [xsp+0h] [xbp-30h] char v22[8]; // [xsp+8h] [xbp-28h] BYREF int v23[2]; // [xsp+10h] [xbp-20h] void *v24; // [xsp+18h] [xbp-18h] do v9 = __ldaxr(a1); while ( __stlxr(v9, a1) ); __dmb(0xBu); if ( (v9 & 1) != 0 ) { v10 = ((unsigned int)v9 >> 1) & 0xFFFFFFF; switch ( (unsigned int)v9 >> 29 ) { case 1u: v11 = sub_63E2C60((unsigned int)v10, a2 & 1); goto LABEL_17; case 2u: v9 = *(_QWORD *)(*(_QWORD *)(qword_FDCE5E8 + 56) + 8LL * (unsigned int)v10); if ( !v9 ) return v9; goto LABEL_19; case 3u: case 6u: v11 = sub_63E2DA8((unsigned int)v9); goto LABEL_17; case 4u: v12 = (unsigned int *)(qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 184) + 8LL * (unsigned int)v10); v9 = *(_QWORD *)(sub_63E2C60(*v12, 1) + 128) + 32LL * (int)v12[1]; if ( !v9 ) return v9; goto LABEL_19; case 5u: v9 = *(_QWORD *)(qword_FDCE628 + 8LL * (unsigned int)v10); if ( v9 ) goto LABEL_18; v13 = (int *)(qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 8) + 8 * v10); v14 = il2cpp_string_new_len_0( (int)qword_FDCE5F0 + *(_DWORD *)(qword_FDCE5F8 + 16) + v13[1], *v13, a3, a4, a5, a6, a7, a8, v21, v22[0], v23[0], v24); v9 = v14; v15 = (unsigned __int64 *)(qword_FDCE628 + 8 * v10); break; case 7u: v18 = (unsigned int *)(qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 184) + 8LL * (unsigned int)v10); v19 = sub_63E2C60(*v18, 1); v11 = sub_63E2DF4(*(_QWORD *)(v19 + 128) + 32LL * (int)v18[1], v22); LABEL_17: v9 = v11; LABEL_18: if ( v9 ) goto LABEL_19; return v9; default: return 0; } while ( 1 ) { v16 = __ldaxr(v15); if ( v16 ) break; if ( !__stlxr(v14, v15) ) { v17 = 1; goto LABEL_22; } } __clrex(); v17 = 0; LABEL_22: __dmb(0xBu); if ( v17 ) { sub_63D38A4(qword_FDCE628 + 8 * v10); if ( !v9 ) return v9; } else { v9 = v16; if ( !v16 ) return v9; } LABEL_19: __dmb(0xBu); *a1 = v9; } return v9; } ``` 剥出关键逻辑: ```c // 最低位为0:已解析真实指针,直接返回 if ((token & 1) == 0) return token; // 最低位为1:待解析token kind = (token >> 29) & 0x7; index = (token >> 1) & 0x0FFFFFFF; switch (kind) { case 1: // 类型/类引用 resolved = sub_63E2C60(index, a2 & 1); break; case 2: // 全局缓存表里直接取 resolved = *(_QWORD *)(*(_QWORD *)(qword_FDCE5E8 + 56) + 8 * index); break; case 3: case 6: // 走特殊解析分支 resolved = sub_63E2DA8(token); break; case 4: // 表里定位类型+成员索引,再算槽位 entry = qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 184) + 8 * index; klass = sub_63E2C60(*(uint32_t *)entry, 1); resolved = *(_QWORD *)(klass + 128) + 32 * *(int *)(entry + 4); break; case 5: // 字符串字面量,带CAS缓存 resolved = qword_FDCE628[index]; if (!resolved) { entry = qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 8) + 8 * index; new_string = il2cpp_string_new_len_0( qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 16) + entry->offset, entry->length ); // CAS抢写,避免多线程重复覆盖 if (atomic_compare_exchange(&qword_FDCE628[index], 0, new_string)) { resolved = new_string; sub_63D38A4(&qword_FDCE628[index]); } else { resolved = qword_FDCE628[index]; } } break; case 7: // 在case 4基础上再解一层成员 entry = qword_FDCE5F0 + *(int *)(qword_FDCE5F8 + 184) + 8 * index; klass = sub_63E2C60(*(uint32_t *)entry, 1); resolved = sub_63E2DF4(*(_QWORD *)(klass + 128) + 32 * *(int *)(entry + 4), tmp); break; default: return 0; } if (resolved) *a1 = resolved; // 回写真实指针,对应开头直接返回 return resolved; ``` 可见真正的token布局是这样: ```c kind = (token >> 29) & 0x7; index = (token >> 1) & 0x0FFFFFFF; ``` 可得与原版il2cpp的显著差异:最低位被单独拿去当状态标志(标记是否已解析),index得先右移掉这一位再取28位。套通用公式时这个flag混进了index,自然要么越界、要么解出无关字符串。 ## 修改il2cppdumper以适配魔改布局 让我们先来解决EndOfStreamException崩溃。根据调用链下断点可得,il2cppdumper根据version = 31来处理metadata文件,而metadata的文件头也确实是这样描述的。但问题在于,游戏实际使用的Unity版本为2021.3.56,其对应il2cpp版本为27,不得不说这招确实有点阴。 修改`Il2CppDumper/Il2Cpp/Il2CppClass.cs`中的`class Il2CppCodeRegistration`,直接锁定版本来修复: ```diff [Version(Min = 22)] public ulong unresolvedVirtualCallPointers; - [Version(Min = 29.1)] + [Version(Min = 31.1)] // [MX-FIX#1] Yostar 伪造版本号; 实际 v27 结构无此字段 public ulong unresolvedInstanceCallPointers; - [Version(Min = 29.1)] + [Version(Min = 31.1)] public ulong unresolvedStaticCallPointers; ``` 接着,我们还需要绕过自动搜索,允许强制指定注册结构地址。因为结构体布局对上之后,`FindCodeRegistration2019`的自动搜索在魔改后的metadata中必定会得到错误结果。在这里我们直接干掉自动搜索,手动填地址: `Il2CppDumper/Config.cs`: ```diff public bool NoRedirectedPointer { get; set; } = false; + public string ForceCodeRegistration { get; set; } = ""; + public string ForceMetadataRegistration { get; set; } = ""; ``` `Il2CppDumper/Program.cs`中 在`Console.WriteLine("Searching...")` 后,`PlusSearch`前: ```diff - var flag = il2Cpp.PlusSearch(...); + bool flag; + if (!string.IsNullOrEmpty(config.ForceCodeRegistration) && + !string.IsNullOrEmpty(config.ForceMetadataRegistration)) + { + var cr = Convert.ToUInt64(config.ForceCodeRegistration, 16); + var mr = Convert.ToUInt64(config.ForceMetadataRegistration, 16); + Console.WriteLine($"[MX] Forcing CodeRegistration=0x{cr:x}, MetadataRegistration=0x{mr:x}"); + il2Cpp.Init(cr, mr); + flag = true; + } + else + flag = il2Cpp.PlusSearch(...); ``` 针对当前样本,使用如下地址常量: - `ForceCodeRegistration = "ee1c1a0"` - `ForceMetadataRegistration = "f410418"` 地址常量求解大致思路: - **MetadataRegistration**:在数据段里找合适的结构,如`typeDefinitionsSizesCount`、`typesCount`,且每个指针字段都挂着`R_AARCH64_RELATIVE`重定位,对得上即可。 - **CodeRegistration**:先用`codeGenModulesCount == imageCount`(镜像数,本样本 108=0x6c)这条线锁定那对 `(count, ptr)`,再从这个 `count` 字段往前数 13 个 8 字节就是结构体起点(这套布局在 codeGenModules 之前一共 14 项指针/计数)即可求得。 附对应脚本: ```python #!/usr/bin/env python3 import sys, struct from elftools.elf.elffile import ELFFile SO = sys.argv[1] if len(sys.argv) > 1 else "extracted/arm64/lib/arm64-v8a/libil2cpp.so" META = sys.argv[2] if len(sys.argv) > 2 else "extracted/base/assets/bin/Data/Managed/Metadata/global-metadata.dat" data = open(SO, "rb").read() rel = {} segs = [] with open(SO, "rb") as f: elf = ELFFile(f) for s in elf.iter_sections(): if s.name == ".rela.dyn": for r in s.iter_relocations(): if r["r_info_type"] == 1027: rel[r["r_offset"]] = r["r_addend"] f.seek(0) elf = ELFFile(f) for s in elf.iter_segments(): if s.header.p_type == "PT_LOAD": segs.append((s.header.p_vaddr, s.header.p_offset, s.header.p_filesz, s.header.p_memsz)) def in_file(va): for vaddr, off, fsz, msz in segs: if vaddr <= va < vaddr + fsz: return True return False def is_ptr(va): return va in rel def num(va): return struct.unpack_from("<Q", data, va)[0] md = open(META, "rb").read() imagesSize = struct.unpack_from("<I", md, 0xac)[0] IMAGE_COUNT = imagesSize // 0x28 # sizeof(Il2CppImageDefinition) v29/31 = 0x28 print(f"[i] imagesSize=0x{imagesSize:x} -> imageCount={IMAGE_COUNT}\n") def find_codereg(): cands = {} for va in range(segs[2][0], segs[2][0] + segs[2][2] - 16, 8): # RW Data Part c = num(va) if c == IMAGE_COUNT and is_ptr(va + 8): start = va + 8 - 14*8 if start < segs[2][0]: continue if (not is_ptr(start)) and is_ptr(start + 8): # (count, ptr) rev = num(start) if 0 <= rev <= 100000: cands.setdefault(c, []).append(start) return cands def find_metareg(): best = [] for va in range(segs[2][0], segs[2][0] + segs[2][2] - 0x80, 8): ok = True for i in range(7): cva = va + i*16 if is_ptr(cva) or not is_ptr(cva + 8): ok = False; break if num(cva) == 0 or num(cva) > 4_000_000: ok = False; break if ok: term = va + 7*16 if not is_ptr(term) and num(term) == 0: best.append(va) return best print("=== CodeRegistration ===") for c, starts in sorted(find_codereg().items()): for st in starts: print(f" imageCount={c}(0x{c:x}) -> CodeRegistration = 0x{st:x}") print("\n=== MetadataRegistration ===") for va in find_metareg()[:10]: print(f" MetadataRegistration = 0x{va:x} (genericClassesCount={num(va)})") ``` 随后再跑修改后的il2cppdumper,即可得到熟悉的东西: <img src="upload/attach/202605/1009406_J8VAXWQG6TBFRG8.webp" width="300" /> 接下来可继续使用IDA或Ghidra等工具载入libil2cpp.so,使用il2cppdumper附带的脚本根据`script.json`和`il2cpp.h`给反编译结果上符号。 至此,完整的游戏逻辑已经跟裸奔没有任何本质区别。 ## 资源文件解密 在写这里的时候我的IDA还在对libil2cpp.so进行分析,我当然不想再等一个小时两个小时的,所以下面的内容主要先根据ISIL来解。 ### 资源路径与压缩包形态 简单预览`il2cpp_dump_mx/stringliteral.json`,能看到一组成套出现的资源路径常量: ```text "/TableBundles/" "/MediaResources/" "/Android_PatchPack/catalog_Android.zip" "/Catalog/TableCatalog.bytes" "/Catalog/MediaCatalog.bytes" "(\/catalog_.+)\.zip" ".zip" ``` 这基本已经把资源分成了几类:TableBundles、MediaResources、PatchPack里的Addressables,以及对应的catalog。 接着全量搜索`ZipInputStream`和`CreatePassword`的调用点,可以看到主要命中三处: - `BundleLogicDataReader`:读取TableBundles - `MediaService`:读取MediaResources - `ResourcePatcher.DownloadCatalog`:读取Addressables清单 其中前两者在创建`ZipInputStream`后都会设置`Password`,后者不会。 整体管线大致如下: <img src="upload/attach/202605/1009406_4SS3ZJRVGPSWAQP.webp" width="700" /> `TableCatalog`和`MediaCatalog`本身是MemoryPack,从我们修改适配后的il2cppdumper的dump结果`il2cpp_dump_mx/dump.cs`中也能证明: ```csharp public class TableCatalog : IMemoryPackable<TableCatalog> public class MediaCatalog : IMemoryPackable<MediaCatalog> ``` 资源根`{root}`则来自登录dispatcher下发的`ServerInfoData.ConnectionGroups[].AddressablesCatalogUrlRoot` ### TableBundles解包 易得读取点在: ```text cpp2il_isil/IsilDump/BlueArchive/MX/AssetBundles/BundleLogicDataReader.txt BundleLogicDataReader.ReadBinary(System.String zippedFilePath) ``` 关键ISIL: ```text 060 Call BundleLogicDataReader.GetBundleNameFromRelativePath, X0, X1 ; X22 = 包名 bundleName 064 Call BundleLogicDataReader.GetTablePath, X0, X1 ; X19 = 本地 zip 路径 076 Call File.OpenRead, X0 ; 打开 zip 文件流 087 Call ZipInputStream..ctor, X0, X1 ; new ZipInputStream(fileStream) 094 Move W1, 20 ; length = 20 095 Move X0, X22 ; arg = bundleName 097 Call TableService.CreatePassword, X0, X1 ; pwd = CreatePassword(bundleName, 20) 100 Move [X0+128], X1 ; zipStream.Password = pwd 105 Call ZipInputStream.GetNextEntry, X0 113 Call String.ToLower …116 op_Inequality ; 找名字匹配的条目 128 Call ZipEntry.get_Size … 读出 entry 字节 ``` 可以知道它是SharpZipLib的带口令ZIP MediaResources同理: ```text cpp2il_isil/IsilDump/BlueArchive/Media/Service/MediaService.txt ``` 对应ISIL: ```text 101 Call ZipInputStream..ctor 129 Call TableService.CreatePassword 132 Move [X0+128], X1 ; .Password = pwd 140 Call ZipInputStream.GetNextEntry ``` 后面多处可见类似逻辑,由此可知,MediaResources同样复用`TableService.CreatePassword`这套口令机制。 反过来看Addressables清单zip,位置在: ```text cpp2il_isil/IsilDump/BlueArchive/MX/AssetBundles/ResourcePatcher_NestedType__DownloadCatalog_d__150.txt ``` 这里的逻辑是: ```text 196 Call ZipInputStream..ctor 203 Call ZipInputStream.GetNextEntry ``` 整个方法里没有`CreatePassword`调用。也就是说,`catalog_Android.zip`只是普通zip容器,不带口令。 ### 口令算法 跟进`TableService.CreatePassword`: ```text cpp2il_isil/IsilDump/BlueArchive/TableService.txt TableService.CreatePassword(string key, int length=20) @ 0x7D216FC ``` 关键ISIL: ```text 037 Call XXHashService.CalculateHash, X0 ; seed = xxHash32(key) 045 Add W8, W19, W19 ; ADD W8,W19,W19,LSL#1 => length*3 046 Add W9, W8, 3 047/048 CMP/CSEL ; 负数向上取整修正 052 SBFM W19,W8,2,0x1F ; W19 = (length*3) >> 2 = length*3/4 053 Call MersenneTwister..ctor, X0, X1 ; mt = new MersenneTwister(seed) 057 Call MersenneTwister.NextBytes, X0, X1 ; raw = mt.NextBytes(15) 072 Call Convert.ToBase64String, X0 ; 15字节 → 20个base64字符 ``` 可得大致等价C#逻辑: ```csharp public static string CreatePassword(string key, int length = 20) { uint seed = XXHashService.CalculateHash(key); byte[] raw = new MersenneTwister((int)seed).NextBytes(length * 3 / 4); return Convert.ToBase64String(raw); } ``` 调用处传入的`length`固定为20,实际就是取15字节随机流,然后转成20字符base64。 继续往下看`XXHashService.CalculateHash`: ```text cpp2il_isil/IsilDump/BlueArchive/MX/Core/Services/XXHashService.txt XXHashService.CalculateHash(string) @ 0x6DF0B18 ``` 关键逻辑: ```text 005 Call String.IsNullOrEmpty 015 Call Encoding.get_UTF8 020/021 BLR (XXHash32 实例.ComputeHash) ``` 也就是: ```text xxHash32(UTF8(name), seed=0) ``` 再看`MersenneTwister`: ```text cpp2il_isil/IsilDump/BlueArchive/MX/Core/Math/MersenneTwister.txt ``` 构造函数里调用`init_genrand(seed)`,常数和流程都是MT19937,也就是`n=624`、初始化乘数为`1812433253`。 值得注意的是,它的`Next()`并不直接返回`genrand_int32()`,反而使用以下逻辑: ```c (genrand_int32() >> 1) & 0x7FFFFFFF ``` `NextBytes(len)`则是每4字节调用一次`Next()`,再用`BitConverter.GetBytes`按小端写入输出。对应ISIL: ```text 036 Call MersenneTwister.genrand_int32 039 ShiftRight W22, 1 040 And W22, W22, 0x7FFFFFFF 050 Call BitConverter.GetBytes ``` 该操作会导致每4字节里的最高字节恒小于`0x80`,后面的验证中也能看到对应效果。 完整复现实现: ```python import base64, struct # ---- xxHash32 (seed 0) ---- P1,P2,P3,P4,P5 = 2654435761,2246822519,3266489917,668265263,374761393 M=0xFFFFFFFF def _rl(x,r): x&=M; return ((x<<r)|(x>>(32-r)))&M def xxhash32(data, seed=0): n=len(data); i=0 if n>=16: v1=(seed+P1+P2)&M; v2=(seed+P2)&M; v3=seed&M; v4=(seed-P1)&M def _round(v,k): return (_rl((v+(k*P2)&M)&M,13)*P1)&M while i<=n-16: v1=_round(v1,struct.unpack_from('<I',data,i)[0]); i+=4 v2=_round(v2,struct.unpack_from('<I',data,i)[0]); i+=4 v3=_round(v3,struct.unpack_from('<I',data,i)[0]); i+=4 v4=_round(v4,struct.unpack_from('<I',data,i)[0]); i+=4 h=(_rl(v1,1)+_rl(v2,7)+_rl(v3,12)+_rl(v4,18))&M else: h=(seed+P5)&M h=(h+n)&M while i+4<=n: k=struct.unpack_from('<I',data,i)[0]; i+=4 h=(_rl((h+k*P3)&M,17)*P4)&M while i<n: h=(_rl((h+data[i]*P5)&M,11)*P1)&M; i+=1 h^=h>>15; h=(h*P2)&M; h^=h>>13; h=(h*P3)&M; h^=h>>16 return h&M # ---- MT19937 ---- class MT: def __init__(self, seed): self.mt=[0]*624; self.idx=625 s=seed & M self.mt[0]=s for i in range(1,624): self.mt[i]=(1812433253*(self.mt[i-1]^(self.mt[i-1]>>30))+i)&M self.idx=624 def genrand_int32(self): if self.idx>=624: for i in range(624): y=(self.mt[i]&0x80000000)|(self.mt[(i+1)%624]&0x7fffffff) v=self.mt[(i+397)%624]^(y>>1) if y&1: v^=2567483615 self.mt[i]=v&M self.idx=0 y=self.mt[self.idx]; self.idx+=1 y^=y>>11; y^=(y<<7)&2636928640; y^=(y<<15)&4022730752; y^=y>>18 return y&M def Next(self): return (self.genrand_int32()>>1)&0x7FFFFFFF def NextBytes(self,length): out=bytearray(length); i=0 while i<length: v=self.Next(); b=struct.pack('<I',v) for j in range(4): if i+j<length: out[i+j]=b[j] i+=4 return bytes(out) def create_key(name): return MT(xxhash32(name.encode())).NextBytes(8) def create_password(key, length=20): raw = MT(xxhash32(key.encode())).NextBytes((length*3)//4) return base64.b64encode(raw).decode() def xor(name, data): mt=MT(xxhash32(name.encode())); out=bytearray(data); k=0 for i in range(len(out)): if i&3==0: k=mt.Next() out[i]^=(k>>((i&3)*8))&0xFF return bytes(out) if __name__=="__main__": import sys enc=open("GameMainConfig.enc","rb").read() for name in ["GameMainConfig","GameMainConfig.bytes","gamemainconfig"]: d=xor(name,enc) printable=sum(1 for b in d if 9<=b<=126) head=d[:80].decode('utf-8','replace') print(f"name={name!r} printable={printable}/{len(d)} head={head!r}") ``` ### 表内第二层XOR zip只是外层。表包里的`.bytes`解出来之后,部分字符串或数值字段还有第二层XOR。 能够定位到相关逻辑在: ```text cpp2il_isil/IsilDump/BlueArchive/MX/Data/TableEncryptionService.txt TableEncryptionService.XOR(string name, byte[] bytes) @ 0x7672BF8 ``` ISIL: ```text 029 Call XXHashService.CalculateHash 041 Call MersenneTwister..ctor ``` 循环里则是每4字节取一次MT输出,然后按字节进行XOR运算: ```text 0x7672D88 LDRB 0x7672D8C UBFX 0x7672D90 ASRV 0x7672D98 EOR ``` 也就是: ```c seed = xxHash32(name); mt = new MersenneTwister(seed); for i in range(len(bytes)): if ((i & 3) == 0) k = mt.Next(); out[i] = in[i] ^ ((k >> ((i & 3) * 8)) & 0xff); ``` 对应的`Encrypt/Convert`: ```text TableEncryptionService.Encrypt(string, byte[]) @ 0x767340C TableEncryptionService.Convert(string, byte[]) @ 0x7673530 ``` 这里走的是: ```text Base64( UTF16LE(s) XOR key循环 ) ``` 以及反向转换,主要用于表里某些“键:值”形式的单元格。 对应的8字节key生成函数是: ```text TableEncryptionService.CreateKey(string name) @ 0x7672B78 ``` 逻辑非常短: ```text 018 Call XXHashService.CalculateHash, X0 ; seed = xxHash32(name) 028 Call MersenneTwister..ctor, X0, X1 ; mt = new MersenneTwister(seed) 032 Move W1, 8 036 Call MersenneTwister.NextBytes, X0, X1 ; return mt.NextBytes(8) ``` 可得对应C#逻辑: ```csharp byte[] key = new MersenneTwister(xxHash32(name)).NextBytes(8); ``` ### 用内置GameMainConfig验证 静态分析完成后,拿APK里内置的`GameMainConfig`做一组真值验证 其中的TextAsset `GameMainConfig`大小为914B,是密文。直接看密文字节,可以发现奇数字节呈现严格周期8: ```text 23 06 72 57 23 06 72 57 ... ``` 这正好符合“UTF-16LE明文的奇数字节多为0,再被8字节key循环异或”的特征。同时`06`、`57`都小于`0x80`,也和前面`MersenneTwister.Next()`会右移一位的特征吻合。 使用上面的脚本`CreateKey('GameMainConfig')`,可以非常轻松的成功解密: <img src="upload/attach/202605/1009406_6NCEV4AYHH7MCC9.webp" width="400" /> ### 在线资源解密验证 通过SDK配置可得: ```text GameMainConfig.ServerInfoDataUrl = https://yostar-serverinfo.bluearchiveyostar.com/r92_69_n6bbzfm9l4uam95cnbhu.json ServerInfoData.ConnectionGroups[0].OverrideConnectionGroups["1.69"].AddressablesCatalogUrlRoot = https://prod-clientpatch.bluearchiveyostar.com/r92_tswkgnxjpvsoghmcv9qu ``` 随意拼一个资源URL: ```text https://prod-clientpatch.bluearchiveyostar.com/r92_tswkgnxjpvsoghmcv9qu/TableBundles/Excel.zip ``` 下载后得到: ```text HTTP/1.1 200 OK Content-Type: application/zip Content-Length: 18209833 Last-Modified: Fri, 29 May 2026 02:29:06 GMT sha256 = 6f6d0e1b11276a0bd9f4c5d27e860280fcb50c9e3a273344a8391e3c9b91694c local header flags = 0x0009 method = 8 first entry = animationblendtable.bytes entries = 71 ``` `flags=0x0009`里bit0为1,说明这确实是一个加密zip。 按真实运行名`Excel.zip`计算口令: ```text CreatePassword("Excel.zip") = /wy5f3hIGGXLOIUDS9DZ ``` 依旧成功解密。 ## 结语 至此,已完成对该二游的改版il2cppdumper制作,并完整分析其资源加密逻辑。 回顾一下可以发现,该二游几乎未使用任何高强度加密或混淆,libil2cpp.so本身几乎裸奔,主要工作量集中在metadata usage token布局魔改、伪造metadata version以及资源下发流程上的一些工程化处理。前者会让通用工具链失效,后者则更多偏向于资源管理与简单反解包,也属于是经典的防君子不防小人设计了。 在修正metadata布局、恢复注册结构、补齐token解析逻辑之后,il2cppdumper即可重新正常工作;再配合cpp2il、ISIL、IDA/Ghidra与符号脚本,客户端逻辑基本已经处于可读状态。资源侧同理:顺着调用链把zip解密流程、口令生成原语与表内二次异或逐层还原后,整个资源系统的行为也已经可以稳定复现。 ## 鸣谢 特别感谢 <a href="elink@168K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6K6k6W2)9J5k6s2W2#2P5X3W2X3N6b7`.`.">sf-yuzifu</a> 提供部分逆向思路
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于
2026-5-30 19:55 被Searchstars编辑 ,原因: 修正鸣谢列表中的链接
#调试逆向
#软件保护
#加密算法
收藏
・
11
点赞
・
11
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_晨梦
这个讨论对我很有帮助,谢谢!
2026-7-16 05:24
x_req
非常支持你的观点!
2026-6-2 23:47
cr_lgdx
期待更多优质内容的分享,论坛有你更精彩!
2026-6-2 16:45
qqizai
感谢你的积极参与,期待更多精彩内容!
2026-6-2 14:04
andy张刘
你的帖子非常有用,感谢分享!
2026-6-1 16:38
我的小拇指啊
你的帖子非常有用,感谢分享!
2026-6-1 12:08
梧桐生
感谢你分享这么好的资源!
2026-6-1 10:19
kaokaoyou
期待更多优质内容的分享,论坛有你更精彩!
2026-5-31 20:04
EX呵呵
为你点赞!
2026-5-30 19:58
小鱼yuzifu
+1
这个讨论对我很有帮助,谢谢!
2026-5-30 17:05
by_Lin
你的帖子非常有用,感谢分享!
2026-5-30 16:52
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
4
)
by_Lin
雪 币:
631
活跃值:
(1096)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
3
粉丝
0
关注
私信
by_Lin
2
楼
tql
2026-5-30 16:52
0
Imxz
雪 币:
112
活跃值:
(9405)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
750
粉丝
9
关注
私信
Imxz
3
楼
tql
2026-5-30 18:30
0
墨穹呢
雪 币:
4697
活跃值:
(8117)
能力值:
( LV3,RANK:20 )
在线值:
发帖
2
回帖
224
粉丝
18
关注
私信
墨穹呢
4
楼
感谢分享
2026-5-30 18:36
0
若离空
雪 币:
能力值:
( LV1,RANK:0 )
在线值:
发帖
0
回帖
1
粉丝
0
关注
私信
若离空
5
楼
谢谢分享,可以请教一下关于储存剧情文本文件的TableBundles/ExcelDB.db文件怎么解包的吗?自己试了搞不明白
2026-6-19 22:06
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
Searchstars
1
发帖
1
回帖
0
RANK
关注
私信
他的文章
[原创]某校园题材二游逆向记录
6341
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部