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:

  1. 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.

  1. 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.

  1. Analyseer de code en identificeer de bouwstenen

De meeste formaten slaan integers, booleans en strings op. Het identificeren hiervan is de eerste stap.

  1. 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.