Lapis Re:LiGHTs 资源解析

为什么我要拆这个游戏……?拆完之后我陷入半天贤者 time。


这游戏我也就发行那三天蝗了一下,之后就再也没玩过,也并不觉得特别有趣,估计以后也不会继续再玩了。但不知道为什么总是挂在心上,大概是因为蝗的时候抽了个 Garnet,总觉得人设画风很熟悉,但又想不起来到底是谁,好像是秋回8的哪个画师来着?总之我想半天也没想出为啥我要拆这个复杂得要死的玩意……

总之拆了就拆了吧,当个练手。


首先还是打开游戏资源文件夹找线索。然而我一开始找资源文件夹的时候脑抽了,在 /data/data/com.klab.lapis 里面翻了半天没找到半个文件,甚至跑去安装包文件夹里去翻了半天也没找到,最后通过 hook 读取资源文件的函数才找到原来是在 /sdcard/Android/data/xxxx 里,简直太zz了……居然没一开始想到这个地方。

进入资源文件夹,直接就有一个叫 manifest.xml 的文件生怕你找不到它。虽然不抱希望还是先打开看看,果然加密了。OK,把字符串拿到 stringliteral 里一搜就出来了,通过向上追踪并结合 dnspy 找了一会,发现 Oz_GameKit_Version_PackageManifest_Serializer__WriteToStream 很明显就是解密 manifest 的地方,于是开工。

粗略研究一下,解密步骤如下:

  1. 读取 manifest.xml 文件
  2. 对文件流使用 Rijndael 解密
  3. 对解密流使用 ICSharpCode.SharpZipLib.Gzip$$GZipInputStream 解压缩
  4. 对解压缩流使用 System.Runtime.Serialization.Formatters.Binary.BinaryFormatter$$Deserialize 获得 packageManifest 指针,直接作为 C# 类使用

看到最后一步我震惊了。卧槽 BinaryFormatter!!

这个玩意不仅因为毫无跨语言移植性,而且最近好像还因为有什么安全隐患被 MS 定为不推荐使用了,这个组居然用这玩意存数据!

当然这样的好处就是,使得解析变得更加困难。因为解析者必须要有整个原始类的结构才能把数据恢复成 C# 类。


好吧,既然对面已经用了这玩意,那么我们只有接招了。首先想到的是两个方案:

  1. 和正规流程一样,采用 BinaryFormatter 解序列化。这首先要求解密脚本的语言肯定只能选 C# 了,然后还必须想办法恢复原始类的结构,以及其内部包含的所有相关类的结构。
  2. 通过研究 BinaryFormatter 的序列化算法自己重写一个方法,直接读取 GZip 的解压缩流强行进行反序列化。

这两个方法看上去工作量都不小啊……

然后我突然想到个玄学方法,我们不是有 DummyDll 吗,虽然是 dummy 但类的结构应该是完整的没问题的。

想到了总之先试一下吧。于是我把 dll 引用加进去,把脚本写好开始测试。

结果果然报错了。但这时我没有对这个报错研究太多,本来就觉得这条路太玄了没报什么希望,所以直接就丢一边了。

于是开始研究第二种方法。把 gzip 解压缩出来的文件打开,研究了半天各字符串之间的间隔、标识符等规律,发现更看不懂,甚至去 .NET 源码库把 BinaryFormatter 的源码翻出来看了下,发现实在太复杂了,于是第二条路卒。


万策尽きたぁぁぁ!!!


怎么办,没招了。

于是我漫无目的地又跑了一遍方案1的脚本,结果看到报错信息之后眼睛一亮

image

注意关键词:PublicKeyToken=null
How could this be?我们知道 System.dll 是.NET 最重要的库,它不可能没有 PublicKeyToken。那么这里为什么会为 null?

我的一个猜想是,Il2cppDumper 生成 dummyDll 时,会自动把其引用的所有 dll 的这个标识符设置为 null,虽然正常情况下这是不可能的,但生成的 dll 本身就是 “dummy”,其目的本身就仅用于研究而非实际使用,所以为了方便把它设置为了 null。

既然这样,那么我们只要把 dll 的 PE 头里相应的标识改过来就行了。


打开 dnspy,定位到相应的地方,发现这个值果然是 0。把它改为 1,保存。

image

回到脚本,测试运行。我本来以为这次会报其他错误,比如其他 dll 的 PublicKeyToken 为 null 之类的,没想到居然直接就成功了。(我:???
好吧,既然成功了那么我也懒得追究下去了。于是我兴奋地打开调试器查看是否真的反序列化成功,发现

image

看上去非常棒!
然而正当我以为一切都已搞定之时,我随便打开了一个 AssetBundleInfo 查看却发现

image

Huh?我又懵逼了。
为什么全都像是没有被初始化成功一样?一开始我还以为是数据不是存放在 manifest.xml 里面,而是反序列化之后在某个地方重新赋的值。然而当我翻遍了程序却没有发现半个踪影之后,我决定不管 manifest 了,直接走捷径,然而——

image

很明显每个 AssetBundle 的 key 都不一样,于是捷径也卒了。还是只有回来老老实实继续研究 manifest。
可是,我已经翻遍了程序都找不到赋值的地方。那么 key 到底是否存在于 manifest 里?于是我把 hook 得到的几个 key 复制到 manifest 里去查找,发现 key 确实存在于 manifest 里。

那么到底是什么原因导致了反序列化失败?这个原因一开始我完全没有找到头绪,于是放置了一段时间,后来通过一个偶然联想到了原因。


那是有一天我在查别的资料时,偶然间看到了“属性”和“字段”这两个关键字。

我猛然想到,那些反序列化成功的数据和失败的数据,会不会分别是字段和属性?于是我把脚本打开调试,发现确实是这样。

那么原因就很明确了。字段之所以能获取成功,是因为 BinaryFormatter 会直接给它赋值,而属性之所以失败,是因为会通过它的访问器来进行的赋值,而 Il2cppDumper 生成的访问器里面都是空的。所以我们需要修改访问器里的代码。

一开始我本来想直接通过 dnspy 修改 C# 层面的代码,然而在编译时通不过,是因为 C# 代码无法使用<>这个符号作为变量名,所以没办法只有修改更低级的 IL 代码。虽然我对 IL 一窍不通,不过复制粘贴还是会的嘛!自己新建一个 .NET 项目,新建一个类写几个属性在里面,编译后用 dnspy 打开查看 IL 码,再照着抄到 Assembly-CSharp.dll 里就行了。结果如下。

访问器:

1
2
3
0000    ldarg .0
0001 ldfld int32 Oz.GameKit.Version.AssetBundleInfo::'<EncryptKey>k__BackingField'
0006 ret

赋值器:

1
2
3
4
0000    ldarg.0
0001 ldarg.1
0002 stfld int32 Oz.GameKit.Version.AssetBundleInfo::'<EncryptKey>k__BackingField'
0007 ret

改好之后保存模块,放到项目引用中,运行。

image

Here you go.


manifest 搞定,开始研究 AssetBundle 解密。

System_IO_FileStream__Read 上进行交叉链接直接就能找到 Oz_GameFramework_Runtime_Resource_AssetBundleStream__Read,这里便是解密的方法。

点进去之后发现这个方法只是处理了 IO 和一些简单的运算,关键的解密函数体并不在其中,一路跟踪下去会发现调用了方法

image

明显是调用了其他 native 库。往上能够找到

image

可以发现是调用了 Lapis 这个库的 _Lapis_Decrypt 这个函数。
于是从 APK 解压出来的 so 库里找到 liblapis.so,用 IDA 打开,定位到 _Lapis_Decrypt,发现

image

好吧……我就知道没这么轻松。
现在有两条路可走,找到解密方法或者 dump 内存。我当然毫不犹豫地选择了 dump 内存这条捷径(

一阵 dump 后,开始研究混淆算法。

这游戏资源混淆算法有 4 个关键变量。一是 key,由 manifest 里提供;二是 step,指混淆的字节跳数,由 | key % 10 | + 1 获得;三是一个用于混淆的字节,由 | key / 10 % 10 | + 1 获得;四是一个用于混淆的正整型数,这个基本上是固定的不需要管。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int key_mod = key % 10;
if (key % 10 < 0) {
key_mod = -key_mod;
}
int step = key_mod + 1;

v5 = key / 10 % 10;
if (v5 < 0)
v5 = -v5;
while (1)
{
v7 = start >= size ? 905677402 : -1904922419;
if (v7 != -1904922419)
break;
v6 = 1103515245 * startIdx + 77880;
if (1103515245 * startIdx + 12345 >= 0)
v6 = 1103515245 * startIdx + 12345;
*(result + start) ^= key >> (BYTE2(v6) - (HIWORD(v6) & 0x7FFF)
/ (unsigned int)(v5 + 1) * (v5 + 1));
++startIdx;
start += step;
}

这里有一个坑。从 IDA 的伪码中看到的取负数是用的 ~ 取反符号。一开始我照着搬过来,却发现当 key 为正数时完全正常,但 key 为负数时 step 和解密结果都是错误的。进一步研究发现当 key 为负数时 step 和变量 3 都与正确值差 1。我猜测会不会是因为 ARM 和 x86 构架的处理器保存负数的方法不一样,一个是反码一个是补码,所以取反的时候两个会差一。总之在这里把 ~ 改成 - 就可以了。

搞定后一开始想把 C++ 的解混淆方法用 C# 重写一遍,后来懒了就直接就编译成 dll 引用了。


最让人失望的是,解出来发现这游戏卡面是分为背景和角色两个部分,在游戏内去合成的。

image

当然可以通过继续研究合成方法,然后写个程序去自动实现合成,但我已经不想继续研究了,反正也不会玩的(
另外,虽然把 AssetBundle 解了出来,但那些文字类型的 AssetBundle,比如 text 和 lua 脚本,还进行了第二次加密。所以从 AssetStudio 里看到的都是乱码。这个解密方法在 IDA 里面能找到,但我也懒得搞了,本来对这游戏剧情和游戏系统也没什么兴趣,留给想搞的人搞吧。


轮子地址:https://github.com/MalitsPlus/HoshimiToolkit

一开始考虑要不要新建一个 repo,想了下每次都新建一个好麻烦,以后就把逆向成果都丢这里边好了。

 Comments
Comment plugin failed to load
Loading comment plugin