某校园题材二游逆向记录
受朋友委托,对某二游进行逆向工程。
经了解,目标游戏使用Unity引擎开发,同时登录PC、Android、iOS平台。这里为了方便绕过反作弊和反调试,选用Android端作为逆向对象。
初步分析
针对客户端进行解包,可以看到非常熟悉的Unity il2cpp打包结构:
对于这种情况,不必多说,先尝试直接使用il2cppdumper开干:
不过很显然事情并不会这么简单,意料之中地报错了,从报错信息中可以得到,libil2cpp.so本身应该不存在保护,问题主要集中在global-metadata.dat。分析错误可知崩溃点在MapVATR读CodeRegistration/MetadataRegistration的数组时抛EndOfStreamException,这基本是非常明显的il2cpp元数据结构魔改特征。
不过也不必先急于对魔改后的结构进行完整逆向,我们可以使用容错能力更强的cpp2il在加载StrippedCodeRegSupport插件的情况下跑diffable-cs和isildump得到结构和机器码:
metadata usage token布局分析
虽然cpp2il已经能跑出diffable-cs和isildump,但il2cppdumper本身仍然无法正常解析。既然崩溃点和metadata usage解析强相关,那就继续顺着相关调用往下看。
先照重定位表随手还原几个metadata usage slot,再顺着读对应位置的内容,可以拿到一些看起来像metadata usage token的值。照常见il2cpp解法来解token:
kind = token >> 29;
index = token & 0x1FFFFFFF;
结果明显异常,有的index直接越界,有的虽然没越界,但解出来也是无关文本:
可以判断这次魔改动的就是token的编码方式,跟通用公式对不上。
继续分析相关汇编,可以很容易发现相关位置在赋值前会先调0x6353FAC去解这些metadata usage,那就直接跟进该函数:
这层只是个跳板,顺着sub_6363BF4的尾调用进到sub_63E2AA0,关键逻辑就在这里:
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;
__int64 v10;
__int64 v11;
unsigned int *v12;
int *v13;
unsigned __int64 v14;
unsigned __int64 *v15;
unsigned __int64 v16;
int v17;
unsigned int *v18;
__int64 v19;
int v21;
char v22[8];
int v23[2];
void *v24;
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;
}
剥出关键逻辑:
if ((token & 1) == 0)
return 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:
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
);
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:
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布局是这样:
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,直接锁定版本来修复:
[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:
public bool NoRedirectedPointer { get; set; } = false;
+ public string ForceCodeRegistration { get; set; } = "";
+ public string ForceMetadataRegistration { get; set; } = "";
Il2CppDumper/Program.cs中 在Console.WriteLine("Searching...") 后,PlusSearch前:
- 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 项指针/计数)即可求得。
附对应脚本:
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
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):
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):
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,即可得到熟悉的东西:
接下来可继续使用IDA或Ghidra等工具载入libil2cpp.so,使用il2cppdumper附带的脚本根据script.json和il2cpp.h给反编译结果上符号。
至此,完整的游戏逻辑已经跟裸奔没有任何本质区别。
资源文件解密
在写这里的时候我的IDA还在对libil2cpp.so进行分析,我当然不想再等一个小时两个小时的,所以下面的内容主要先根据ISIL来解。
资源路径与压缩包形态
简单预览il2cpp_dump_mx/stringliteral.json,能看到一组成套出现的资源路径常量:
"/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,后者不会。
整体管线大致如下:

TableCatalog和MediaCatalog本身是MemoryPack,从我们修改适配后的il2cppdumper的dump结果il2cpp_dump_mx/dump.cs中也能证明:
public class TableCatalog : IMemoryPackable<TableCatalog>
public class MediaCatalog : IMemoryPackable<MediaCatalog>
资源根{root}则来自登录dispatcher下发的ServerInfoData.ConnectionGroups[].AddressablesCatalogUrlRoot
TableBundles解包
易得读取点在:
cpp2il_isil/IsilDump/BlueArchive/MX/AssetBundles/BundleLogicDataReader.txt
BundleLogicDataReader.ReadBinary(System.String zippedFilePath)
关键ISIL:
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同理:
cpp2il_isil/IsilDump/BlueArchive/Media/Service/MediaService.txt
对应ISIL:
101 Call ZipInputStream..ctor
129 Call TableService.CreatePassword
132 Move [X0+128], X1 ; .Password = pwd
140 Call ZipInputStream.GetNextEntry
后面多处可见类似逻辑,由此可知,MediaResources同样复用TableService.CreatePassword这套口令机制。
反过来看Addressables清单zip,位置在:
cpp2il_isil/IsilDump/BlueArchive/MX/AssetBundles/ResourcePatcher_NestedType__DownloadCatalog_d__150.txt
这里的逻辑是:
196 Call ZipInputStream..ctor
203 Call ZipInputStream.GetNextEntry
整个方法里没有CreatePassword调用。也就是说,catalog_Android.zip只是普通zip容器,不带口令。
口令算法
跟进TableService.CreatePassword:
cpp2il_isil/IsilDump/BlueArchive/TableService.txt
TableService.CreatePassword(string key, int length=20) @ 0x7D216FC
关键ISIL:
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#逻辑:
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:
cpp2il_isil/IsilDump/BlueArchive/MX/Core/Services/XXHashService.txt
XXHashService.CalculateHash(string) @ 0x6DF0B18
关键逻辑:
005 Call String.IsNullOrEmpty
015 Call Encoding.get_UTF8
020/021 BLR (XXHash32 实例.ComputeHash)
也就是:
xxHash32(UTF8(name), seed=0)
再看MersenneTwister:
cpp2il_isil/IsilDump/BlueArchive/MX/Core/Math/MersenneTwister.txt
构造函数里调用init_genrand(seed),常数和流程都是MT19937,也就是n=624、初始化乘数为1812433253。
值得注意的是,它的Next()并不直接返回genrand_int32(),反而使用以下逻辑:
(genrand_int32() >> 1) & 0x7FFFFFFF
NextBytes(len)则是每4字节调用一次Next(),再用BitConverter.GetBytes按小端写入输出。对应ISIL:
036 Call MersenneTwister.genrand_int32
039 ShiftRight W22, 1
040 And W22, W22, 0x7FFFFFFF
050 Call BitConverter.GetBytes
该操作会导致每4字节里的最高字节恒小于0x80,后面的验证中也能看到对应效果。
完整复现实现:
import base64, struct
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
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。
能够定位到相关逻辑在:
cpp2il_isil/IsilDump/BlueArchive/MX/Data/TableEncryptionService.txt
TableEncryptionService.XOR(string name, byte[] bytes) @ 0x7672BF8
ISIL:
029 Call XXHashService.CalculateHash
041 Call MersenneTwister..ctor
循环里则是每4字节取一次MT输出,然后按字节进行XOR运算:
0x7672D88 LDRB
0x7672D8C UBFX
0x7672D90 ASRV
0x7672D98 EOR
也就是:
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:
TableEncryptionService.Encrypt(string, byte[]) @ 0x767340C
TableEncryptionService.Convert(string, byte[]) @ 0x7673530
这里走的是:
Base64( UTF16LE(s) XOR key循环 )
以及反向转换,主要用于表里某些“键:值”形式的单元格。
对应的8字节key生成函数是:
TableEncryptionService.CreateKey(string name) @ 0x7672B78
逻辑非常短:
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#逻辑:
byte[] key = new MersenneTwister(xxHash32(name)).NextBytes(8);
用内置GameMainConfig验证
静态分析完成后,拿APK里内置的GameMainConfig做一组真值验证
其中的TextAsset GameMainConfig大小为914B,是密文。直接看密文字节,可以发现奇数字节呈现严格周期8:
23 06 72 57 23 06 72 57 ...
这正好符合“UTF-16LE明文的奇数字节多为0,再被8字节key循环异或”的特征。同时06、57都小于0x80,也和前面MersenneTwister.Next()会右移一位的特征吻合。
使用上面的脚本CreateKey('GameMainConfig'),可以非常轻松的成功解密:
在线资源解密验证
通过SDK配置可得:
GameMainConfig.ServerInfoDataUrl
= 883K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6&6L8%4y4@1j5i4u0Q4x3X3c8K6k6i4u0$3k6i4u0A6L8X3k6G2i4K6u0W2j5X3I4#2k6h3q4J5j5$3S2A6N6X3g2&6L8%4y4@1j5i4u0Q4x3X3g2U0L8$3#2Q4x3V1k6J5z5e0u0Q4y4h3j5$3z5g2)9#2k6X3^5$3j5X3u0*7k6X3@1&6L8o6c8#2j5h3@1&6y4h3y4F1j5X3S2#2i4K6u0W2K9Y4y4G2L8R3`.`.
ServerInfoData.ConnectionGroups[0].OverrideConnectionGroups["1.69"].AddressablesCatalogUrlRoot
= d2fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6H3M7X3!0V1i4K6u0V1j5$3I4A6k6h3&6@1M7r3q4@1j5$3S2Q4x3X3g2T1L8s2g2W2j5i4u0U0K9r3W2$3k6i4W2G2M7%4c8S2M7W2)9J5k6h3y4G2L8g2)9J5c8Y4t1&6x3W2)9#2k6Y4c8K6N6$3E0Y4L8Y4S2B7M7s2k6K6L8$3N6Z5L8h3y4$3z5i4q4#2
随意拼一个资源URL:
255K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6H3M7X3!0V1i4K6u0V1j5$3I4A6k6h3&6@1M7r3q4@1j5$3S2Q4x3X3g2T1L8s2g2W2j5i4u0U0K9r3W2$3k6i4W2G2M7%4c8S2M7W2)9J5k6h3y4G2L8g2)9J5c8Y4t1&6x3W2)9#2k6Y4c8K6N6$3E0Y4L8Y4S2B7M7s2k6K6L8$3N6Z5L8h3y4$3z5i4q4#2i4K6u0r3g2r3q4T1L8r3g2n7N6h3&6V1L8r3g2K6i4K6u0r3c8i4S2U0k6h3I4Q4x3X3g2*7K9i4l9`.
下载后得到:
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计算口令:
CreatePassword("Excel.zip") = /wy5f3hIGGXLOIUDS9DZ
依旧成功解密。
结语
至此,已完成对该二游的改版il2cppdumper制作,并完整分析其资源加密逻辑。
回顾一下可以发现,该二游几乎未使用任何高强度加密或混淆,libil2cpp.so本身几乎裸奔,主要工作量集中在metadata usage token布局魔改、伪造metadata version以及资源下发流程上的一些工程化处理。前者会让通用工具链失效,后者则更多偏向于资源管理与简单反解包,也属于是经典的防君子不防小人设计了。
在修正metadata布局、恢复注册结构、补齐token解析逻辑之后,il2cppdumper即可重新正常工作;再配合cpp2il、ISIL、IDA/Ghidra与符号脚本,客户端逻辑基本已经处于可读状态。资源侧同理:顺着调用链把zip解密流程、口令生成原语与表内二次异或逐层还原后,整个资源系统的行为也已经可以稳定复现。
鸣谢
特别感谢 sf-yuzifu 提供部分逆向思路
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 22小时前
被Searchstars编辑
,原因: 修正鸣谢列表中的链接