「学園アイドルマスター」逆向解析

笨呆和 QA 联手打造的次世代烧手机爱抖露育成右手游戏终于在一片期待中发行了。

QA 不反一常之态推出了超高质量的模型,甚至比在此之前个人认为美少女模型业界 SotA 的爱普拉的模型还要精致,当然随之而来的代价就是烧手机。在我还沉浸在半年前换手机之后终于可以在爱普拉开最高画质不掉帧不发热的丝滑体验中的时候,QA 又给我来了当头一棒。难怪之前看到有人说 “Qualcomm gives, QualiArts takes away”。个人体验是满格电的设备大概只能玩 4 ~ 5 小时左右,导致我在长时间游戏的时候只好两个设备换着充电玩(边冲边玩?这发热量怕不是手机会爆炸还是算了)
除开烧性能和发牌员偏心,目前个人游戏体验是非常不错的。特别是 live 的临场感,配上咱的 call 之后非常出色,同时也算是给出了官方答案,以后去 live 时不需要事前考虑一首歌该在哪些地方 call 了。

作为爱抖露厨,既然是爱抖露游戏,对于其资源还是感兴趣的,所以拆还是一定要拆。由于三年前拆了 IPR ,学玛仕又是 Dev by QA,所以在发行之前就可以预想到两者在系统设计上有很多相似之处。另外不知道是从哪条门路上摸过来的,学玛仕发行前一天和当天我还收到了几封游戏拆解希望的邮件。

从发行后拆解的情况来看,学玛仕和 IPR 在各个方面的确几乎完全相同。
本文将从 metadata、proto schema、主数据库、manifest、资源文件来说明游戏的设计及加密机制。通信方面,虽然已经以研究的心态实现了模拟登录,但考虑到公开通信的加密机制可能会导致滥用,对游戏造成负面影响(没人想看到排行榜首位 999999 pt 吧),所以这部分不会公开。

Metadata

QA 总算学会了混淆 metadata!
当我解压 APK 后发现这个事实的时候在心里给 QA 点了个赞,不错嘛有进步!

虽然并没有任何卵用就是了。

我采用的方式是 dump 内存,直接可以获取到反混淆后的 metadata。当然也有大佬通过正攻法研究出了反混淆的方法,写了一篇很详细的解说,所以这里不作过多叙述。

Proto Schema

由于(个人感觉)本作的卡图不如 IPR 精致,所以 elf 分析完成后我首先决定分析 master database 而不是资源。
根据以前分析 IPR 主数据库的经验,猜测这次 QA 依旧采用 protobuf 作为存储数据的格式。简单扫了一眼 dummy DLL 果然如此,于是开始进行还原 proto schema 的工作。

原本想的是,直接沿用以前分析 IPR 时写的工具对 dump.cs 进行正则分析,然而实际上发现这条路行不通。
为什么?先来简单对比一下还原完成后的 GKMS 和 IPR 的 proto schema。

这是 IPR 的 schema 例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
message DiceSaveDeckResponse {
api.DiceDeckInfo deckInfo = 1;
api.CommonResponse commonResponse = 9999;
}
message DivisionListResponse {
repeated api.DivisionInfo divisions = 1;
ProtoEnum.DivisionCannotMoveReasonType reasonType = 2;
api.CommonResponse commonResponse = 9999;
}
message DivisionMoveRequest {
string divisionId = 1;
}
...

这是 GKMS 的 schema 例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
message StartupConfirmRequest {
repeated StartupConfirmRequest.Types.Notification notifications = 1;
message Types {
message Notification {
string startupNotificationId = 1;
bool isSkip = 2;
}
}
}
message HomeSetCharactersRequest {
repeated HomeSetCharactersRequest.Types.HomeCharacter characters = 1;
message Types {
message HomeCharacter {
penum.HomeType homeType = 1;
string characterId = 2;
}
}
}
...

可以发现 IPR 的 proto 都是平坦的,而 GKMS 是 nested 的,嵌套层数可以达到未知深度(目前实际的最深层度是 4 层),而以前的工具只能分析 flat 的 schema。

也就是说,咱得重新造轮子(扔帽子

从结果上来说,从设计到写完这个看上去简单的轮子花了我整整两天时间,因为其中用到了自从大学玩 ACM 以来几乎再也没有接触过的树形结构和递归函数。
(不、不是我的锅!除了做算法的大佬平常工作中谁会用到这些玩意儿嘛!)

由于这部分纯粹只涉及到算法而不是逆向,所以折叠起来感兴趣的读者可以查看。

[Click Me] 还原 proto schema 算法

我设计的树结构如下:

1
2
3
4
5
6
7
8
type ProtoTree struct {
prefix string
name string
level int
category Category
children map[string]*ProtoTree
traversed bool
}

其中

prefix 代表当前节点被嵌套的前缀,用于正则匹配
name 代表当前节点的名称
level 表示当前嵌套层数,用于在生成代码时添加适当的 indentation
children 存储当前节点的子节点,用 hash table 而不是 array 来加速查找运算
traversed 用于在遍历时区分当前节点是否已被遍历过
category 表示当前分析 proto 的种类,具体如下

1
2
3
4
5
6
7
8
9
10
11
const (
Enum Category = 1 + iota
Common
Master
Transaction
ApiCommon
Api
Nested
Root
)
type Category int

关键函数有点长,这里只截取关键部分做个演示

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
func analyzeTree(
entireContent *string,
rootCategory Category,
parentTree *ProtoTree,
rootTree *ProtoTree,
) *strings.Builder {
sb := new(strings.Builder)
for _, tree := range parentTree.children {
if tree.traversed {
continue
}
...
contents := classSearchPtn.FindAllStringSubmatch(*entireContent, -1)
// search entire class context
for _, oneClass := range contents {
content := oneClass[0]
// search for every single message
for _, subMatches := range generalColumnPtn.FindAllStringSubmatch(content, -1) {
...
// if typeName is a list, prune the redundant characters
if strings.HasPrefix(typeName, "RepeatedField<") {
...
}
if mappedType, ok := typeMap[typeName]; ok {
// in case of primitive types
...
} else{
// in case of user defined types
// first, check if it contains ".", if yes, it's highly like a nested message
if strings.Contains(typeName, ".") {
analyzeTree(
entireContent,
Nested,
tree,
rootTree
)
} else {
// if not, it can be an imported type
...
}
}
...
}
}
...
}
return sb
}

解说写在注释里了,感兴趣就自己看吧(懒
但使用这种方法有一个缺点:需要检索整个 dump.cs N + 1 次,N 是 class 的数量。而以前还原 flat 型 proto 的算法只需要检索 1 次。
这导致了分析花费时间从以前的一秒钟不到变成了 10+ 秒。
虽说分析 proto schema 的需要不是很频繁,10+ 秒仍然在容忍范围之内,但如果读者能想到更有效率的方法欢迎留言。

获得 proto schema 后,就可以开始解密主数据库了。

Master Database

与产生了变化的 proto schema 不同,GKMS 的主数据库加密方式和 IPR 一模一样,直接用以前的轮子就能解密,所以这里不作过多赘述,关键部分实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dbname := fmt.Sprintf("%s?_pragma_key=x'%s'", dbPath, key)
db, err := sql.Open("sqlite3", dbname)
if err != nil {
panic(err)
}
defer db.Close()
rows, err := db.Query(fmt.Sprintf("select data from %s;", name))
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
data := []byte{}
if err = rows.Scan(&data); err != nil {
panic(err)
}
...
}

pragma_key 是动态的,会随着每次 DB 更新而变动。解密之后把数据装进前一步获得的 proto schema 里就可以获得人类可读的数据。

Manifest, AssetBundles

这部分内容也和 IPR 一模一样,甚至连 octo 的 IV 都没有变化。GH 上也已经有好多反混淆工具了,所以也不作赘述。这部分的代码已经在另一篇文章里已有作说明所以就不列出来了。

API

由于文章开头所述的原因,通信的加密方式暂不打算公开。但在这里给想要研究的读者一个小小的提示。
GKMS 的通信加密方式和 IPR 完全一致,但在 TLS 握手时协定的 ALPN 不是 HTTP2 协议中规定的标准 identifier。这会导致在截取流量时看到的报文是通用的 TCP stream,而不是 HTTP 报文。
最初截取流量时我就上了当,看到视图里出现的是 TCP stream,首先想到的是 QA 这次改用了 Bidirectional streaming RPC,于是开始研究文档。然而越读下去却越觉得不对劲儿,Bidirectional streaming RPC 的方式每一个流只能传固定的一种 protobuf 报文,除非 QA 把所有的消息种类都包含在一个超大的 message 里,否则只能每调用一个 API 重新创建一个流,这显然违背了使用流的意义。可能的调用方式只能是 Unary,但为什么和 IPR 截取到的视图不同呢。对于这个问题我仔细对比了 GKMS 和 IPR 的流量,后来才发现是 ALPN 不同,所以在截取流量时需要手动将 TLS 握手时的 ALPN extension 改为标准的 identifier。

当然如果您是能够解读 raw TCP 报文的大佬请当我没说(

另外如果你的实现语言是 Go,文档中虽然没有标注,在实现 encoding.Codec 接口时可以直接复制 gRPC 的默认 codec 再进行更改,有一个 entry point 之后会轻松很多。下面是我改好的 codec 例。

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
type QuaCodec struct{}

func (g *QuaCodec) Marshal(v interface{}) ([]byte, error) {
vv := messageV2Of(v)
if vv == nil {
return nil, fmt.Errorf("failed to marshal, message is %T, want proto.Message", v)
}
protoBytes, err := proto.Marshal(vv)
if err != nil {
return nil, err
}
return Serialize(protoBytes), nil
}

func (g *QuaCodec) Unmarshal(data []byte, v interface{}) error {
vv := messageV2Of(v)
if vv == nil {
return fmt.Errorf("failed to unmarshal, message is %T, want proto.Message", v)
}
protoBytes := Deserialize(data)
return proto.Unmarshal(protoBytes, vv)
}

func (g *QuaCodec) Name() string {
return "qua"
}

如果是 Python,那很不幸可能需要手动修改 protoc 生成的代码,因为 Python gRPC 库似乎没有提供自定义 Codec 的 API。
其他语言没有试过。


目前研究完成的内容暂时到此为止。
除此之外比较感兴趣的是与游戏系统相关的内容,比如 Produce 时 support card 剧情触发的概率,memory 继承词条的选取机制,Contest 自动打牌时的出牌机制等。估计有一部分内容是在服务端执行的没法获取,但能够在客户端找到的部分估计会有大佬研究?非常期待能看到相关的解说。至于我,打算先肝一段时间游戏再说了(

最后,恭喜 QA 在爱抖露游戏制作这条路上又迈进了新的一步,那么オルタナティブガールズ3什么时候开服啊?

P.S. 谁来遵照传统给 GKMS 取个菜名啊?

References

gRPC Core concepts
gRPC-Go Documentation
Protobuf Go Generated Code Guide
go-sqlcipher
IDOLY PRIDE 资源解析

 Comments
Comment plugin failed to load
Loading comment plugin