EarthCore - Injecting, Sicherheit und Spieldaten

Earthcore: Shattered Elements ist ein Online Kartenspiel für Android und iOS, programmiert mit dem Unity Framework. Von den vier Handkarten spielt man drei auf das Spielfeld aus, wobei jede Karte eines von 3 Elementen hat: Feuer, Wasser oder Erde. Am Ende der Runde wird das eigene Kartenelement mit dem Element der gegnerischen, gegenüberliegenden Karte verglichen. Nun schlägt Wasser Feuer, Feuer Erde und Erde Wasser. Der Verlierer auf dem jeweiligen Feld bekommt Lebenspunkte abgezogen, die sich aus dem ausgespielten Kartenwert berechnen. Sollten sich gleiche Elemente gegenüberliegen, wird der Angriffswert jeweils für die nächste Runde gespeichert. Im Grunde also ein “Schere-Stein-Papier” auf drei Feldern gleichzeitig. Spannend wird das Spiel, wenn man die Skills der Karten dazunimmt. Man kann Karten auf Feldern tauschen, die Elemente von Karten ändern oder auch Direktschaden am Gegener verursachen. Mehr möchte ich an dieser Stelle garnicht über das Spiel erzählen, hier geht es um die Internals, das Injecten und natürlich das Daten auslesen.

earthcoreNicht ohne Grund habe ich direkt im ersten Satz erwähnt, dass das Spiel mit Unity entwickelt wurde. Für den Programmierer ist das praktisch, da er Plattformunabhängig und sogar mit .NET Programmieren kann. Für uns hat das den Vorteil, dass wir den kompletten Sourcecode unobfuscated vor uns liegen haben! Dabei befinden sich alle interessanten Klassen in der Assembly-CSharp.dll, die wiederrum in den Ressourcen der APK liegt. Zwar hat man alle Daten unverschlüsselt vor sich liegen, nur ohne die Möglichkeit des Auslesens oder Veränderns bringen uns die schönsten Klassenstrukturen nichts. Wir müssen also eigenen Code in die Android Applikation bekommen. Während schon bei dem Hearthstone-Artikel das CLRHosting mit Mono fehlschlägt, haben hier hier nichtmal die Möglichkeit den Mono Kontext zu kapern. Nach einigem hin und herüberlegen fiel mir eine passable Lösung ein: Benötigt ist ein Loader, der wiederrum weitere DLLs zur Laufzeit nachladen kann. Dieser Loader wird mit in die APK gepackt und muss nun irgendwo im Spiel aufgerufen werden um den eigenen Thread zu starten. Was ist da passender, als die Init-Funktion des Hauptmenüs? In Unity erbt jede Klasse die Renderlogik enthält von “MonoBehaviour”. Davon wird die Start()-Methode aufgerufen sobald die Klasse initialisiert wird, und genau hier packen wir einen call auf unsere statische Loader-Klasse rein. Die will ich nun etwas genauer Besprechen:

Die Idee dahinter ist, einen kleinen TCPServer in der Android Applikation zu starten der auf Verbindungen wartet. Vom “Client” her senden wir nun eine .NET Assembly hin, die über Reflection geladen und aufgrufen wird. Da die gesendete Assembly eine Referenz auf die EarthCore-Klassen besitzt, kann sie auch später auf die internen EarthCore-Klassen zugreifen.

public static void Load()
{
    // If already loaded then abort load process
    if (IsAlreadyLoaded)
        return;
 
    // Init buffer for the retrieved assembly, start TCP Thread
    currentAssembly = new MemoryStream();
    new Thread(new ThreadStart(LoaderThread)).Start();
 
    IsAlreadyLoaded = true;
 
    // Log to logcat
    Process.Start("log", "-p e -t unitystuff Loaded in Assembly!");
}

Mehr als einen Thread-Starten und ein Log absetzen passiert hier noch nicht, daher schauen wir uns den Loader-Thread an:

private static void LoaderThread()
{
    // Listen at 1337 and retrieve from any IP Address
    TcpListener listener = new TcpListener(IPAddress.Any, 1337);
    listener.Start();
    // Endless Loop, do not do this!
    while (true)
    {
        // Accept Client. This methods blocks and waits
        var acceptedClient = listener.AcceptTcpClient();
 
        // Internal State Object, not that interesting
        StateObject state = new StateObject();
        state.workClient = acceptedClient;
 
        var connectionStream = acceptedClient.GetStream();
        // Read Async!
        connectionStream.BeginRead(state.buffer, 0, state.buffer.Length, OnDataRecieved, state);
    }
}

Wir warten auf neue Clients, die sich auf Port 1337 mit dem Handy verbinden. Dann starten wir den Lese-Thread asynchron und das warten auf Verbindungen beginnt von vorne. Zum Anschluss noch die gekürzte Version der OnDataRecieved-Methode, die den Code zum Laden der eigentlichen Assembly enthält:

// if all data recieved:
Process.Start("log", "-p e -t unitystuff Retrieved ASM. Length: " + currentAssembly.Length);
try
{
    // Load the .NET Assembly
    var asm = Assembly.Load(currentAssembly.ToArray());
    Process.Start("log", "-p e -t unitystuff Assembly Loaded: " + asm.ToString());
    // Get Type and Entry Point Method via Reflection
    var mainType = asm.GetType("EarthCoreInjected.EP");
    var entryPointFunc = mainType.GetMethod("EntryPoint", BindingFlags.Static | BindingFlags.Public);
 
    Process.Start("log", "-p e -t unitystuff loaded_invoking_notnull_" + (entryPointFunc != null).ToString());
    if (entryPointFunc != null)
    {
        // if entry point found, invoke it (static invoke, 1 parameter)
        var result = entryPointFunc.Invoke(null, new object[] {"test"});
        Process.Start("log", "-p e -t unitystuff Result Invoke: " + result.ToString());
    }
 
    Process.Start("log", "-p e -t unitystuff invoked. Clearing up");
 
    // Clear buffer to retrieve new assembly
    currentAssembly = new MemoryStream();
}
catch (Exception e)
{
    Process.Start("log", "-p e -t unitystuff ex: " + e.ToString());
}

Nun haben wir also die Möglichkeit, Assemblies an das Handy zu schicken die direkt geladen werden! Nun, ganz so einfach ist es leider nicht… Denn die Assembly wird in die AppDomain, also der Ausführungskontext von .NET, geladen. Wenn wir nun eine zweite Assembly an den Server schicken, so ist bereits die erste Assembly unter exakt diesem Namen geladen und die zweite, neuere Version der Assembly wird ignoriert. Und diese aus dem AppDomain Kontext entladen geht nicht! Die einzige Möglichkeit ist es, eine neue AppDomain zu erzeugen, dort die Assembly reinzuladen und beim Empfangen der neuen Version die erzeugte AppDomain wieder zu löschen. Was auf dem Computer wunderbar klappt, macht auf dem Handy einfach nichts. Nichtmal eine Exception wurde geworfen! Nach einigen Stunden des Probierens wurde ich in einem von tausenden Logeinträgen fündig: “AppDomain Methods not implemented yet”, eine Warnung von mono.

Im Endeffekt nutze ich nun einen kleinen Hack: Die App-Domain mag es nicht wenn eine neue Assembly gleichen Namens geladen wird, also ändern wir den Namen doch vor dem verschicken:

var ownAssemblyBytes = File.ReadAllBytes(@"c:\Users\Easysurfer\Documents\Visual Studio 2012\Projects\EarthCoreInjected\EarthCoreInjected\bin\x86\Debug\EarthCoreInjected.dll");
dnlib.DotNet.AssemblyDef asmDef = AssemblyDef.Load(ownAssemblyBytes);
asmDef.Name = "EarthCoreInjected" + new Random().Next().ToString();
 
var memStream = new MemoryStream();
asmDef.Write(memStream);
// verschicke diesem MemoryStream über TCP an das Handy

Wir laden die Assembly und hängen über die dnlib (eine bessere Version von Mono.Cecil) eine zufällige Zahl an den Assemblynamen an. Der Nachteil ist natürlich, dass wir irgendwann 50 Assemblies in dem Handyprogramm haben. Aber das Stört bei einer größe von wenigen Kilobytes nicht.

Das Debuggen von einer .NET (Mono) Runtime mit einer dynamischen geladenen Assembly über TCP/IP ist das so ziemlich komplizierteste vorstellbare Szenario. Daher braucht es viele Abstürze und Versuche, bis man endlich am Ziel ist. Um eine Veränderung an dem Loader vorzunehmen, darf man viele zeitaufwändige Schritte ausführen: Das Deinstallieren der App, Ersetzen der Loader-DLL, Packen mit APKTool, Signieren mit JarSigner, Installieren und kopieren der zusätzlichen OBB Dateien über FTP. Mit dem Handy ist das alles sehr unständlich, also warum nicht einen Emulator nehmen? Auch hier habe ich einige Stunden rumprobiert, bis ich resigniert aufgegeben habe. Der Android-Emulator “Andy” leistet zwar gute Dienste und ist schnell, unterstützt aber nur den NAT Netzwerkmodus. Eine Verbindung auf einem gewünschten Port 1339 mit der VM ist also nicht möglich. Das Laden der Andy vmx-Datei im VMWare funktioniert auch nicht und zerstört dabei das Image… :/ Eine normale Android x86 Version in die VM installiert funktioniert zwar mit dem Bridged Network Mode, aber Earthcore lässt sich auf diesem Gerät nicht installieren. Zwar gibt es Möglichkeiten diese Beschränkungen zu umgehen, aber großartig rumprobieren wollte ich auch nicht mehr. So blieb es im Endeffekt doch bei vielen Appstarts und Wartezeiten direkt auf dem Android Gerät.

Noch eine kleine Anmerkung zur Sicherheit am Rande: Der Entwickler Tequila läd oftmals Logs von Spielen auf einen FTP Server hoch. Dabei sind die FTP-Zugangsdaten in Plaintext hinterlegt und der FTP User besitzt Leserechte für alle Ordner. Auf dem Server liegen auch Daten von anderen Spielen des Entwicklers, die ganzen APKs und OBB Dateien sowie ein paar Webfiles. Keine so gute Idee… ;)

So, nun zum spannenden Teil: Wir haben uns erfolgreich in die Assembly geladen und jetzt Zugriff auf viele Klassen. Doch wie Kommunizieren wir eigentlich die ausgelesen Daten? Auch hier habe ich auf einen TCPServer zurückgegriffen, der ein simples Protokoll implementiert: Der Client (= PC) sendet ein Byte (= Command) und der Server (Handy) antwortet mit den gewünschten Daten. 0×100 beendet den Server, damit wir eine neue Assembly laden können.

Wir interessieren uns natürlich für die Daten auf dem Spielfeld, den Spieler und die Handkarten des Gegners. Doch wie finden wir diese? In Unity sind die einzelnen Menüs und Spielzustände in sog. Szenen gekapselt. Die GameScene sieht vielversprechend aus und implementiert ein Singleton-Pattern. Das heißt, dass wir von überall darauf zugreifen können!

Log("Action: ShowHandcards");
 
// If the GameScene is set
if (GameScene.Instance != null)
{
    // If the actual Game is set
    if (GameScene.Instance.Game != null)
    {
        var game = GameScene.Instance.Game;
        // If the game data is set
        if (game.Data != null)
        {
            // if two players are in the game data
            if (game.Data.Players.Length >= 2)
            {
                // Parse the player data in our struct to send via TCP
                var playerDatas = new List();
                playerDatas.Add(GamePlayerDataS.FromGamePlayerData(game.Data.Players[0], game.Data));
                playerDatas.Add(GamePlayerDataS.FromGamePlayerData(game.Data.Players[1], game.Data));
 
		// Singleplayer Hack: Set Enemys Health to 1
                game.Data.Players[1].HP = 1;
 
                // We Serialize with XML (Binary didnt work)
                Log("Serializing");
                System.Xml.Serialization.XmlSerializer x = new System.Xml.Serialization.XmlSerializer(typeof(List));
                var stream = new MemoryStream();
                x.Serialize(stream, playerDatas);
 
                // Write the player data to the Client
                connectionStream.Write(stream.ToArray(),0, (int)stream.Length);
                // Start listening again
                bytesRead = connectionStream.Read(buffer, 0, buffer.Length);
                continue;

Damit bekommen wir alle Informationen über den Spieler und setzen sogar das Leben des Gegners auf 1. Da man mit den Singleplayer-Missionen auch Gold für Kartenpacks verdient ist das sicher nicht schlecht ;) Im Multiplayer wird man dafür übrigens gekickt, weil eine Abweichung von der gesendeten HP zu der HP auf dem Server besteht.

Mit diesen Daten können wir nun eine kleine Auswertesoftware schreiben:

Wie sieht das ganze im Multiplayer aus? Gut für den Hacker, schlecht für den ehrlichen Spieler! Denn auch hier kann man einfach die Handkarten des Gegners auslesen, was natürlich einen erheblichen Vorteil bringt. So weis man, welche Karten gekontert werden und mit welchen Karten man den Gegner schlagen kann. Manipulationen der Lebenspunkte führt (zumindest beim Gegner) zu einem Kick. Ich bin mir aber ziemlich sicher, dass das Protokoll nicht komplett gegen Veränderungen immun ist. So werden die nachgezogenen Karten nach einer Runde nicht vom Server generiert, sondern von einem lokalen RNG. Theoretisch ist es also möglich, die Karten die man als nächstes Ziehen möchte selbst zu bestimmen.

Weiter habe ich mich mit EarthCore nicht beschäftigt, dennnoch möchte ich den Artikel mit einigen weiterführenden Ideen abschließen: Wenn man die Handkarten jedes Spielers kennt, so hat man nach Einberechnung aller Skills ein Spiel mit perfekten Informationen. Damit ist es möglich eine KI zu schreiben, hier sei der MinMax-Algorithmus erwähnt. Auch kann man das Netzwerkprotokoll nachimplementieren, die Klassen dazu hat man ja schon. So lässt sich ein PC-Client für Earthcore schreiben und sein Geld ins unendliche treiben (Erinnere: Singleplayer-Spiele geben Geld und werden nicht auf dem Server verifiziert).

Ich konnte mit dem Earthcore Projekt einiges lernen und vertiefen: TCP Protokolle, APK Building mit OBB Dateien, Serialisation, Logging über ADB und das Umgehen mit VMs sind sicherlich noch nicht alle Themen denen ich begegnet bin. Hoffentlich habt ihr auch einen kleinen Eindruck bekommen was nötig ist, ein solches Spiel zu “hacken”.

EDIT (Nachtrag): Den Server stört es nicht, wenn man sich als Spieler einfach Gold oder Diamanten hinzufügt. Diese können ohne Probleme zum Kauf von Kartenpacks genutzt werden. Auch stört es den Server wenig, dass ich mir alle Karten in meine Kartensammlung hinzugefügt habe. Auch der Goldverdoppeler ist nun immer aktiv, ein Feature das erst per In-App-Purchase gekauft und aktiviert werden muss…

2 people like this post.
  1. Noch keine Kommentare vorhanden.

  1. Noch keine TrackBacks.