Reverse Engineering van Onbekende Bestandsformaten met ImHex
Inleiding
Meestal kon ik geen echt goed antwoord geven, behalve: "Kijk naar de gedecompileerde code van het programma dat deze bestanden leest of schrijft, en werk vanaf daar terug." Dit artikel is bedoeld om dat te veranderen. We gaan van een volledig aangepast binair save-bestand voor het spel FEZ naar een volledige definitie geschreven in de Pattern Language, onderdeel van ImHex. ImHex is de hex-editor die ik de afgelopen jaren heb ontwikkeld; het is gratis, open source en beschikbaar voor elk besturingssysteem (en zelfs via de browser via ImHex Web).
ImHex Versie Ten tijde van het schrijven zijn sommige hier gebruikte functies nog niet in een officiële release opgenomen, maar alleen beschikbaar in de Nightly-build. Als je ImHex v1.38.1 of een oudere versie gebruikt en problemen ervaart, overweeg dan om te upgraden naar de Nightly-build.
Aan de slag
Waarschuwing voor spoilers FEZ kwam al uit in 2012. Toch raad ik je aan, als je het nog niet gespeeld hebt, om dat eerst te doen voor de volledige ervaring. Sommige van de getoonde code bevat zware spoilers voor geheimen en endgame-content.
De eerste stap is het verkrijgen van het save-bestand. Na het spelen van het spel (gebaseerd op de laatste volledige release van 2 december 2016) is het bestand gevonden onder /home/werwolv/.local/share/FEZ/SaveSlot2 (op Windows bevindt dit zich op een andere locatie).
Het openen van het bestand in ImHex toont het volgende:
SaveSlot2
Hex View 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 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_
Dit onthult direct een paar zaken. Het bestand lijkt niet gecomprimeerd en niet versleuteld, wat blijkt uit de platte tekststrings en andere patronen die direct zichtbaar zijn in de bytes. De data bevat geen 'file magic' (herkenbare tekst aan het begin van het bestand) en ImHex kan het type ook niet direct identificeren. Zonder verdere informatie zijn we hier in feite vastgelopen; alleen het programma dat de data genereert en parseert, kan er betekenis aan geven.
Het spel decompileren
De juiste bestanden vinden
Door naar de lokale bestanden van het spel via Steam te gaan, vallen bestanden als System.Core.dll of mscorlib.dll direct op. Het spel is geschreven in de programmeertaal C#, wat over het algemeen zeer eenvoudig te reverse engineeren is. Tools zoals JetBrains Rider kunnen de binaries decompileren naar iets wat lijkt op de originele broncode.
Door de projectmap te openen en via de Assembly Explorer naar interessante .dll-bestanden te kijken, kwamen FEZ.exe, FezEngine.dll, Common.dll, ContentSerialization.dll en EasyStorage.dll naar voren als de meest relevante.
De juiste functies vinden
Het doorzoeken van de namespaces leidt snel naar een interessant bestand: EasyStorage -> PCSaveDevice.
In de constructor van die klasse zien we de regel string str = "SaveSlot" + (object) index;, wat aangeeft dat hier de naam van ons bestand (SaveSlot2) wordt opgebouwd. Verderop vinden we een functie genaamd Save die een byte-buffer aanmaakt en deze vult met een BinaryWriter stream voordat deze naar de locatie op schijf wordt geschreven.
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!"
);
}
}
// ...
}
Het ImHex Pattern schrijven
Eerste stappen
Nu we weten waar het save-bestand wordt gegenereerd, kunnen we een Pattern-bestand in ImHex schrijven om de data te decoderen. We beginnen met het maken van een struct FezSaveFile en plaatsen deze aan het begin van het bestand met de @ placement operator.
fez.hexpat
struct FezSaveFile {
// Struct Definitie
};
FezSaveFile saveFile @ 0x00;
In de code voor het genereren van het bestand zien we writer.Write(DateTime.Now.ToFileTime());. Dit schrijft het huidige tijdstip als een Windows File Time. Dit is een little-endian 64-bit waarde (een long in C#) die het aantal intervallen van 100 nanoseconden vertegenwoordigt sinds het jaar 1601 n.Chr. We kunnen een s64 gebruiken om dit te lezen, of type-aliassen maken met het using keyword om de code dichter bij de originele types te brengen.
fez.hexpat
using int = s32;
using long = s64;
struct FezSaveFile {
long fileTime;
};
FezSaveFile saveFile @ 0x00;
Voor dit specifieke geval biedt de standaardbibliotheek van ImHex echter al een type voor het decoderen van Windows FILETIME-waarden. Door de type.time bibliotheek te importeren, wordt de onleesbare getalswaarde omgezet in een menselijk leesbare tijdsaanduiding.
fez.hexpat
import type.time;
struct FezSaveFile {
type::FILETIME fileTime;
};
FezSaveFile saveFile @ 0x00;
Het [[fixed_size]] attribuut
Uit de Save() functie blijkt dat het save-bestand altijd exact 0xA000 bytes lang is. Als het korter is, wordt het opgevuld met nullen; als het langer is, wordt er een uitzondering gegooid. Dit kan worden gedocumenteerd in ImHex met het [[fixed_size(0xA000)]] attribuut.
De werkelijke save-data
Terug in de C#-code zien we dat de saveAction callback wordt aangeroepen. In GameStateManager.cs (functie SaveInternal()) zien we dat de daadwerkelijke data-dump wordt gedelegeerd aan de functie DoSave(), die vervolgens SaveFileOperations.Write() aanroept. Hier worden alle verschillende velden naar het binaire bestand geschreven.
GameStateManager.cs
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);
// ...
}
Deze types kunnen eenvoudig worden omgezet naar het ImHex Pattern:
fez.hexpat
struct FezSaveFile {
// Uit PCSaveDevice.cs
type::FILETIME fileTime;
// Uit SaveFileOperations.cs
long version; // Gecontroleerd in de `Read()` functie op waarde 6
long creationTime;
bool finished32;
bool finished64;
bool hasFpView;
bool hasStereo3d;
bool canNewGamePlus;
bool isNewGamePlus;
};
Het eerste veld is de versie van het save-bestand. We kunnen een std::assert gebruiken om te garanderen dat we alleen bestanden laden die compatibel zijn met ons pattern.
fez.hexpat
import std.sys;
struct FezSaveFile {
type::FILETIME fileTime;
long version;
std::assert(version == 6, "Niet ondersteunde versie van het save-bestand. Alleen versie 6 wordt ondersteund");
// ...
};
Objecten en Strings
Vervolgens wordt een lijst van String -> Bool key-value paren geserialiseerd. Eerst wordt het aantal paren opgeslagen, gevolgd door de paren zelf.
GameStateManager.cs
w.Write(sd.OneTimeTutorials.Count);
foreach (KeyValuePair<string, bool> oneTimeTutorial in sd.OneTimeTutorials)
{
w.WriteObject(oneTimeTutorial.Key);
w.WriteObject(oneTimeTutorial.Value);
}
De functie WriteObject werkt als volgt: er wordt eerst een boolean geschreven die aangeeft of het object null is. Alleen als het object niet null is, wordt de eigenlijke waarde geschreven. In de Pattern Language ziet dit er zo uit:
fez.hexpat
struct Object<T> {
bool isValid;
if (isValid)
T value;
};
Voor strings wordt eerst de lengte geschreven in een 7BitEncodedInt formaat, gevolgd door de data. Write7BitEncodedInt gebruikt de Most Significant Bit (MSB) van elke byte als vlag om aan te geven of er nog een volgende byte volgt. De overige 7 bits bevatten de eigenlijke waarde.
Dit kan als volgt worden geïmplementeerd:
fez.hexpat
struct SevenBitEncodedIntByte {
u8 byte;
if ((byte & 0x80) == 0x00)
break;
};
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) {
result |= (encodedInt.bytes[i].byte & 0x7F) << (i * 7);
}
return result;
};
struct String {
SevenBitEncodedInt size;
char string[size];
} [[format("formatString")]];
fn formatString(ref auto string) {
return string.string;
};
Lijsten
Met de bovenstaande types kunnen we nu de lijsten in het bestand parsen. Omdat Count in C# een int is, gebruiken we s32.
fez.hexpat
struct List<T> {
int count;
T items[count];
};
struct KeyValuePair<Key, Value> {
Key key;
Value value;
};
Alles samen resulteert dit in de volgende definitie voor de tutorial-lijst:
fez.hexpat
struct FezSaveFile {
// ...
List<
KeyValuePair<
Object<String>,
bool
>
> oneTimeTutorials;
};
Enums
Hetzelfde patroon geldt voor enumeraties. In de code zien we dat ActorType wordt geschreven als een integer. We kunnen dit in ImHex direct als enum definiëren voor een betere leesbaarheid.
ActorType.cs
public enum ActorType
{
None,
Ladder,
Bouncer,
Sign,
GoldenCube,
// ...
}
fez.hexpat
enum ActorType : int
{
None,
Ladder,
Bouncer,
Sign,
GoldenCube,
// ...
};
Meer sub-types
Dit proces gaat door tot we bij geneste serialisatie komen, zoals bij de werelddata:
SaveFileOperations.cs
foreach (KeyValuePair<string, LevelSaveData> keyValuePair in sd.World)
{
w.WriteObject(keyValuePair.Key);
SaveFileOperations.Write(w, keyValuePair.Value);
}
Dit mapte direct naar een nieuwe struct genaamd LevelSaveData.
fez.hexpat
struct LevelSaveData {
// ...
};
struct FezSaveFile {
// ...
List<KeyValuePair<Object<String>, LevelSaveData>> world;
};
De vruchten van ons werk
Op dit punt zou elke byte in de Hex Editor View (behalve de padding aan het einde) gekleurd moeten zijn. Je kunt nu door de Pattern Data View navigeren om de waarden te inspecteren en ze zelfs wijzigen door dubbel te klikken op een waarde.
Samenvattend
Na het lezen van dit artikel heb je een basisbegrip van hoe je een binair bestandsformaat reverse engineered. Niet alle programma's zijn zo eenvoudig te decompileren als dit voorbeeld, maar de algemene workflow blijft hetzelfde:
- Controleer of het bestand in een bekend formaat staat
Gebruik tools zoals ImHex magic detection of binwalk. Als het een bestaand formaat is, zijn er wellicht al tools beschikbaar of is er een specificatie te vinden.
- Zoek de code die het bestand parseert of genereert
Kijk naar de broncode of decompileer het programma (Rider voor .NET, Ghidra/IDA/Binary Ninja voor native code, Recaf voor JVM). Zoek naar File I/O library calls of strings die verwijzen naar de bestandsnaam.
- Analyseer de code en identificeer de bouwstenen
De meeste formaten slaan integers, booleans en strings op. Het identificeren hiervan is de eerste stap.
- Schrijf een Pattern-bestand om je bevindingen te documenteren en te verifiëren
Patterns zijn niet alleen handig voor het decoderen, maar ook voor het vastleggen en valideren van je ontdekkingen tijdens het proces.
Groetjes,