逆向工具
引路人
unity引擎基于Windows下的il2cpp逆向初探——以CTF赛题为例-先知社区
[原创]IL2CPP 逆向初探-软件逆向-看雪安全社区|专业技术交流与安全研究论坛
dnspy
反编译C#相关程序,反编译修改后需要保存模块以应用到目标程序。
获取so metadata
Il2cpp逆向:global-metadata解密-腾讯云开发者社区-腾讯云
Unity
打开APK安装包,自己找,只要是unity游戏一定会有,不过有可能被加密或者隐藏了。
- libil2cpp.so 所有的C#方法被编译成了C++
- global-metadata.dat 结构化的数据库,存放了定义
MT管理器左侧功能,提取套件(Extract APK)



Cocos
- libcocos2dlua.so
没有元数据,可以直接导入IDA
Zygisk-Il2CppDumper
如果不想给so或者metadata脱壳,可以通过动态获取。

比如这里的metadata就是套壳了。
IDA
下载
需要自行配备python3.8以上环境,IDA安装时会自动检测。
磁力链接下载IDA9.1
magnet:?xt=urn:btih:f24cfadb8a66b343bf1ff4f0c1386a5f6991c818&dn=ida91
激活方式,生成License
ThatNotEasy/IDA-Patcher: IDA Pro Patcher & License Generator
main第28行写错了,修改为
end_year = args.end_date or (now.year + 10)
执行命令参数为安装目录,也就是ida.exe的根目录
python main.py -p "D:\Tools\IDA"
成功激活后如下

使用
F5反编译
G跳转地址
右键查看XREF引用
左侧函数列表Ctrl F 搜索,右侧文本搜索。
按住Ctrl点击文本视图函数,会新建视窗栏目,而不是覆盖上一个。
插件
-
可以配合il2Cppdumper使用
对于global-metadata.dat和libil2cpp.so都正常的情况:会生成以下这些文件

如果你没有指定目录,那么就会这些生成在Il2CppDumper安装目录
在IDA中选择
file > script file...选中Il2CppDumper安装目录下的ida_with_struct_py3.py然后会继续弹框让你选择文件,依次选择
script.json>il2cpp.h之后就等待IDA运行脚本了。
Zygisk-Il2CppDumper
下载Magisk工具Release Magisk (f9f02c65) (30403) · ayasa520/Magisk
【安卓模拟器安装 Magisk30.4】 https://www.bilibili.com/video/BV1PYWHzCEjE/?share_source=copy_web&vd_source=05320767bb904def1ce7c1e88823462a
获取游戏包名称,以mumu模拟器为例,在这个路径的文件夹名称就是包名。

Fork本体violet-wdream/Zygisk-Il2CppDumper: Using Zygisk to dump il2cpp data at runtime,不需要下载,然后在你Fork的分支actions里面输入包名,构建文件。
原仓库使用的actions太久了需要改成v4版本,修改了build.yml
- uses: actions/upload-artifact@v4
Workflow runs · violet-wdream/Zygisk-Il2CppDumper 这个是我改过的。

输入包名jp.glee.girl构建结果模块
然后你点击这个Build,进去下载这个构建好的工件,下载后是一个压缩包(模块)。
安装这个构建的模块。

启动游戏,会在/data/user/0/GamePackageName/files/或者/data/data/0/GamePackageName/files/目录下生成dump.cs,要是没有就重启模拟器再试一下。

Il2CppDumper
和Zygisk-Il2CppDumper二选一即可。Perfare/Il2CppDumper: Unity il2cpp reverse engineer
使用方法Il2CppDumper/README.zh-CN.md at master · Perfare/Il2CppDumper
这里的放置少女的global-metadata.dat已被加密,所以不适用。
对于global-metadata.dat和libil2cpp.so都正常的情况:会生成以下这些文件

如果你没有指定目录,那么就会这些生成在Il2CppDumper安装目录
在IDA中选择file > script file... 选中Il2CppDumper安装目录下的ida_with_struct_py3.py
然后会继续弹框让你选择文件,依次选择script.json > il2cpp.h
之后就等待IDA运行脚本了。
ADB
adb connect 127.0.0.1:5555
adb connect 127.0.0.1:16384
adb devices
adb root
adb shell
exit
#查找package,不建议自己查,不如MT管理器简单方便
pm path com.fknzj.qooapp
ls -l /data/data/com.fknzj.qooapp
Frida
#server - emulator
adb connect 127.0.0.1:16384
adb root
adb shell
cd /data/local/tmp
./frida
#client - PC
adb forward tcp:27042 tcp:27042
#conda activate py314
frida-ps -U
frida -U -f "jp.glee.girl" -l hook.js #重启注入
frida -U -n jp.glee.girl -l hook.js #附加注入
模拟器frida服务
APP逆向第五课: Frida安装&基础HOOK⽅法_哔哩哔哩_bilibili
模拟器adb shell连接
查看模拟器adb端口

找到adb路径D:\Games\Mumu\MuMuPlayer\nx_device\12.0\shell,getprop查询CPU架构为x86_64
adb.exe connect 127.0.0.1:5555
adb.exe shell
getprop ro.product.cpu.abi

找到对应版本下载。https://github.com/frida/frida/releases/download/16.1.0/frida-server-16.1.0-android-x86_64.xz
解压xz文件后得到一个无拓展名文件。重新命名为frida
传入到模拟器中。这里使用mumu共享文件夹传输文件,然后在共享文件夹再次把文件移动到本机根目录
或者使用push指令一步到位
adb push ...\frida /data/local/tmp

打开root explorer或者任意root管理器,找到frida文件

再次移动frida,移动到tmp目录,因为其他目录下不能修改这个文件的权限。

修改文件权限为777,全部勾选即可。

接下来cd进这个目录,启动frida
cd /data/local/tmp;
./frida
然后这里会显示权限不够。
`Cetus:/data/local/tmp $ ./frida
Unable to save SELinux policy to the kernel: Permission denied`。
需要提升权限root,输入exit返回
exit
adb root
adb shell
cd /data/local/tmp
./frida
然后如果命令行一动不动,说明模拟器frida服务成功开启了

物理机连接frida服务
接下来用物理机连接模拟器的frida服务,新建一个终端,还是在D:\Games\Mumu\MuMuPlayer\nx_device\12.0\shell这个路径下,进行端口转发。
adb forward tcp:27042 tcp:27042
需要python环境,物理机下载frida和frida-tools,参照这个表找到对应版本thelicato/frida-compatibility-matrix: Compatible versions of the frida package for each version of the frida-tools package. Automatically updated through CI/CD
因为我们下载的是16.1.0版本,所以tools下载12.3.0,如果版本下载太高了就无法识别模拟器的frida服务。
pip uninstall frida-tools frida -y #如果下载错了需要先卸载frida和依赖
pip install frida==16.1.0 frida-tools==12.3.0
两个包都需要指定版本,不然会自动下载默认版本,可能会出问题。
如果一切顺利,查询模拟器进程,可以查到PID和对应进程。
frida-ps -U

hook
模拟器frida服务容易掉线,如果出现了问题可以检查一下是不是frida服务掉线了。
最后一个参数是包名,获取方式在Zygisk-Il2CppDumper这个章节,这里是目标游戏放置少女的包名。
在物理机下输入以下指令,会自动启动,同时附加到进程。
frida -U -f "jp.glee.girl"

这里就完成了Frida功能的初步验证。
Frida-il2cpp-bridge
当前使用很多很多问题,难以解决。
git clone https://github.com/oleavr/frida-agent-example.git
cd frida-agent-example/
npm install -g typescript
npm install
npm install --save-dev frida-il2cpp-bridge
index.ts
import "frida-il2cpp-bridge";
console.log("Rebuilded")
Il2Cpp.perform(() => {
console.log(Il2Cpp.unityVersion);
});
测试使用
npm run watch
frida -Uf jp.glee.girl -l _agent.js
更多用法Snippets · vfsfitvnm/frida-il2cpp-bridge Wiki
CE
模拟器链接CE如何在MuMu模拟器上使用CheatEngine_MuMu模拟器_安卓模拟器
Unity 可以参考哥特少女勇闯恶魔城2 Sinisistar2
加密算法
UnityCN
加密:AssetBundle.SetAssetBundleEncryptKey();
解密:AssetBundle.SetAssetBundleDecryptKey();
雷索纳斯
搜索SetAssetBundleDecryptKey



ResonanceRebornR String -> Hex
得到5265736F6E616E63655265626F726E52

黑色信标 RC4
下面找一下key是怎么来的。
搜索SetAssetBundleDecryptKey
查看引用发现GameAOT_AOT$$Awake
搜索GameAOT_AOT$$Awake发现Game_GenerateData$$Generate为SetAssetBundleDecryptKey提供了参数。

查看Game_GenerateData$$Generate定义,发现一个可疑的string

得到原始key,后续得知这个其实是RC4的seed
`?dZqfdo|&pM@Js^
但还不是真正的结果,发现还有一层处理

System_Byte_array *Game_Helper__Encrypt(System_Byte_array *pwd, System_Byte_array *data, const MethodInfo *method)
{
__int64 v5; // r15
__int64 v6; // rdx
__int64 v7; // rax
__int64 v8; // rdx
__int64 v9; // r13
__int64 v10; // rax
il2cpp_array_size_t max_length; // rcx
__int64 v12; // r8
__int64 max_length_low; // rdi
unsigned __int64 i; // rsi
__int64 v15; // rdx
unsigned __int64 v16; // r9
unsigned __int64 v17; // rax
int v18; // ebx
int v19; // ebx
unsigned int v20; // r9d
int v21; // r10d
unsigned __int64 v22; // r11
int v23; // ecx
if ( !byte_4E3A8A8 )
{
sub_D96BF5(&byte___TypeInfo, data);
sub_D96BF5(&int___TypeInfo, data);
byte_4E3A8A8 = 1;
}
v5 = sub_D96C5F(int___TypeInfo, 256, method);
v7 = sub_D96C5F(int___TypeInfo, 256, v6);
if ( !data || (v9 = v7, v10 = sub_D96C5F(byte___TypeInfo, LODWORD(data->max_length), v8), !pwd) )
LABEL_29:
sub_D96D1C();
v12 = v10;
max_length_low = LODWORD(pwd->max_length);
for ( i = 0; i != 256; ++i )
{
v15 = (unsigned int)((int)i >> 31);
LODWORD(v15) = (int)i % (int)max_length_low;
if ( (int)i % (int)max_length_low >= (unsigned int)max_length_low )
goto LABEL_27;
if ( !v5 )
goto LABEL_29;
v16 = *(unsigned int *)(v5 + 24);
if ( i >= v16 )
goto LABEL_27;
*(_DWORD *)(v5 + 4 * i + 32) = pwd->m_Items[(int)v15];
if ( !v9 )
goto LABEL_29;
v17 = *(unsigned int *)(v9 + 24);
if ( i >= v17 )
goto LABEL_27;
*(_DWORD *)(v9 + 4 * i + 32) = i;
}
v15 = 0;
v18 = 0;
do
{
if ( (unsigned int)v15 >= (unsigned int)v17 )
goto LABEL_27;
if ( (unsigned int)v15 >= (unsigned int)v16 )
goto LABEL_27;
max_length_low = *(unsigned int *)(v9 + 4 * v15 + 32);
v19 = *(_DWORD *)(v5 + 4 * v15 + 32) + max_length_low + v18;
i = (v19 + ((unsigned int)(v19 >> 31) >> 24)) & 0xFFFFFF00;
v18 = v19 % 256;
if ( v18 >= (unsigned int)v17 )
goto LABEL_27;
i = v18;
max_length = *(unsigned int *)(v9 + 4LL * v18 + 32);
*(_DWORD *)(v9 + 4 * v15 + 32) = max_length;
*(_DWORD *)(v9 + 4LL * v18 + 32) = max_length_low;
++v15;
}
while ( (_DWORD)v15 != 256 );
max_length = data->max_length;
if ( (int)max_length > 0 )
{
if ( (unsigned int)v17 >= 2 )
{
v20 = data->max_length;
LODWORD(v15) = 0;
v21 = 1;
v22 = 0;
do
{
max_length_low = v21;
max_length = *(unsigned int *)(v9 + 4LL * v21 + 32);
v15 = (unsigned int)(((int)max_length + (int)v15) % 256);
if ( (unsigned int)v15 >= (unsigned int)v17 )
break;
*(_DWORD *)(v9 + 4LL * v21 + 32) = *(_DWORD *)(v9 + 4LL * (int)v15 + 32);
*(_DWORD *)(v9 + 4LL * (int)v15 + 32) = max_length;
v23 = *(_DWORD *)(v9 + 4LL * v21 + 32) + max_length;
i = (v23 + ((unsigned int)(v23 >> 31) >> 24)) & 0xFFFFFF00;
max_length = (unsigned int)(v23 % 256);
if ( (unsigned int)max_length >= (unsigned int)v17 || v22 >= v20 )
break;
if ( !v12 )
goto LABEL_29;
if ( v22 >= *(unsigned int *)(v12 + 24) )
break;
*(_BYTE *)(v12 + v22 + 32) = data->m_Items[v22] ^ *(_DWORD *)(v9 + 4LL * (int)max_length + 32);
++v22;
v20 = data->max_length;
if ( (__int64)v22 >= (int)v20 )
return (System_Byte_array *)v12;
LODWORD(v17) = *(_DWORD *)(v9 + 24);
i = (v21 + ((unsigned int)((v21 + 1) >> 31) >> 24) + 1) & 0xFFFFFF00;
max_length = (unsigned int)(v21 + 1 - i);
v21 = max_length;
}
while ( (unsigned int)max_length < (unsigned int)v17 );
}
LABEL_27:
sub_D96D22(max_length_low, i, v15, max_length, v12);
}
return (System_Byte_array *)v12;
}
反正就是 RC4 的 KSA + PRGA,有点复杂,可以找现成的python复现或者找相关工具实现。
def rc4(key, data):
S = list(range(256))
j = 0
out = []
for i in range(256):
j = (j + S[i] + key[i % len(key)]) & 0xFF
S[i], S[j] = S[j], S[i]
i = j = 0
for b in data:
i = (i + 1) & 0xFF
j = (j + S[i]) & 0xFF
S[i], S[j] = S[j], S[i]
out.append(b ^ S[(S[i] + S[j]) & 0xFF])
return bytes(out)
完整流程
public static byte[] Generate()
{
// 1. ASCII 编码器
var ascii = new System.Text.ASCIIEncoding();
// 2. 固定明文字符串
string plain = "`?dZqfdo|&pM@Js^";
// 3. 转成 ASCII 字节
byte[] data = ascii.GetBytes(plain);
// 4. 使用静态密码进行加密
// pwd 存在于 Game.GenerateData 的 static fields
byte[] encrypted = Game.Helper.Encrypt(pwd, data);
// 5. 再次用 ASCIIEncoding 把 byte[] 转回 byte[]
return ascii.GetBytes(encrypted);
}
这里还需要RC4的key,然后通过这个算法进一步处理
为了获取RC4的key,还需要查看上一级调用的函数的构造函数,也就是Game_Helper__Encrypt的调用者Game_GenerateData$$Generate的构造函数cctor。
查找Game_GenerateData相关函数,找到Game_GenerateData$$_cctor
可以看到这里创建一个256字节的数组,明显是RC4相关的数据。接着应该是使用常量Field_eval_p_eval_b来初始化。

跳转到定义

到这里还是有点迷惑,并没有给出实质性的数据,只有System_RuntimeFieldHandle_o <<800002F1h>>这个信息,gpt说这个800002F1h是一个token,需要查global-metadata.dat得出结果。
Token → metadata → default value
但是查metadata是需要偏移量的
接下来查找script.json,搜索eval_p.eval_b得到
{
"Address": 81565432,
"Name": "Field$eval_p.eval_b",
"Signature": null
}
这里只给一个RVA地址,没什么用。
接下来查找dump.cs相关定义,成功找到偏移量
[CompilerGenerated]
internal sealed class eval_p // TypeDefIndex: 12761
{
// Fields
internal static readonly eval_p.eval_a eval_a /*Metadata offset 0x65D840*/; // 0x0
internal static readonly eval_p.eval_a eval_b /*Metadata offset 0x65D948*/; // 0x100
}
查metadata导出数据
dat = r"global-metadata.dat"
with open(dat, "rb") as f:
f.seek(0x65D948)
eval_b = f.read(0x100)
print(eval_b.hex())
得到RC4的key为eval_b
eval_b = bytes.fromhex( "ca2c93b9b371dc2b9d91303bd6fc9836c246a8f4970481f2c6abd4635f996fb324eb4f62a450d693237315468e3fbc9e4f80c41a412b422690db81fac7c5dd24e3ac74989a1823d3b76ceccfc8033c52e0fc2066c2e6f37634de8cebc2686ab6c184f9b2d253673aacc519ee7da24702a378c7f40ca48e39f824ed91783e1aa15ed3018df351f11f3c1226440b42b566abf6f066769ab4c70c5ec2d07fd097204143d290082db6e0882bc5f838cb0740f4c2f6e5c2c3af86838e5317cf6d3678d9c1dea986c554e69b33d2bc35f0625b63715f261be571e05ef80d56ce2bf2f88187bd4f4cc310a167924ec7982e9786c3ddd5610f8d9249812b860d46e86ff5"
)
再次回到此前的流程
public static byte[] Generate()
{
// 固定明文string
string plain = "`?dZqfdo|&pM@Js^";
// ASCII Encode: string(UTF16) -> Bytes
byte[] data = ascii.GetBytes(plain);
// 使用静态密码进行RC4加密
byte[] encrypted = Game.Helper.Encrypt(pwd, data);
// 再次用 ASCIIEncoding 把 byte[] 转回 byte[]
return ascii.GetBytes(encrypted);
}
所以最终结果
seed = b"`?dZqfdo|&pM@Js^" #string -> bytes
encrypted = rc4(eval_b, seed)
final_key = encrypted.decode("ascii", errors="replace")
print(encrypted.hex())
print(final_key)
print(final_key.encode("ascii", errors="replace").hex())
输出得到
encrypted = 5f6c4efe3ae2238d9ab28666a81acbe8
final_key = _lN�:�#����f���
UnityCN key = 5f6c4e3f3a3f233f3f3f3f663f1a3f3f //跟表格里的数据一致。
但是为什么先解码再编码后结果出现了一些差异?
5f
6c
4e
fe #3f
3a
e2 #3f
23
8d #3f
9a #3f
b2 #3f
86 #3f
66
a8 #3f
1a
cb #3f
e8 #3f
发现有很多个字节应该是3f,但是却不对。其实final_key = _lN�:�#����f��� 这里出现的问号已经解释了原因:一些字节无法进行ASCII解码:
- 0x00–0x7F:合法,原样保留
- ≥ 0x80:无法表示 → 替换为
?(0x3F)
然后再次ASCII编码的时候,这些原先的非法字节已经都被替换了,原先合法的字节就原封不动了。
这也就解释了最后一步到底在干嘛。
return ascii.GetBytes(encrypted);
| 先看下这个方法的原型[ASCIIEncoding.GetBytes 方法 (System.Text) | Microsoft Learn](https://learn.microsoft.com/zh-cn/dotnet/api/system.text.asciiencoding.getbytes?view=net-8.0) |
ASCIIEncoding.GetBytes接受的参数只能是字符数组/字符串,这里输入的encrypted是byte[],应该是不合法的操作,但是为什么会发生呢?应该需要先进行数据类型转换。
其实这段是gpt反编译的,IDA反编译这一段失败了。

但是大致可以推测是因为il2cpp编译的时候优化了这个部分。从汇编的角度来看就只是操作寄存器了,也就是直接移动内存数据进行计算。
实际上应该是这样
string tmp = ascii.GetString(encrypted);
return ascii.GetBytes(tmp);
也就是byte[] -> string -> byte[]的一个过程。这里的两个映射规则遵从ASCII的解码/编码。
通俗地讲就是把目标数据进行了一次ASCII洗筛,把不能够通过ASCII解码的数据清洗为合法数据,然后复原。
总结
总结一下,大概就是
- 明文string 作为RC4的 seed
- metadata中取出一个约定的256b数据eval_b作为key
- 计算密文
encrypted = rc4(eval_b, seed) - 对密文进行ASCII洗筛得到 UnityCN的key
花亦山心之月
SetABEncryption <- SetAssetBundleDecryptKey <- SetAssetBundleKey


dump.cs

Unity内部的虚拟路径。

点击mono后需要导入DummyDLL还原数据


string EncryptKey = "INHJnhdypqk547xd"
string -> hex
494e484a6e68647970716b3534377864
###
Cocos
XXTEA
libcocos2djs.so搜索Cocos Game 后面跟着的第一个串(如果有)就是keywf-game-card
AppDelegate::applicationDidFinishLaunching(void)
cocos2d::Image::initWithImageData
FileUtils::fullPathForFilename
幻想名将录 - astc
key
这里得到的key并不重要,因为后续也用不到,只是用来反编译jsc使用。如果只需要获取spine,这里可以跳过
参考
关于Cocos2dx-js游戏的jsc文件解密(二) - 吾爱破解 - 52pojie.cn
关于Cocos2dx打包游戏的jsc文件解密(一) - 知乎
cocos引擎,xxtea加密
libcocos2djs.so搜索Cocos Game 后面跟着的第一个串就是keywf-game-card
77 66 2D 67 61 6D 65 2D 63 61 72 64 00 00 00 00 #自动填充到16 bytes
wf-game-card

或者IDA里面搜索xxtea相关的函数也能找到。
验证了论坛里的一个说法applicationDidFinishLaunching,大多都是在这里可以找到xxtea的key
AppDelegate::applicationDidFinishLaunching(void)

还原astc为png
从散落的文件里看到了atlas文件,所以是spine无疑了。然后bin文件就是二进制skel骨骼,直接改为skel后缀就能用了。
绝大部分文件都是astc,压缩且加密,没法直接转换,解压astc发现文件头变成了beeplay,说明第一步是解压,然后再去掉beeplay进行XOR解密
逆向libcocos2djs.so搜索astc以及image相关函数
cocos2d::Image::initWithImageData
发现检测文件头7bytes,就是检测beeplay签名
if ( !memcmp(cocos2d::Image::initWithImageData(unsigned char const*,long)::ENCRYPT_SIGNATURE, p[0], 7u) )
{
v8 = v4 - 7;
v9 = (unsigned __int8 *)malloc(v4 - 7);
memcpy(v9, v7 + 7, v4 - 7);
if ( v4 - 7 >= 1 )
{
if ( v8 < 0x20 )
{
v10 = 0;
do
LABEL_15:
v9[v10++] ^= 0x17u;
while ( v8 != v10 );
goto LABEL_16;
}
v11 = 0;
v10 = v8 & 0xFFFFFFFFFFFFFFE0LL;
v12.n128_u64[0] = 0x1717171717171717LL;
v12.n128_u64[1] = 0x1717171717171717LL;
do
{
v13 = (int8x16_t *)&v9[v11];
v14 = *(int8x16_t *)&v9[v11];
v15 = *(int8x16_t *)&v9[v11 + 16];
v11 += 32;
*v13 = veorq_s8(v14, v12);
v13[1] = veorq_s8(v15, v12);
}
while ( v10 != v11 );
if ( v8 != v10 )
goto LABEL_15;
}
}
这里大概就是说把beeplay文件头去掉后,直接对所有的字节XOR 0x17
伪astc文件
└─ GZIP解压
└─ beeplay ASTC
└─ 去 7 字节头
└─ XOR 解密
└─ 标准 ASTC
└─ astcenc 解码 = 正常astc文件
astc处理为正常格式后,还需要分类,

上面提到了很多种图片格式,这里统一处理为png格式的(spine的贴图是png的),这里只会处理astc
文件,skel 和 atlas不会处理,可以放心使用。后文会给出脚本。
资产分类/名称还原
所有文件的名称都是没什么规律的,没法进行分类,所以也没法通过名称找到同一个spine的三个文件。尽管可以尝试用atlas里面记载的图片名称和尺寸进行匹配,但是skel的匹配却别无他法。所以最优解法还是找到映射表。
根据上文找到的key,可以通过jsc反编译器jsc解加密工具-Orange.zip - 蓝奏云来查看源码index.jsc。

这里得到index.jsc,目前看来没什么用。推测是引擎自己使用的逻辑,有兴趣可以自行了解一下。
理论上来说通过config.json应该就可以搞定了,但是这里uuid里记载的都是22位的,实际文件基本都是36位,有少数是9位的。在config里面都是22位的根本找不到,应该还做了其他处理。
通过抓包发现,下载的本地manifest里面记载的也是22位数据,同时远程的manifest也是22位数据,所以说要处理的话只能在本地处理了。要去找到游戏内部的处理逻辑。
# 热更新
http://hotter-hxmjljp.wengames.com/japan/cardjapan-update/1.0.17.73/ios_bundle/assets/resources/import/51/5127c5e3-3e3e-4150-b8bf-904386ffe7fc.json?md5=b120aa6f54754ce2834e617c2ddc4e7f
# 本地manifest
http://hotter-hxmjljp.wengames.com/japan/cardjapan-update/1.0.17.73/ios_bundle/project.manifest
经过一天的瞎折腾,终于找到了答案:
不懂就问:UUID的压缩算法是怎么样的呢? - Creator 3.x - Cocos中文社区
有什么方法能从cocos creator构建的游戏里还原出live2d或spine动画资源? - 讨论 - Live2DHub
config.json里记载的uuid是22位的被压缩的uuid,而文件名称都是36位的uuid (不是hash)
处理顺序:读取config.json建表(path的key对应uuid的index)dictionary<uuid22, path>,遍历所有文件(uuid36 -> uuid22 -> path)找到对应path,重命名并移动。后文会给出处理脚本。
处理有点慢,要个5s吧。但是处理完发现还有大约900张图片是没有分类的,因为config里面根本没有对应的路径,只能找到uuid。
小人spines/panel/ 立绘spines/beauty 预览图icon/heroHead
到这里基本上就搞定了,接下来就是把spine相关文件的名称改为模型名称。
从马后炮的角度来看,其实path里面记载的路径比如"3": ["icon/heroHead/11068", 1],
最后一部分11068其实是文件名,icon/heroHead才是路径。这样可以理解为什么会有这样的"spines/panel/SG_SHU_OR_pangtong/SG_SHU_OR_pangtong"后面两段重复的路径了。
发现还缺了挺多东西的,不知道是不是鉴权,下面这几个角色有一大半没有。

从头像数量来看,应该有211个的spine,config的路径里面spines/beauty/spine/开头的有168个,分类后实际42个。
我找了一个没有的角色头像,同时也是未被分类的,计算出来的path应该是8790,而这里刚好没有。这就产生了一个疑点。

搜索武则天,在官网也能找到对应的立绘,但是本地只能找到小人,config里面也只有小人的相关文件,并没有立绘的。
实测游戏内可以看到立绘,而且是动态的。

在游戏里面切换立绘十分流畅,几乎可以肯定都是本地资源。
又经过了一番探索,发现了问题的端倪,APK里的只有500MB,而游戏体积占大头的一定是资产,所以这1.69G里面肯定有资产。

在/data/data/包名/这个路径下,remote目录

这里还有个很重要的表cacheList.json,一并保存下来。
打开remote发现有两个特别大的json文件,果不其然是config。为什么会有两个?
猜测因为把native和import放一起了,所以既有atsc bin atlas 还有json文件。
但是这俩玩意里面很多重复的条目,这个也无从分辨,不知是怎么考虑的,当前的策略是第一个config为主体,如何没有查到,就查另一个config。不过从结果来看,用那个size更大的config好像就行了,因为这个查不到的话,另一个也还是查不到。
注意到文件的命名都不是36位的uuid,而是类似17686595069780的一串数字。这里cacheList.json就发挥作用了,这里又是一层映射关系,url -> uuid36

然后接下来的处理方式跟之前一致。
三国云梦录 - XXTEA
cocos2d::HGameFileZip::getFileData
cocos2d::HGameFileZip::getFileDataEx
cocos2d::HGamePackFile::readFile
cocos2d::abc_decrypt
cocos2d::abc
得到
sub_E43DD4(src, 256, "k=%ds=%d%s", (_DWORD)this, a2, "9s07s26cs7r2449487rr8085sx7xa1c5");
v2 = (char *)calloc(1u, 0x21u);
md5_init(v5);
__strlen_chk(src, 0x100u);
md5_append((int)v5, src);
md5_finish(v5, v4);
sub_E43DD4(v2, 33, "%02x", v4[0]);
sub_E43DD4(v2 + 2, 31, "%02x", v4[1]);
sub_E43DD4(v2 + 4, 29, "%02x", v4[2]);
sub_E43DD4(v2 + 6, 27, "%02x", v4[3]);
sub_E43DD4(v2 + 8, 25, "%02x", v4[4]);
sub_E43DD4(v2 + 10, 23, "%02x", v4[5]);
sub_E43DD4(v2 + 12, 21, "%02x", v4[6]);
sub_E43DD4(v2 + 14, 19, "%02x", v4[7]);
sub_E43DD4(v2 + 16, 17, "%02x", v4[8]);
sub_E43DD4(v2 + 18, 15, "%02x", v4[9]);
sub_E43DD4(v2 + 20, 13, "%02x", v4[10]);
sub_E43DD4(v2 + 22, 11, "%02x", v4[11]);
sub_E43DD4(v2 + 24, 9, "%02x", v4[12]);
sub_E43DD4(v2 + 26, 7, "%02x", v4[13]);
sub_E43DD4(v2 + 28, 5, "%02x", v4[14]);
sub_E43DD4(v2 + 30, 3, "%02x", v4[15]);
return v2;
9s07s26cs7r2449487rr8085sx7xa1c5是salt
key生成流程:
def gen_key_hex(this_ptr: int, a2: int) -> str:
s = f"k={this_ptr}s={a2}9s07s26cs7r2449487rr8085sx7xa1c5"
return hashlib.md5(s.encode("ascii")).hexdigest()
xxtea也魔改了,太难了。
三国杀名将录跟这个基本一样。
对称加密/非对称加密
decrypt
IV
Key
哥特少女勇闯恶魔城2 Sinisistar2 - AES - Rijindael
其实隔壁已经给出答案了,但我还是挺好奇是怎么来的,花了几天看了下。仅供参考。
参考文献
https://live2dhub.com/t/topic/4420/13?u=twistzz
https://learn.microsoft.com/zh-cn/dotnet/api/system.security.cryptography.aes?view=net-8.0
电报的资源
https://t.me/huangyou_A/99826
都是序列帧动画,然后基本都是拆分成好几个部分了(比如表情和脸),要看的话只能自己去Unity拼图了。
建议用Raz版AS导出,会跳过重复的资产。
虽然解决的不是很干净利索,但也算是完工了,抛砖引玉下吧。


确定加密
搜索decrypt可以发现有很多个加密/解密相关的信息。
有DES RC2 3DES Rijindael AES这几种加密算法的信息,前面这三个都过时了,很显然应该使用的是后面这两个算法,不过也得看引用情况,说不准是混淆之类的。

这里区分一下Rijindael和AES的关系:
| 项目 | Rijndael | AES |
|---|---|---|
| 算法本体 | 原始算法 | Rijndael 子集 |
| 分组长度bit | 128–256 可变 | 仅 128 |
| 密钥长度bit | 128–256 可变 | 128 / 192 / 256 |
| 轮数 | 依参数变化 | 固定(10/12/14) |
这些Decryptor都是一些工具方法,而不是实际进行数据处理过程,所以需要看System_Security_Cryptography_RijndaelManagedTransform$$DecryptData以及Mono_Security_Cryptography_SymmetricTransform$$FinalDecrypt
这个FinalDecrypt不难猜到应该是对最后一个块的padding进行处理。
DecryptData 反编译后就有将近900行了,看不了一点,大致可以确定是标准的Rijindael算法,还需要判断一下工作模式,填充处理。
看到输入的参数里有paddingMode,以及fLase (应该是判断是否为Last,也就是最后一块)

接下来寻找工作模式,值得一提的是,既然需要选择padding的模式,也就说明工作模式肯定是需要padding的,对应的也就是CBC、ECB、PCBC这三种,根据经验来看,默认是CBC了。
接下来观察到这样的两个分支,很显然对2和4两个模式做了专门的处理,是PKCS7和ANSI X923。
| 名称 | 值 | 说明 |
|---|---|---|
| ANSIX923 | 4 | ANSIX923 填充字符串由一个字节序列组成,此字节序列的最后一个字节填充字节序列的长度,其余字节均填充数字零。 |
| ISO10126 | 5 | ISO10126 填充字符串由一个字节序列组成,此字节序列的最后一个字节填充字节序列的长度,其余字节填充随机数据。 |
| None | 1 | 不填充。 |
| PKCS7 | 2 | PKCS #7 填充字符串由一个字节序列组成,每个字节填充该字节序列的长度。 |
| Zeros | 3 | 填充字符串由设置为零的字节组成。 |
if ( paddingMode != 1 )
{
if ( paddingMode == 2 )
{ ... }
//...
if ( paddingMode != 3 )
{
this = (System_Security_Cryptography_RijndaelManagedTransform_o *)(unsigned int)(paddingMode - 4);
if ( paddingMode == 4 )
{ ... }
接下来只要获取IV和Key就可以尝试破解了。搜索关键词key

发现这个keyExpansion出现的很频繁,但是找不到IV相关的定义,可能是由其他变量名替代了。
System_Security_Cryptography_RijndaelManagedTransform_o *v11; // rbx
v11 = this;
//...
m_decryptKeyExpansion = v11->fields.m_decryptKeyExpansion;
跳转到fields定义
struct System_Security_Cryptography_RijndaelManagedTransform_o // sizeof=0x80
00000000 {
00000000 System_Security_Cryptography_RijndaelManagedTransform_c *klass;
00000008 void *monitor;
00000010 System_Security_Cryptography_RijndaelManagedTransform_Fields fields;
00000080 };
struct __declspec(align(8)) System_Security_Cryptography_RijndaelManagedTransform_Fields // sizeof=0x70
00000000 { // XREF: System_Security_Cryptography_RijndaelManagedTransform_o/r
00000000 int32_t m_cipherMode;
00000004 int32_t m_paddingValue;
00000008 int32_t m_transformMode;
0000000C int32_t m_blockSizeBits;
00000010 int32_t m_blockSizeBytes;
00000014 int32_t m_inputBlockSize;
00000018 int32_t m_outputBlockSize;
0000001C // padding byte
0000001D // padding byte
0000001E // padding byte
0000001F // padding byte
00000020 struct System_Int32_array *m_encryptKeyExpansion;
00000028 struct System_Int32_array *m_decryptKeyExpansion;
00000030 int32_t m_Nr;
00000034 int32_t m_Nb;
00000038 int32_t m_Nk;
0000003C // padding byte
0000003D // padding byte
0000003E // padding byte
0000003F // padding byte
00000040 struct System_Int32_array *m_encryptindex;
00000048 struct System_Int32_array *m_decryptindex;
00000050 struct System_Int32_array *m_IV;
00000058 struct System_Int32_array *m_lastBlockBuffer;
00000060 struct System_Byte_array *m_depadBuffer;
00000068 struct System_Byte_array *m_shiftRegister;
00000070 };
发现了IV的信息。接下来只要找到对应的赋值语句了。
最简单的方式就是去构造函数里面看看
搜搜RijndaelManagedTransform 找到ctor 和 cctor,然后依次向上寻找调用点以及对应的ctor,尽量找相关联的,比如这里有encrypt和decrypt,肯定优先看decrypt
依旧只是调用,不能直接找到赋值。
继续向上。。。XREF为o以及行数是1的不用看
运气不太好,这些相关的函数都没有实质性的信息。
其实也没有太多固定的方法,大多数时候要点运气,后来在dump.cs里面搜到了一个Util类

然后可以看下Util类的方法
System_Security_Cryptography_RijndaelManaged_o *Util__get_aesManaged(const MethodInfo *method)
{
Util_c *v1; // rcx
System_Security_Cryptography_RijndaelManaged_o *v2; // rbx
struct Util_StaticFields *static_fields; // rdx
void *aes; // rcx
struct System_Security_Cryptography_RijndaelManaged_o *v5; // rbx
System_Text_Encoding_o *UTF8; // rax
__int64 v7; // rax
struct System_Security_Cryptography_RijndaelManaged_o *v8; // rbx
System_Text_Encoding_o *v9; // rax
__int64 v10; // rax
if ( !byte_1830E4F1F )
{
sub_18038C080(&System_Security_Cryptography_RijndaelManaged_TypeInfo);
sub_18038C080(&Util_TypeInfo);
byte_1830E4F1F = 1;
}
v1 = Util_TypeInfo;
if ( !Util_TypeInfo->static_fields->aes )
{
Util__Update(0);
v2 = (System_Security_Cryptography_RijndaelManaged_o *)sub_18033E930(System_Security_Cryptography_RijndaelManaged_TypeInfo);
System_Security_Cryptography_RijndaelManaged___ctor(v2, 0);
Util_TypeInfo->static_fields->aes = v2;
sub_18038B400(&Util_TypeInfo->static_fields->aes, v2);
aes = Util_TypeInfo->static_fields->aes;
if ( !aes )
goto LABEL_14;
(*(void (__fastcall **)(void *, __int64, _QWORD))(*(_QWORD *)aes + 552LL))(
aes,
256,
*(_QWORD *)(*(_QWORD *)aes + 560LL));
aes = Util_TypeInfo->static_fields->aes;
if ( !aes )
goto LABEL_14;
(*(void (__fastcall **)(void *, __int64, _QWORD))(*(_QWORD *)aes + 424LL))(
aes,
256,
*(_QWORD *)(*(_QWORD *)aes + 432LL));
aes = Util_TypeInfo->static_fields->aes;
if ( !aes )
goto LABEL_14;
(*(void (__fastcall **)(void *, __int64, _QWORD))(*(_QWORD *)aes + 584LL))(
aes,
1,
*(_QWORD *)(*(_QWORD *)aes + 592LL));
v5 = Util_TypeInfo->static_fields->aes;
UTF8 = System_Text_Encoding__get_UTF8(0);
if ( !UTF8 )
goto LABEL_14;
v7 = ((__int64 (__fastcall *)(System_Text_Encoding_o *, struct System_String_o *, const MethodInfo *))UTF8->klass->vtable._17_GetBytes.methodPtr)(
UTF8,
Util_TypeInfo->static_fields->IV,
UTF8->klass->vtable._17_GetBytes.method);
if ( !v5 )
goto LABEL_14;
((void (__fastcall *)(struct System_Security_Cryptography_RijndaelManaged_o *, __int64, const MethodInfo *))v5->klass->vtable._10_set_IV.methodPtr)(
v5,
v7,
v5->klass->vtable._10_set_IV.method);
v8 = Util_TypeInfo->static_fields->aes;
v9 = System_Text_Encoding__get_UTF8(0);
aes = Util_TypeInfo;
static_fields = Util_TypeInfo->static_fields;
if ( !v9
|| (v10 = ((__int64 (__fastcall *)(System_Text_Encoding_o *, struct System_String_o *, const MethodInfo *))v9->klass->vtable._17_GetBytes.methodPtr)(
v9,
static_fields->K,
v9->klass->vtable._17_GetBytes.method),
!v8)
|| (((void (__fastcall *)(struct System_Security_Cryptography_RijndaelManaged_o *, __int64, const MethodInfo *))v8->klass->vtable._12_set_Key.methodPtr)(
v8,
v10,
v8->klass->vtable._12_set_Key.method),
(aes = Util_TypeInfo->static_fields->aes) == 0) )
{
LABEL_14:
sub_18038C2D0(aes, static_fields);
}
(*(void (__fastcall **)(void *, __int64, _QWORD))(*(_QWORD *)aes + 616LL))(
aes,
2,
*(_QWORD *)(*(_QWORD *)aes + 624LL));
v1 = Util_TypeInfo;
}
return v1->static_fields->aes;
}
这里的几个数字很有趣

aes的数据类型一路溯源可以找到
struct __declspec(align(8)) System_Security_Cryptography_SymmetricAlgorithm_Fields // sizeof=0x38
00000000 { // XREF: System_Security_Cryptography_SymmetricAlgorithm_o/r
00000000 // System_Security_Cryptography_Aes_Fields/r ...
00000000 int32_t BlockSizeValue;
00000004 int32_t FeedbackSizeValue;
00000008 struct System_Byte_array *IVValue;
00000010 struct System_Byte_array *KeyValue;
00000018 struct System_Security_Cryptography_KeySizes_array *LegalBlockSizesValue;
00000020 struct System_Security_Cryptography_KeySizes_array *LegalKeySizesValue;
00000028 int32_t KeySizeValue;
0000002C int32_t ModeValue;
00000030 int32_t PaddingValue;
00000034 // padding byte
00000035 // padding byte
00000036 // padding byte
00000037 // padding byte
00000038 };
按照偏移量也就是顺序来看:
256只能是Keysize或者是BlockSize,得出的信息是BlockSize = 256 bit ` Keysize = 256bit `
因为CipherMode.CBC = 1,所以对应工作模式CBC
| 名称 | 值 |
|---|---|
| CBC | 1 |
| CFB | 4 |
| CTS | 5 |
| ECB | 2 |
| OFB | 3 |
所以Rijndael-CBC-256

aes.IV = Encoding.UTF8.GetBytes(Util.IV);
aes.Key = Encoding.UTF8.GetBytes(Util.K);
IV 和 Key 来源是 字符串 ,使用 UTF-8编码把ASCII字符转为字节
所以可以确定原始的IV和KEY的长度跟转化后是一致的,32B也就是32个字符,这里需要记住长度,后面会用到。
然后最后这个2毫无疑问对应的是padding了
PaddingMode.PKCS7 = 2所以填充方式是PKCS7
完整的加密方式为:Rijndael-256-CBC-PKCS7
寻找 IV 和 KEY
接下来只需要找出静态域的Key和IV了
继续寻找Util相关函数,发现没有ctor,但是有一个Update完成了初始化数据。
void Util__Update(const MethodInfo *method)
{
Il2CppObject *object; // rax
__int64 v2; // rdx
__int64 v3; // rcx
Il2CppObject *v4; // rbx
struct System_String_o *v5; // rax
struct System_String_o *v6; // rax
if ( !byte_1830E4F20 )
{
sub_18038C080(&Method_UnityEngine_Resources_Load_Unique___);
sub_18038C080(&Util_TypeInfo);
sub_18038C080(&StringLiteral_6649);
sub_18038C080(&StringLiteral_10860);
sub_18038C080(&StringLiteral_6614);
byte_1830E4F20 = 1;
}
if ( System_String__IsNullOrEmpty(Util_TypeInfo->static_fields->IV, 0) )
{
object = UnityEngine_Resources__Load_object_(StringLiteral_10860, Method_UnityEngine_Resources_Load_Unique___);
v4 = object;
if ( !object )
sub_18038C2D0(v3, v2);
v5 = System_String__Concat_6464675520((System_String_o *)object[2].klass, StringLiteral_6614, 0);
Util_TypeInfo->static_fields->IV = v5;
sub_18038B400(Util_TypeInfo->static_fields, v5);
v6 = System_String__Concat_6464675520((System_String_o *)v4[2].monitor, StringLiteral_6649, 0);
Util_TypeInfo->static_fields->K = v6;
sub_18038B400(&Util_TypeInfo->static_fields->K, v6);
}
}
查看汇编视图直接得到了这几个常量的字符值。其中两个数字和字母组成的串已经在dump.cs里面看过了。

可以初步得到的信息:
object = UnityEngine_Resources__Load_object_("Manager/unique", Method_UnityEngine_Resources_Load_Unique___);
K = object[2].monitor + "06789412023ED45";
IV = object[2].klass + "01127802CDEF00BC";
# Il2CppObject是某一个没有确定的类型,所以取了一个笼统的名字object
00000000 struct Il2CppObject // sizeof=0x10
00000000 { // XREF: MidasTouch_State_array/r
00000000 // UnityEngine_GameObject_array/r ...
00000000 Il2CppClass *klass; // XREF: sub_180248F60+48/w
00000008 void *monitor; // XREF: sub_180248F60+4D/w
00000010 };
object应该是从这个路径"Manager/unique"上加载的一个文件,同时Method_UnityEngine_Resources_Load_Unique___这里说明了类型是<Unique>
以及拼接使用的字符值偏移量20h,28h

可以在dump.cs找到Unique定义,发现这里的偏移量也是20和28,正好对应上了。
// Namespace:
public class Unique : MonoBehaviour // TypeDefIndex: 673
{
// Fields
public string x; // 0x20
public string y; // 0x28
// Methods
// RVA: 0x3FA3D0 Offset: 0x3F97D0 VA: 0x1803FA3D0
public void .ctor() { }
}
x -> klass
y -> monitor
可以进一步细化结果
K = y + "06789412023ED45"; # y是 32-15 = 17个字符
IV = x + "01127802CDEF00BC"; # x是 32-16 = 16个字符
所以接下来只需要找到这个unique文件即可。
但是对于Unity而言Resources资源存储在resources.assets、resources.assets.resS、sharedassets0.assets、sharedassets0.assets.resS等文件中,不确定最好全部导AS里面,然后查找。
大概就是这些玩意

设置里需要打开Display all assets然后导入文件,不然看不到unique。

https://live2dhub.com/t/topic/5678/7?u=twistzz
这里之前没有x和y的值,因为之前选择Monobehaviour时候AS会弹出一个框让你选择assembly加载dll还原数据,之前没想太多就关了
顺利导入后就会正常显示x和y

或者导出原始数据查看。 https://live2dhub.com/t/topic/5678/9?u=twistzz



CE搜索内存
用IDA调试的时候游戏里一读档就一直报错卡异常,无语了。
突然发现可以用CE硬搜,前文已经得出了两个关键的拼接串,所以可以用CE直接搜索内存,但是不知道为什么这个CE没有搜索子串的方法(也可能是我没找到),只能搜索精确值。
然后可以枚举一下,因为这里的IV和KEY都是 32个字符
所以IV需要枚举前16位,KEY需要枚举前17位
其实最多也就 16 * 16 + 16 * 17 = 528 次就能搞定。
比如这里搜01127802CDEF00BC会显示有50个地址。(注意搜索的值类型)

然后向前添加一位,尝试0~F,发现添加0之后还是可以搜索到50多个地址,但是添加1就什么都没有,可以说明添加0是对的。
如果我们搜到了同一个串,那么每次添加一个位,地址应该是邻近的。
这里保存下前三个搜索结果,然后重新进行搜索:

发现地址确实跟假设的一样,是相邻的。不妨大胆一点,直接算一下地址,这里已经找到了1位,所以还剩15位,也就是地址偏移0x0F
0x1CA76F2607F - 0x0F = 0x1CA76F26070
手动添加一下地址0x1CA76F26070 ,这里自动读出来了内容。

成功得到了IVD9AB89AA56F5673001127802CDEF00BC
同理可得KEY0BFAB106A793DCA7F06789412023ED45
0x1C98502A9E0 - Ox10 = 0x1C98502A9D0

虽然有点丑陋,不过好在没有烂尾。
CE-Mono
CE对Unity的程序有额外支持,附加之后在顶部会额外出现一个mono选项



可以选择实例

双击ctor,进入内存视图然后CTRL + F 搜索数值。

总结
- 确定加密方式Rijndael-256-CBC-PKCS7
- 找到IV和KEY的生成方式
- CE
Rijndael-256-CBC-PKCS7
IVD9AB89AA56F5673001127802CDEF00BC
KEY0BFAB106A793DCA7F06789412023ED45
注意这里的IV和KEY需要用UTF-8编码
aes.IV = Encoding.UTF8.GetBytes(Util.IV);
aes.Key = Encoding.UTF8.GetBytes(Util.K);
之前用py3rijindael来算,发现太慢了这也,byd满载跑了一晚上快8个小时,最后一个50M的文件跑了一个小时还没跑出来,不建议使用。
Rijindael.py
用pythonnet甚至不要10分钟🤡🤡🤡
其他
Spine合并
Spine文件怎么把文件和背景合二为一 - 讨论 - Live2DHub

Spine骨骼解析
- skeleton
- bones
- slots
- skins
- animations
"skeleton": {
"hash": "5WtEfO08B0TzTg2mDqj4IHYpUZ4", //类似 MD5,用于检测数据是否变化
"spine": "3.8.24",
"x": -17.2, //AABB盒原点坐标(x,y)
"y": -13.3,
"width": 470.86, //AABB盒宽高
"height": 731.44,
//"fps": 30
//"images": "./images/",
//"audio": "./audio/"
},
Spine.Animation$$Deserialize
AseetStudio Fork开发
环境设置
IDE为Rider
使用.NET8.0 开发
MSbuild 选择IDE自带的17.0版本。

配置选择GUI,设置为.NET8.0 后启动。

Comments