Link Like Love Live (蓮ノ空) 逆向解析

本文仅对游戏在各部件上进行的加密以及序列化方法进行讨论,暂不提供轮子。

其实一开始我是拒绝的。

「什么?莲?哦ぁじ啊…那算了」

一切的开始是去年年底的异次元 Live,虽然我早已退坑了爱马仕,当时也不是ぁじ人,但毕竟是这样一个前所未闻的联动 Live,作为爱抖露厨怎么可能放过这个见证历史的时刻。虽说如此但 motivation 也没有高到想跑老远去现场参战,于是便折衷 remote 参加。

然后我就遇见命运中的新妈妈了。

不,那啥,我还是要澄清一下,我推莲是因为在 Live 首次听到「眩耀夜行」这曲时大受震撼,绝对不是因为被のんすけ天真烂漫的可爱和うい様忧郁的眼神所拉进去了,不是哦真的不是,肉体厨怎么可能嘛对吧啊哈哈。

嗯…扯远了,闲话休题。
LLLL 这游戏 (游戏?) 在开服时我有过尝试,但游戏的谜之 Live 机制以及类似 VTB 的直播功能让我很不适应,开服当天试过就丢掉了。但经过异次元 Live 后产生了兴趣,现在捡回来主要用于看主线剧情以及偶尔看看直播。Live 之类的使这个 App 成为一个游戏的机能完全没碰,但对游戏的卡面和数据还是有兴趣的。于是在浩大的因特网上搜了一圈儿发现竟然没有人拆,虽然抱着「这可是ぁじ啊?居然隔了这么久都没人拆??」这样的疑惑,但还是自力更生了起来。

本文照惯例从 Manifest、Assets、MasterDB、通信 四个方面对游戏进行分析。

Dump

首先按照解 Unity 国际惯例使用 il2cppdump 时会报错。
报错的原因是因为游戏中使用到的 Concentus.dll 里似乎有一些无法被识别到的 token,猜测应该是 il2cppdump 自身的 bug。
简单搜索了一下这个库,发现是个音频的编解码器库,很明显对于游戏的解密无关紧要,所以我采用的方法是直接修改 il2cppdump 源码,使其跳过这个库的分析:

DummyAssemblyGenerator.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
foreach (var imageDef in metadata.imageDefs)
{
var imageName = metadata.GetStringFromIndex(imageDef.nameIndex);
var typeEnd = imageDef.typeStart + imageDef.typeCount;
for (int index = imageDef.typeStart; index < typeEnd; index++)
{
var typeDef = metadata.typeDefs[index];
var typeDefinition = typeDefinitionDic[typeDef];
var fieldEnd = typeDef.fieldStart + typeDef.field_count;
for (var i = typeDef.fieldStart; i < fieldEnd; ++i)
{
...
if (imageName == "Concentus.dll") {
continue; // simply ignore it
}
...
}
...
}
}

然后就是万年不变的那套操作了。

本地文件

游戏内部文件在 /sdcard/Android/data/com.oddno.lovelive/files,其中有两个叫做 DM 的文件夹,简单猜测一下就能猜出 D 是 data,存放游戏的 assets 文件,M 是 metadata,存放 manifest 和数据库文件。
这些文件夹里所有的文件名都经过了 base32 编码。首先看看我们最关心的 metadata。

M 文件夹里有三个文件,分别是 CatalogCacheIdentitySQLiteDB

然后这里是最有意思的地方:这三个文件都被经过了加密处理,并且文件名和 key 会在游戏安装后初次启动时或者清除数据后初次启动时随机变化。
这也就是说,安装在不同设备上的这些文件的 key 都是不同的。并且即便是同一设备,清除数据之后的 key 也将会变化。
这种非普遍性使得研究本地储存的文件的解密方式变得失去了意义,毕竟每台设备的 key 都不同,作为解密工具来说不具有通用性,所以到这里我就停止了继续研究本地储存,开始从通信下手。

这些逻辑可以在 Core.dllLocalDataStorageProvider 这个类的初始化方法中找到。流程是:
计算字符串 “app” 的 HMACSHA1,将得到的 bytes 通过 base64 编码,作为 UnityEngine.PlayerPrefs 的 key;再使用 RNGCryptoServiceProvider 生成长度 256 的随机字节,同样经过 base64 编码,作为 UnityEngine.PlayerPrefs 的 value,保存在 persistent storage 中。这个值作为 Seed 只要不清除数据,就会永远保存在本地储存中供后续使用。

通信

上边提到的这些文件虽然缓存到本地时使用了不同的 key 进行加密,但从网络获取时一定会得到相同的结果,否则 oddnum 必须得造 256 ^ 256 个文件,这显然是不科学的。

通过抓包可以观察到新安装后的游戏首次登陆会 POST 一条内容几乎为空的消息到服务器,服务器返回的 header 里有一个之后将会使用到的 x-res-version,客户端会以这个 header 的值为参考,下载对应版本的 Manifest,再通过读取 Manifest 下载其余的 assets。

image

顺带一提,API 通信没什么好说的,直接 application/json 明文。

Manifest

获取 Path

以上面提到的 x-res-version 为例:

R2402010@B/FicABV0d3BUb8PQHvXSsDwHw==

通过分析可以得到,这段字符串以 @(0x40u) 作为 splitter,前半 R2402010SimpleResver,后半段是 base64 编码后的二进制数据,以这里数据为例,解码后得到:

07F162700055D1DDC151BF0F407BD74AC0F01F

这一小段二进制数据中包含了从获取到解密的所有 Manifest 相关的信息。
分析一下结构:

  1. 07F162700055D1DD
    checksum,8 字节

  2. C151BF0F407BD74A
    Seed,8 字节

  3. C0F01F
    Size,VLQ,变长字节

从以上信息衍生出:

  1. labelCrc = Crc64(SimpleResver) = E0D8673BCC7D7A04

  2. Size = ReadVLQ(C0F01F) = 7F840 = 522,304

研究到这里时我碰到了第一个坑。

CRC64 算法有非常多种标准,每种标准采用了不同的 polynomial,最终计算出来的结果也都不同。其中游戏里采用的 polynomial 是一个常量可以直接逆向得到是:0x42F0E1EBA9EA3693,对应的标准是ECMA-182。游戏里的逻辑是通过这个 polynomial 计算出 lookup table,使用查表法加速运算。
我尝试过使用原版、Reversed、Reciprocal、Reversed reciprocal 的 polynomial,没有一个得到正确答案。
深入研究后发现,目前主流的 CRC64 算法会默认将初始状态置为 0xFFFFFFFFFFFFFFF,同时将输入的字节全部取反后再进行计算;在计算完成后会将结果与 0xFFFFFFFFFFFFFFF 进行异或,再将结果取反后返回。
而游戏里的 CRC64 算法完全没有进行这些操作。
也就是说,现有的 CRC64 库无法直接使用,需要咱手动写一个。

嘛,幸好写起来很简单。我采用的方法是先 dump 内存获得 lookup table,再参考 Linux Kernel 的算法使用这个 table 进行计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func UpdateCrc64(crc uint64, buf []byte, len int, tb *crc64.Table) uint64 {
if tb == nil {
tb = lookupTable
}
for i := 0; i < len; i++ {
idx := byte(crc>>56&0xff) ^ buf[i]
crc = tb[idx] ^ (crc << 8)
}
return crc
}

var (
lookupTable = &crc64.Table{
0x0000000000000000,
0x42F0E1EBA9EA3693,
...
}
)

当然也可以研究游戏中 lookup table 的生成逻辑,我只是懒了。

回到 Manifest。
现在有了 checksum、labelCrc、size,最后我们需要做的就是把他们连起来:

x = checksum + labelCrc + VLQ(size) = 07F162700055D1DDE0D8673BCC7D7A04C0F01F

然后计算获得值的 MD5:

y = MD5(x) = 8C50F36F436CCE88E99EB741CF9E690A

最后对其进行 base32 编码:

RealName = Base32.FromBytes(y) = rripg32dnthir2m6w5a47htjbi

这就是 Manifest 的下载路径。加上固定的前缀之后客户端就可以直接从服务器 GET 到加密后的 Manifest 文件。

解密

获取到 Manifest 文件之后就可以开始解密了。首先准备一个空的 buffer,经过分析解密流程如下:

  1. 向 buffer 写入一段固定的常量字节
1
2
3
hexPrefix, _ := hex.DecodeString(PREFIX)
// Step 1. Write a bunch of constant arbitrary hex data
buf.Write(hexPrefix)
  1. 向 buffer 写入之前从 x-res-version header 中得到的 Seed
1
2
3
// Step 2. Write Manifest.Seed
seed := asset.Seed
binary.Write(buf, binary.BigEndian, seed)
  1. 计算 AppVersion + “:” + SimpleResver 的 CRC64,写入 buffer
1
2
3
// Step 3. Write CRC64 of crc64str
crc64 := crypto.UpdateCrc64(0, []byte(crc64str), len(crc64str), nil)
binary.Write(buf, binary.BigEndian, crc64)
  1. 计算常量字符串 “android” 的 CRC32,写入 buffer
1
2
3
4
// Step 4. Write CRC32
crc32Str := CATALOG_STR
crc32 := crypto.UpdateCrc32(0, []byte(crc32Str), len(crc32Str))
binary.Write(buf, binary.BigEndian, crc32)
  1. 向 buffer 写入之前从 x-res-version header 中得到的经过 VLQ 编码后的 Size
1
2
3
vlqBuf := make([]byte, binary.MaxVarintLen64)
n := binary.PutUvarint(vlqBuf, asset.Size)
buf.Write(vlqBuf[:n])
  1. 计算 buffer 中的所有字节的 SHA256,得到 32 字节的 hash,将其 [:16] 作为 key,[16:] 作为 iv
1
2
3
4
5
// Compute sha256 of all bytes
keyiv := sha256.Sum256(buf.Bytes())

key := keyiv[:16]
iv := keyiv[16:]
  1. AES 解密文件
1
2
3
internalBuf := new(bytes.Buffer)
// Decrypt
crypto.Decrypt(key, iv, src, internalBuf)

教科书一般的 key 制造方法,涉及到了固定值和变量的融合。
但同时有一个弊端:整个流程将 app version 包括在了计算 key 的因素内,一旦 app version 发生变化,即便 Manifest 没有改变,也需要重新制造。

解密完成后,将得到的数据进行 LZ4 解压,即可获得经过序列化的 Manifest 文件。

1
2
3
4
5
// Decompress
lz4Reader := lz4.NewReader(internalBuf)
if _, err := io.Copy(dst, lz4Reader); err != nil {
panic(err)
}

反序列化

经过序列化后的 Manifest 结构类似于一个经过转置后的 CSV 文件,里面每个 entry 包含了这些内容:

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
type Entry struct {
Priority uint32
ResourceType uint32
NumDeps uint32
NumContents uint32
NumCategories uint32
Size uint64
ContentTypeCrcs []byte
TypeCrc uint32
CategoryCrcs []byte
LabelCrc uint64
ContentNameCrcs []byte
DepCrcs []byte
RecDepCrcs []byte
NumRecDepCrcs uint32
Checksum uint64
Seed uint64
StrTypeCrc string
StrContentTypeCrcs []string
StrCategoryCrcs []string
StrLabelCrc string
StrContentNameCrcs []string
StrDepCrcs []string
RealName string
}

其中以 Str 为前缀的内容和 RealName 是游戏中原本的 Manifest 不含的,为了方便后续使用我给每个 entry 都添加了这些 field。

文件分析起来很简单:

  1. Magic number CA01
1
2
3
4
5
var magic uint16
binary.Read(bBuff, binary.BigEndian, &magic)
if magic != 0xCA01 {
rich.Panic("First 2 bytes are [%X], expect [CA01]", magic)
}
  1. 不知道是什么,直接丢弃无碍 0003
1
2
var nothing uint16
binary.Read(bBuff, binary.BigEndian, &nothing)
  1. VLQ size,表示文件的列数
1
2
3
4
5
6
var _size uint64
_size, err := binary.ReadUvarint(bBuff)
if err != nil {
panic(err)
}
size := int(_size)
  1. 接下来到文件末尾都是数据了

照这个规律写一个 Parser,很容易就能得到原始 Manifest。以下是随便挑其中一个元素作为例:

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
...
{
"Priority": 0,
"ResourceType": 1,
"NumDeps": 0,
"NumContents": 1,
"NumCategories": 2,
"Size": 57786,
"ContentTypeCrcs": "gxgDkA==",
"TypeCrc": 2199389072,
"CategoryCrcs": "4B++ao/tvP0=",
"LabelCrc": 2148189895458925363,
"ContentNameCrcs": "Hc/nVAzpqzM=",
"DepCrcs": null,
"RecDepCrcs": "Hc/nVAzpqzM=",
"NumRecDepCrcs": 0,
"Checksum": 2287089857172803265,
"Seed": 0,
"StrTypeCrc": "png",
"StrContentTypeCrcs": [
"png"
],
"StrCategoryCrcs": [
"images",
"sticker"
],
"StrLabelCrc": "image_sticker_20330601",
"StrContentNameCrcs": [
"image_sticker_20330601"
],
"StrDepCrcs": null,
"RealName": "vboc5cvp57ikhnwep6p5p6ekqa"
},
...

RealName 的计算方法和上述类似,同时也是 asset 的下载地址。

Assets

Manifest 搞定了,接下来是 assets。

根据 Entry.ResourceType 可以判断 asset 的类型,游戏中定义了以下几种:

1
2
3
4
5
const PlainAssetBundle = 0
const OffsetAssetBundle = 1
const RawAssetMinValue = 128
const RawFile = 128
const EncodedBin0File = 192

PlainAssetBundle 目前不存在,可以无视。
OffsetAssetBundle 是 Unity 的 AssetBundle,只在文件前部追加了 0xAB00 两个字节,没有加密。
RawAssetMinValueRawFile 是 Criware 音频或者 usm 视频等文件格式,可以直接使用。
EncodedBin0File 包含了纯文本和 MasterDB,经过了加密。

也就是说我们只需要研究 EncodedBin0File 的解密方法就足够了。

解密

EncodedBin0File 的解密方法和 Manifest 相似,其中有变化的步骤是:

  1. 向 buffer 写入 Entry.Seed & 0x7FFFFFFFFFFFFFFF

  2. 计算 Entry.StrLabelCrc 的 CRC64,写入 buffer

  3. 计算常量字符串 “raw” 的 CRC32,写入 buffer

  4. 向 buffer 写入 Entry.Size 经过 VLQ 编码后的 Size

其余步骤相同。

到这里 plain 文本对话和 MasterDB 就可以被获得了。
其中剧情脚本里的一部分注释很有意思:

image

MasterDB

获得解密后 MasterDB 文件之后,需要对其反序列化。

顺带一提,这游戏的 MasterDB 被取名为 *.tsv,个人猜测是 Transposed Separated Values 的意思。

反序列化步骤和 Manifest 的反序列化步骤类似,但更复杂:

  1. 读取 magic number 0xDA00
1
2
3
4
5
6
7
8
buf := make([]byte, 2)
// magic number 0xDA00
if _, err := io.ReadFull(bufr, buf); err != nil {
panic(err)
}
if buf[0] != 0xDA || buf[1] != 0x00 {
rich.Panic("Magic number mismatched, expect: 0xDA00, given: %X.", buf)
}
  1. 读取 2 字节数据,直接丢弃
1
2
3
4
// idk what is this
if _, err := io.ReadFull(bufr, buf); err != nil {
panic(err)
}
  1. 读取 VLQ,行数 rowNum
1
2
3
4
5
6
7
8
9
// vlq: numRows
rowNum, err := binary.ReadUvarint(bufr)
if err != nil {
panic(err)
}
if rowNum < 1 {
rich.Info("Database file %q has 0 rows.", label)
return nil
}
  1. 读取 VLQ,列数 fieldNum
1
2
3
4
5
6
7
8
9
// vlq: numFields
fieldNum, err := binary.ReadUvarint(bufr)
if err != nil {
panic(err)
}
if fieldNum < 1 {
rich.Warning("Table %q has no fields.", label)
return nil
}
  1. 循环读取 fieldNamesfieldTyps,执行 fieldNum 次:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var fieldNames []uint32
var fieldTypes []uint32

for i := 0; i < int(fieldNum); i++ {
buf4b := make([]byte, 4)

// uint32: fieldNames
if _, err = io.ReadFull(bufr, buf4b); err != nil {
panic(err)
}
fn := binary.BigEndian.Uint32(buf4b)
fieldNames = append(fieldNames, fn)

// uint32: fieldTyps
if _, err = io.ReadFull(bufr, buf4b); err != nil {
panic(err)
}
ty := binary.BigEndian.Uint32(buf4b)
fieldTypes = append(fieldTypes, ty)
}
  1. 接下来到文件最后都是数据了

在读取数据的时候,游戏中的逻辑是针对 fieldNames 在代码里硬编码进行判断,获取相应的 field。如果要按游戏原逻辑来反序列化必须要把所有 fieldNames 手写出来进行判断,这显然无法实现自动化。但经过少量样本比较之后,可以发现序列化的顺序是完全按照类中 field 的顺序来进行的,所以可以抛弃 fieldNames,直接使用反射按照顺序取 struct 中的 fieldName,再进行赋值。

1
2
3
4
5
6
7
8
9
for fieldIdx := 0; fieldIdx < int(fieldNum); fieldIdx++ {
givenType := fieldTypes[fieldIdx]
fieldName := reflect.TypeOf(*instance).Field(fieldIdx).Name
field := Field{
Name: fieldName,
Type: givenType,
}
...
}

完成之后就可以自动取得所有 masterDB 的数据了。

至于 masterDB 的结构,我采用的是和当初拆爱普拉时同样的方法,通过正则匹配 dump.cs 来获得:

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
var (
tablePtn = regexp.MustCompile(`// Namespace: Silverflame\.SFL\n\[Table\("(?<tableName>\w+)"\)\][\s\S]+?\n}\n\n`)
columnPtn = regexp.MustCompile(`\[Column\("(?<columnName>\w+)"\)\][\s\S]+?public (?<type>\w+) (?<fieldName>\w+)`)
)

func Analyze() {
...
// match all classes
contents := tablePtn.FindAllStringSubmatch(sb.String(), -1)

structFile, err := os.Create(structFieldPath)
if err != nil {
panic(err)
}
defer structFile.Close()
structFileBuf := bufio.NewWriter(structFile)

structFileBuf.WriteString("// Generated code. DO NOT EDIT!\npackage master\n\nimport \"time\"\n\n")

for _, oneClass := range contents {
content := oneClass[0]
tableName := oneClass[1]
writeStruct(structFileBuf, tableName, content)
}
...
}

这样便可以自动生成 masterDB 的所有结构:

image

最后将数据导出:

image

搞定。


整体来看拆解这游戏很简单,只是 Manifest 和 MasterDB 的文件格式都是游戏独创的,需要花时间去解读。
最初拆的目的是想要卡图,但后来我也不知道为什么顺手就把 MasterDB 也拆了,目前也没想到拿 DB 来做什么,或许闲得无聊会做一个莲的查卡站?可我不玩这个游戏啊…

最后,祝大家推活快乐。
花帆妈妈真可爱。

image

References

Linux kernel source tree
Cyclic redundancy check
Computation of cyclic redundancy checks
A PAINLESS GUIDE TO CRC ERROR DETECTION ALGORITHMS

 Comments
Comment plugin failed to load
Loading comment plugin