使用ImHex逆向工程未知文件格式
摘要
本文展示了如何使用ImHex这款免费开源的十六进制编辑器来逆向工程游戏FEZ的存档文件格式,提供了从二进制分析到模式定义的实用指南。
暂无内容
查看缓存全文
缓存时间: 2026/09/02 23:52
# 使用ImHex逆向工程未知文件格式 | WerWolv
来源:https://werwolv.net/posts/file_format_reverse_engineering/
## 引言
(https://werwolv.net/posts/file_format_reverse_engineering/#introduction)
多年来,我被问过无数次同样的问题:
Discord用户请求帮助逆向工程文件格式
Discord用户请求帮助逆向工程文件格式
我通常无法给出很好的答案,只能回复:“查看读取/写入这些文件的任何程序的反编译代码,然后从那里逆向推导。”
本文旨在改变这种情况。我们将从一个完全自定义的二进制保存文件开始——游戏《FEZ》(https://fezgame.com/)的存档,最终使用模式语言(Pattern Language)编写完整的格式定义。模式语言是我过去几年开发的十六进制编辑器ImHex(https://imhex.werwolv.net/)的一部分。它免费开源,适用于任何操作系统(或者如果你愿意,甚至可以通过浏览器访问:ImHex Web (https://web.imhex.werwolv.net/nightly))。
#### ImHex版本
在撰写本文时,此处使用的部分功能尚未发布,仅在每日构建版本(Nightly build)中可用(也可通过上述相同链接下载)。如果你使用的是ImHex v1.38.1或更低版本并遇到问题,请考虑升级到每日构建版。
## 入门
(https://werwolv.net/posts/file_format_reverse_engineering/#getting-starting)
#### 剧透警告
FEZ早在2012年就已发布。不过,如果你还没玩过,并且希望获得完整体验,我强烈建议你在继续阅读之前先玩一下。此处展示的部分代码会严重剧透一些秘密和终局内容,可能会破坏你的游戏体验。请知悉。
首先我们需要存档文件。我从Steam下载了游戏(当前可用的最新完整版本,发布于2016年12月2日),启动游戏并玩了一小会儿直到游戏保存。然后我浏览文件系统,在 `/home/werwolv/.local/share/FEZ/SaveSlot2` 找到了存档文件。在Windows上,路径会有所不同。
在ImHex中打开该文件,显示如下:
```
十六进制视图
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000 3E 74 E1 41 6B BA DA 01 06 00 00 00 00 00 00 00 >t.Ak...........
00000010 3A AC 78 49 C9 B4 CF 01 01 00 01 00 00 01 12 00 :.xI............
00000020 00 00 01 11 44 4F 54 5F 4C 4F 43 4B 45 44 5F 44 ....DOT_LOCKED_D
00000030 4F 4F 52 5F 41 00 01 10 44 4F 54 5F 4E 55 54 5F OOR_A...DOT_NUT_
00000040 4E 5F 42 4F 4C 54 5F 41 00 01 0B 44 4F 54 5F 50 N_BOLT_A...DOT_P
00000050 49 56 4F 54 5F 41 01 01 11 44 4F 54 5F 54 49 4D IVOT_A...DOT_TIM
00000060 45 5F 53 57 49 54 43 48 5F 41 00 01 0F 44 4F 54 E_SWITCH_A...DOT
00000070 5F 54 4F 4D 42 53 54 4F 4E 45 5F 41 01 01 0C 44 _TOMBSTONE_A...D
00000080 4F 54 5F 54 52 45 41 53 55 52 45 00 01 0B 44 4F OT_TREASURE...DO
00000090 54 5F 56 41 4C 56 45 5F 41 01 01 13 44 4F 54 5F T_VALVE_A...DOT_
```
这已经揭示了一些信息。该文件似乎是未压缩且未加密的,因为可以看到纯文本字符串和其他只需查看字节和字符就能轻松发现的模式。数据也没有文件魔数(file magic)(文件开头用于更容易识别的某些可读文本),并且看起来不像任何标准格式,因为ImHex也无法直接识别其类型。
ImHex中的魔数文件信息
ImHex中的魔数文件信息
没有更多信息,我们基本上就卡在这里了。数据可能代表任何含义,只有生成和解析它的程序才能理解它。
## 反编译游戏
(https://werwolv.net/posts/file_format_reverse_engineering/#decompiling-the-game)
### 找到正确的文件
(https://werwolv.net/posts/file_format_reverse_engineering/#finding-the-right-files)
点击Steam页面的齿轮图标并选择`管理 -> 浏览本地文件`,即可到达游戏的二进制文件位置。立即引人注目的是诸如`System.Core.dll`或`mscorlib.dll`之类的文件。游戏是用C#编程语言编写的,这种语言通常非常容易逆向工程。像JetBrains Rider (https://www.jetbrains.com/rider/)这样的工具可以将二进制文件反编译回看起来像原始源代码的形式。为此,我们可以将游戏文件夹作为一个项目打开,然后对所有看起来有趣的`.dll`文件简单地`右键单击 -> 在程序集中查看`。对我来说,特别有趣的是`FEZ.exe`、`FezEngine.dll`、`Common.dll`、`ContentSerialization.dll`和`EasyStorage.dll`。其余的是系统库或外部依赖项,看起来与我们这里尝试做的事情无关。
### 找到正确的函数
(https://werwolv.net/posts/file_format_reverse_engineering/#finding-the-right-functions)
只是快速浏览命名空间,就发现了一个看起来有趣的文件:`EasyStorage -> PCSaveDevice`。在该类的构造函数中,我们还可以立即看到`string str = "SaveSlot" + (object) index;`,这看起来像是在构建我们的文件名`SaveSlot2`,所以我们肯定找到了正确的地方。向下滚动一点,我们可以找到一个名为`Save`的函数,该函数创建一个字节缓冲区并开始使用`BinaryWriter`流填充它,然后将其保存到我们的存档文件位置。找到了!
```csharp
public virtual bool Save(string fileName, SaveAction saveAction){
// ...
byte[] buffer = new byte[40960 /*0xA000*/];
using (MemoryStream output = new MemoryStream(buffer))
{
using (BinaryWriter writer = new BinaryWriter((Stream) output))
{
writer.Write(DateTime.Now.ToFileTime());
saveAction(writer);
if (output.Length < 40960L /*0xA000*/)
{
long length = 40960L /*0xA000*/ - output.Length;
writer.Write(new byte[length]);
}
else if (output.Length > 40960L /*0xA000*/)
throw new InvalidOperationException(
"Save file greater than the imposed limit!"
);
}
}
// ...
}
```
## 编写ImHex模式
(https://werwolv.net/posts/file_format_reverse_engineering/#writing-the-imhex-pattern)
### 简单开始
(https://werwolv.net/posts/file_format_reverse_engineering/#humble-beginnings)
现在我们已经找到了存档文件生成的位置,我们可以开始在ImHex中编写模式文件来解码数据。打开`模式编辑器`选项卡,显示一个文本编辑器,我们可以在其中编写源代码。我们可以简单地创建一个`struct FezSaveFile`,并使用`@`放置运算符将其放置在文件的开头。
```c
struct FezSaveFile {
// 结构体定义
};
FezSaveFile saveFile @ 0x00;
```
这会在文件地址`0x00`处实例化`FezSaveFile`模式对象。接下来,在存档文件生成代码中,我们看到`writer.Write(DateTime.Now.ToFileTime());`,它将当前时间戳作为Windows文件时间(https://learn.microsoft.com/en-us/dotnet/api/system.datetime.tofiletime?view=net-10.0#remarks)写入输出。如文档备注部分所示,这只是一个表示自公元1601年以来经过的100纳秒间隔数的小端序、64位值(C#中的`long`类型)。当然,我们可以正确解码这个值,但为了简单开始,我们只需在模式中用一个`s64`代替它。或者,我们也可以使用`using`关键字编写类型别名,使模式中的代码更接近真实代码中使用的类型。这只是定义了一个新类型,该类型具有右侧类型的完全相同的属性,但可能有一个更具描述性的名称。
```c
using int = s32;
using long = s64;
struct FezSaveFile {
long fileTime;
};
FezSaveFile saveFile @ 0x00;
```
点击模式编辑器底部的按钮(或按`F5`键)后,该值的区域现在在十六进制编辑器视图中高亮显示,它也出现在模式数据视图的模式树中。
十六进制编辑器中高亮显示的字节和模式数据视图中解码的值
十六进制编辑器中高亮显示的字节和模式数据视图中解码的值
不过对于这个特定情况,我们很幸运,标准库已经实现了一个用于解码Windows `FILETIME`值的类型。要使用它,我们可以导入定义该类型的`type.time`库,然后像使用代码中的任何其他类型一样使用它:
```c
import type.time;
struct FezSaveFile {
type::FILETIME fileTime;
};
FezSaveFile saveFile @ 0x00;
```
这个简单的改动现在将之前那个不可读的数字转换成了实际时间值的易读表示:
使用标准库中的 `type::FILETIME` 类型解码 FILETIME 值
使用标准库中的 `type::FILETIME` 类型解码 FILETIME 值
这就是开始,恭喜!你编写了你的第一个模式!
#### `[[fixed_size]]` 属性
我们还能在代码中看到的一件事是,`Save()`函数确保存档文件始终为`0xA000`字节长。如果它较短,将用零填充;如果较长,则会抛出`InvalidOperationException`。这非常匹配可以附加到`FezSaveFile`上的`[[fixed_size(0xA000)]]`属性以确保这一点。这完全是可选的,但有助于记录官方行为。
### 实际的存档数据
(https://werwolv.net/posts/file_format_reverse_engineering/#the-actual-save-data)
回到C#代码,接下来要做的是调用`saveAction`回调,该回调在别处实现。幸运的是,Rider在这里有帮助,你只需Ctrl+单击`Save`函数名称即可找到定义。在那里我们看到它被调用的几个地方,但有趣的地方在`GameStateManager.cs SaveInternal()`中。
在JetBrains Rider中查找用法
在JetBrains Rider中查找用法
```csharp
private void SaveInternal(bool ngpBackup){
// ...
this.ActiveSaveDevice.Save(
"SaveSlot" + (object) this.SaveSlot,
new SaveAction(this.DoSave)
);
// ...
}
```
在那里我们可以看到实际的存档数据转储被委托给`DoSave()`函数,该函数调用`SaveFileOperations.Write()`。现在就是重点了。在这里我们可以看到所有写入二进制文件的不同字段。
```csharp
public static void Write(CrcWriter w, SaveData sd){
w.Write(6L);
w.Write(sd.CreationTime);
w.Write(sd.Finished32);
w.Write(sd.Finished64);
w.Write(sd.HasFPView);
w.Write(sd.HasStereo3D);
w.Write(sd.CanNewGamePlus);
w.Write(sd.IsNewGamePlus);
// ...
}
```
查看这些值的类型,可以轻松转换为ImHex模式:
```c
struct FezSaveFile {
// 来自 PCSaveDevice.cs
type::FILETIME fileTime;
// 来自 SaveFileOperations.cs
long version; // 在 `Read()` 函数中检查为 6
long creationTime;
bool finished32;
bool finished64;
bool hasFpView;
bool hasStereo3d;
bool canNewGamePlus;
bool isNewGamePlus;
};
```
第一个字段似乎是一个存档文件版本,如下方的`Read()`函数所示,该函数读取该字段,确保它也是6,如果不是则抛出异常。我们可以简单地解析该字段,但如果我们想额外花哨一点,并确保只加载实际上与我们的模式兼容的文件,我们可以轻松地对此字段进行断言。在模式语言中,我们可以有条件和函数调用与类型定义交织在一起,使得这样的事情成为可能:
```c
import std.sys;
struct FezSaveFile {
// 来自 PCSaveDevice.cs
type::FILETIME fileTime;
// 来自 SaveFileOperations.cs
long version; // 在 `Read()` 函数中检查为 6
std::assert(version == 6, "不支持的存档文件版本。仅支持版本 6");
// ... (后续字段定义)
```
### 对象和字符串
(https://werwolv.net/posts/file_format_reverse_engineering/#objects-and-strings)
下一部分很有趣。这里,正在序列化一个字符串 -> 布尔键值对的列表。首先存储对的数量,然后是该数量的已序列化对。
```csharp
w.Write(sd.OneTimeTutorials.Count);
foreach (KeyValuePair oneTimeTutorial in sd.OneTimeTutorials){
w.WriteObject(oneTimeTutorial.Key);
w.Write(oneTimeTutorial.Value);
}
```
`w.WriteObject`稍微复杂一些,值得仔细看看。
```csharp
public static void WriteObject(this CrcWriter writer, string s){
writer.Write(s != null);
if (s == null) return;
writer.Write(s);
}
```
对象似乎被定义为可能存在也可能不存在的东西。首先,向文件写入一个`bool`,表示对象是否为`null`。如果它是`null`,就到此为止,我们不再写入任何内容。但如果它不是`null`,则序列化该值。在模式语言中,这看起来像这样:
```c
struct Object<T> {
bool isValid;
if (isValid)
T value;
};
```
这段代码定义了一个名为`Object`的新模板结构体。它在输出文件中放置一个`bool`,然后检查该`bool`是否为`true`。只有当它为`true`时,才会放置模板参数类型的值。接下来我们需要看看`string`类型是如何序列化的。`BinaryWriter`有另一个函数可以做到这一点:
```csharp
public virtual void Write(string value){
if (this.disposed)
throw new ObjectDisposedException(nameof (BinaryWriter), "Cannot write to a closed BinaryWriter");
this.Write7BitEncodedInt(this.m_encoding.GetByteCount(value));
if (this.stringBuffer == null) {
this.stringBuffer = new byte[512 /*0x0200*/];
this.maxCharsPerRound = 512 /*0x0200*/ / this.m_encoding.GetMaxByteCount(1);
}
int charIndex = 0;
int charCount;
for (int length = value.Length; length > 0; length -= charCount) {
charCount = length <= this.maxCharsPerRound ? length : this.maxCharsPerRound;
this.OutStream.Write(this.stringBuffer, 0, this.m_encoding.GetBytes(value, charIndex, charCount, this.stringBuffer, 0));
charIndex += charCount;
}
}
```
这似乎首先以某种`7BitEncodedInt`格式写出字符串的字节长度,然后是实际的字符串数据。
```csharp
protected void Write7BitEncodedInt(int value){
do {
int num1 = value >> 7 & 33554431 /*0x01FFFFFF*/;
byte num2 = (byte) (value & (int) sbyte.MaxValue);
if (num1 != 0)
num2 |= (byte) 128 /*0x80*/;
this.Write(num2);
value = num1;
} while (value != 0);
}
```
`Write7BitEncodedInt`看起来有点吓人,但在用一些值演练之后,它所做的只是使用每个字节的最高有效位(MSB)作为标志来告诉解析器是否还有另一个字节即将到来。其他7位是实际的编码值。在模式语言中,这可以这样实现:
```c
struct SevenBitEncodedIntByte {
u8 byte;
if ((byte & 0x80) == 0x00)
break;
};
struct SevenBitEncodedInt {
SevenBitEncodedIntByte bytes[while(true)];
};
```
此外,为了使这种类型更容易使用,我们可以使用`[[format]]`属性在模式数据视图中显示解码后的整数值,并使用`[[transform]]`属性,这样我们代码的其余部分就可以简单地读取此类型的变量,并获得解码后的整数值:
```c
struct SevenBitEncodedInt {
SevenBitEncodedIntByte bytes[while(true)];
} [[format("transformSevenBitEncodedInt"), transform("transformSevenBitEncodedInt")]];
fn transformSevenBitEncodedInt(ref auto encodedInt) {
u64 result = 0;
// 遍历我们之前放置的所有字节
for (u32 i = 0; i < std::core::member_count(encodedInt.bytes); i += 1) {
// 每个字节包含下一个更显著的7位组
result |= (encodedInt.bytes[i].byte & 0x7F) << (i * 7);
}
return result;
};
```
现在所有这些都完成了,我们终于可以定义我们的String类型了。再次使用一个不错的`[[format]]`函数,这样我们就可以直接在UI中看到字符串。
```c
struct String {
// 读取字符串的大小
SevenBitEncodedInt size;
char string[size];
} [[format("formatString")]];
fn formatString(ref auto string) {
return string.string;
};
```
ImHex解析的最终对象类型
ImHex解析的最终对象类型
### 列表
(https://werwolv.net/posts/file_format_reverse_engineering/#lists)
相似文章
Hexel Editor
Hexel Editor 是一款原生 macOS 十六进制编辑器,具备智能文件格式识别功能,面向开发者和高级用户。
二进制文件可视化
本文描述了在 'bine' 十六进制编辑器中增强的二进制文件可视化功能的开发,包括使用 Unicode 字符的彩色输出和一种将二进制文件解释为图像以进行大规模查看的方法。
Fisher-Price Pixter 的完整保存
详细记录了 Fisher-Price Pixter 设备及其游戏的首次完整逆向工程、文档化和模拟过程,涵盖了硬件分析、ROM 转储以及软件模拟。
使用Ruby逆向工程Codemasters的BIGF存档格式
一位逆向工程师解释了如何使用纯Ruby从《TOCA Race Driver》中读取Codemasters的BIGF存档格式,重点介绍使用String#unpack方法进行二进制解码。
二进制文件的视觉分析
本文探讨用于视觉分析二进制文件的方法,这有助于逆向工程和网络安全等任务。