C#逆向入门:Celeste 拆包

前言

Celeste 是 2018 年第一款 IGN 满分游戏,游戏类型正好是我比较喜欢的硬核跳台,再加上操作手感极佳,可以说是非常对口味了。

玩到一半的时候我想换个 Madeline 的头像结果一番搜索没有找到现成的,所以决定自己动手拆包。好在 Celeste 没有任何混淆,使用的是微软的 XNA 框架,逆向基本上没有难点,难点主要是如何适当的复用其代码提取资源,所以这篇文章主要是想记录一下自己是如何拆 Celeste 包的。

收集信息

当然一开始我也不知道 Celeste 是 C# 写的,但是在搜索的时候我注意到了官方上这样一篇文章,基本为逆向准备提供了所有需要的信息。

  • Visual Studio C# :我当时看到这个时候心里就松了一口气,虽然我对 C# 逆向的知识很少,但是就算是有混淆相对常见的二进制逆向来说也要爽的多了。
  • XNA :这意味着等会儿就有文档可以查了。
  • FNA & MonoGame :这里对后面逆向影响不大。
  • Monocle : 这里是整个拆包的关键。这段话里有两层意思:一是 Celeste 使用了 Monocle 来管理场景、实体、组件,言外之意贴图就是用它读的,二是 Celeste 对源代码进行了相当大的修改。

这就意味着逆向的核心应该放在对 Monocle 的分析上。不过实际上由于 Celeste 修改了 Monocle 中不少核心代码,因此即使有 Monocle 最初的源代码也没有什么用了。

定位逻辑

直接把 Celeste.exe 拖进 dnSpy

可能 Steam 版的 Celeste 也是 DRM-Free 吧,我测试了下,只要删掉中间 Steam 初始化的几行代码游戏照样能正常启动。

这里不用直接往下跟踪了,因为可以看到左边有一个成员函数 LoadContent,显然我们的目标就是它,点进去一看

Bingo!

分析 Atlas 和 VirtualTexture

实际上几个 Load 的逻辑都是类似的,这里我们以 Portraits 的逆向为例,看一看提取的逻辑。跟踪进入 LoadPortraits 有

可以看到这里生成了一个 Monocle.Atlas 类。如果做过游戏开发的话应该对这个名词再熟悉不过了(或者说它的别名 SpriteSheet)。

同时这里用 Path.Combine 生成了相对路径,所以基本可以确定是要解包了。

Metadata

跟踪几步后,就到了解元数据的地方。

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
using (FileStream fileStream4 = File.OpenRead(Path.Combine(Engine.ContentDirectory, path + ".meta"))) // 其中 path 就是刚才传入的相对路径,这里生成了元数据文件的绝对路径用于读取。
{
BinaryReader binaryReader4 = new BinaryReader(fileStream4);
binaryReader4.ReadInt32();
binaryReader4.ReadString();
binaryReader4.ReadInt32();
short num13 = binaryReader4.ReadInt16();
for (int num14 = 0; num14 < (int)num13; num14++)
{
string path5 = binaryReader4.ReadString();
string path6 = Path.Combine(Path.GetDirectoryName(path), path5);
short num15 = binaryReader4.ReadInt16();
for (int num16 = 0; num16 < (int)num15; num16++)
{
string text6 = binaryReader4.ReadString().Replace('\\', '/');
binaryReader4.ReadInt16();
binaryReader4.ReadInt16();
binaryReader4.ReadInt16();
binaryReader4.ReadInt16();
short num17 = binaryReader4.ReadInt16();
short num18 = binaryReader4.ReadInt16();
short frameWidth2 = binaryReader4.ReadInt16();
short frameHeight2 = binaryReader4.ReadInt16();
VirtualTexture virtualTexture6 = VirtualContent.CreateTexture(Path.Combine(path6, text6 + ".data")); // VirtualTexture 和 VirtualContent 都是 Celeste 自己的修改
atlas.Sources.Add(virtualTexture6);
atlas.textures[text6] = new MTexture(virtualTexture6, new Vector2((float)(-(float)num17), (float)(-(float)num18)), (int)frameWidth2, (int)frameHeight2); // MTexture 是 Monocle 原有的内容
}
}
return;
}

显然这里是读取了 Atlas 的整体长宽,然后根据元数据读取每个纹理。

这里我们不用关心具体逻辑,因为我们之后可以复用这段函数。

VirtualTexture

跟踪 VirtualContent.CreateTexture 几步后我们就可以看到 VirtualTexture 的核心逻辑了。

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
internal unsafe override void Reload()
{
this.Unload();
if (string.IsNullOrEmpty(this.Path))
{
this.Texture = new Texture2D(Engine.Instance.GraphicsDevice, base.Width, base.Height);
Color[] array = new Color[base.Width * base.Height];
Color[] array2;
Color* ptr;
if ((array2 = array) == null || array2.Length == 0)
{
ptr = null;
}
else
{
ptr = &array2[0];
}
for (int i = 0; i < array.Length; i++)
{
ptr[i] = this.color;
}
array2 = null;
this.Texture.SetData<Color>(array);
return;
}
string extension = System.IO.Path.GetExtension(this.Path);
if (extension == ".data")
{
using (FileStream fileStream = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, this.Path)))
{
fileStream.Read(VirtualTexture.bytes, 0, 524288);
int num = 0;
int num2 = BitConverter.ToInt32(VirtualTexture.bytes, num);
int num3 = BitConverter.ToInt32(VirtualTexture.bytes, num + 4);
bool flag = VirtualTexture.bytes[num + 8] == 1;
num += 9;
int num4 = num2 * num3 * 4;
int j = 0;
try
{
byte[] array3;
byte* ptr2;
if ((array3 = VirtualTexture.bytes) == null || array3.Length == 0)
{
ptr2 = null;
}
else
{
ptr2 = &array3[0];
}
byte[] array4;
byte* ptr3;
if ((array4 = VirtualTexture.buffer) == null || array4.Length == 0)
{
ptr3 = null;
}
else
{
ptr3 = &array4[0];
}
while (j < num4)
{
int num5 = (int)(ptr2[num] * 4);
if (flag)
{
byte b = ptr2[num + 1];
if (b > 0)
{
ptr3[j] = ptr2[num + 4];
ptr3[j + 1] = ptr2[num + 3];
ptr3[j + 2] = ptr2[num + 2];
ptr3[j + 3] = b;
num += 5;
}
else
{
ptr3[j] = 0;
ptr3[j + 1] = 0;
ptr3[j + 2] = 0;
ptr3[j + 3] = 0;
num += 2;
}
}
else
{
ptr3[j] = ptr2[num + 3];
ptr3[j + 1] = ptr2[num + 2];
ptr3[j + 2] = ptr2[num + 1];
ptr3[j + 3] = byte.MaxValue;
num += 4;
}
if (num5 > 4)
{
int k = j + 4;
int num6 = j + num5;
while (k < num6)
{
ptr3[k] = ptr3[j];
ptr3[k + 1] = ptr3[j + 1];
ptr3[k + 2] = ptr3[j + 2];
ptr3[k + 3] = ptr3[j + 3];
k += 4;
}
}
j += num5;
if (num > 524256)
{
int num7 = 524288 - num;
for (int l = 0; l < num7; l++)
{
ptr2[l] = ptr2[num + l];
}
fileStream.Read(VirtualTexture.bytes, num7, 524288 - num7);
num = 0;
}
}
}
finally
{
byte[] array3 = null;
byte[] array4 = null;
}
this.Texture = new Texture2D(Engine.Graphics.GraphicsDevice, num2, num3);
this.Texture.SetData<byte>(VirtualTexture.buffer, 0, num4);
goto IL_52D;
}
}
if (extension == ".png")
{
using (FileStream fileStream2 = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, this.Path)))
{
this.Texture = Texture2D.FromStream(Engine.Graphics.GraphicsDevice, fileStream2);
}
int num8 = this.Texture.Width * this.Texture.Height;
Color[] array5 = new Color[num8];
this.Texture.GetData<Color>(array5, 0, num8);
Color[] array2;
Color* ptr4;
if ((array2 = array5) == null || array2.Length == 0)
{
ptr4 = null;
}
else
{
ptr4 = &array2[0];
}
for (int m = 0; m < num8; m++)
{
ptr4[m].R = (byte)((float)ptr4[m].R * ((float)ptr4[m].A / 255f));
ptr4[m].G = (byte)((float)ptr4[m].G * ((float)ptr4[m].A / 255f));
ptr4[m].B = (byte)((float)ptr4[m].B * ((float)ptr4[m].A / 255f));
}
array2 = null;
this.Texture.SetData<Color>(array5, 0, num8);
}
else if (extension == ".xnb")
{
string assetName = this.Path.Replace(".xnb", "");
this.Texture = Engine.Instance.Content.Load<Texture2D>(assetName);
}
else
{
using (FileStream fileStream3 = File.OpenRead(System.IO.Path.Combine(Engine.ContentDirectory, this.Path)))
{
this.Texture = Texture2D.FromStream(Engine.Graphics.GraphicsDevice, fileStream3);
}
}
IL_52D:
base.Width = this.Texture.Width;
base.Height = this.Texture.Height;
}

可以看出纹理的后缀名有三种,不过 Celeste 中只有 .data 这一种,所以我们不用管其它两种。

要注意的是最后 VirtualTexture 实际上还是把纹理信息存储到了 XNA 框架中的 Texture2D 里,这对后面的解包至关重要。

同样具体的逻辑不用关心,因为我们之后可以复用。

解包

虽然说核心逻辑都已经清晰了,但是如果能复用自然是最好的。

导出

因为已有代码最终得到的是一个 VirtualTexture 或者说是 Texture2D 对象,所以核心问题就变成了怎么把这个对象导出为常见的图片格式。

一开始我是直接从缓冲区导出的,因为缓冲区是 RGBA 存储的,不过后来我在 XNA 框架文档中发现 Texture2D 本身就有两个导出函数分别导出为 jpeg 和 png,所以问题迎刃而解。

这里插播一条趣闻。

Texture2D 的文档中可以看到有 SaveAsJpeg 和 SaveAsPng 两个函数,但是点开却是

不太清楚 XNA 框架的现状,单从这个文档现状来看可能是已经被微软抛弃?

GraphicDevice

这大概是在写解包器的时候遇到的最让我头疼的问题了,那就是:Texture2D 是继承 Texture 的,相比 Texture 它需要一个特定的 GraphicDevice 对象来对特定的显示设备进行渲染优化。简而言之,如果我想复用之前的代码,就必须拿到一个 GraphicDevice。

但是在 XNA 框架中,这个对象是蕴涵在 Game 对象生命周期内的,但是现在 CLI 连窗口都没有怎么会有 GraphicDevice 呢?

这个问题困扰了我大概一天多,最后我终于在 StackOverflow 上一个角落里找到了我想要的答案——创建一个隐藏的 Form 来获得一个 GrapicDevice 对象,当然这个 Form 也是需要接入整个 XNA 框架生命周期的。

因此最后实现的代码是这样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// This property is a bit hacking to get a GraphicsDevice.
///
/// Extracting textures in Celeste (or in XNA) needs to construct Texture2D objects
/// which requires a valid GraphicsDevice.
///
/// Since it is a command-line app, in order to get the GraphicsDevice,
/// I have to create an invisible form to acquire a handle and get the device.
/// </summary>
private static void Initialize()
{
Form form = new Form(); // Invisible form to get a valid GraphicsDevice.
GraphicsDeviceService gds = GraphicsDeviceService.AddRef(form.Handle, form.ClientSize.Width, form.ClientSize.Height);
ServiceContainer services = new ServiceContainer();
services.AddService<IGraphicsDeviceService>(gds);
_content = new ContentManager(services, "Content");
IGraphicsDeviceService graphicsDeviceService = services.GetService(typeof(IGraphicsDeviceService)) as IGraphicsDeviceService;
_graphicsDevice = graphicsDeviceService.GraphicsDevice;
}

其中 GraphicsDeviceService 和 ServiceContainer 来自 XNA 的 WinForm 例程,用于把 Form 接入到 XNA 生命周期中。

这样我们就拥有了一个合法的 GraphicDevice,完美!

HiDef 和 Reach

最后还有一个小问题,Celeste 使用了大于 2048 * 2048 的贴图,这个只有 XNA 中的 HiDef 支持,Reach 最多支持到 2048 * 2048,只需要修改 GraphicsDeviceService 即可。

小结

到这里解包器所有难点就克服了,个人觉得这个过程来当做 C# 逆向入门挺合适的,不过要想继续提升内力还是得熟悉 C# 本身的语法和 .NET 虚拟机的细节。嘛,反正目的是达到了。

另外最后解包的源代码放到 Github 了,欢迎 fork 和 pr。

https://github.com/wtdcode/CelesteExtractor

参考资料

Monocle
Texture atlas
Texture2D
how can i use content manager in console aplication to load a new Model?
WinForm Sample
Tools