C# Quellcode - 11.8 Kb

Worum geht es?

Jede Anwendung enthält viele Zeilen, die den Stack leer hinterlassen. Nach diesen Zeilen kann jeder Code eingefügt werden, sofern er den Stack wieder leer zurücklässt. Man kann ein paar Werte auf den Stack laden und wieder entfernen, ohne den Programmfluss zu stören.

Stille Verstecke finden

Werfen wir mal einen Blick auf den IL Assembler Language Code eines Assemblies. Jede Methode enthält Zeilen, die etwas auf den Stack schreiben oder herunter lesen. Wir können nicht immer vorhersagen, was genau bei welcher Zeile auf dem Stack liegt, darum sollten wir nichts zwischen zwei Zeilen ändern. Aber es gibt einige Zeilen, von denen man genau weiß, was auf dem Stack liegen muss.

Jede Methode enthält mindestens eine ret Anweisung. Wenn die Laufzeitumgebung ein ret erreicht, muss der Stack den Rückgabewert und sonst nichts enthalten. Das heißt, vor einer ret Anweisung in einer Methode, die einen Int32 zurückgibt, enthält der Stack genau einen Int32 Wert. Wir könnten ihn in einer lokalen Variable speichern, zusätzlichen Code einfügen, der einen leeren Stack hinterlässt, und dann den Rückgabewert zurück auf den Stack schreiben. Zur Laufzeit würde es niemand bemerken. Es gibt noch viel mehr solcher Zeilen, zum Beispiel die schließenden Klammern eines .try { und .catch { Blocks (definitiv leerer Stack!) oder Methodeaufrufe (nur ein Wert von bekanntem Typ auf dem Stack!). Um dieses Beispiel einfach zu halten, werden wir uns auf void-Methoden konzentrieren und alle anderen ignorieren. Wenn eine void-Methode verlassen wird, muss der Stack leer sein, so dass wir uns nicht mit Rückgabewerten aufhalten müssen.

Hier ist der IL Assembler Language Code einer typischen void Dispose() Methode:

.method family hidebysig virtual instance void
            Dispose(bool disposing) cil managed
    {
      // Code size       39 (0x27)
      .maxstack  2
      IL_0000:  ldarg.1
      IL_0001:  brfalse.s  IL_0016

      IL_0003:  ldarg.0
      IL_0004:  ldfld      class [System]System.ComponentModel.Container
                  PictureKey.frmMain::components
      IL_0009:  brfalse.s  IL_0016

      IL_000b:  ldarg.0
      IL_000c:  ldfld      class [System]System.ComponentModel.Container
                  PictureKey.frmMain::components
      IL_0011:  callvirt   instance void [System]System.ComponentModel
                  .Container::Dispose()
      IL_0016:  ldarg.0
      IL_0017:  ldarg.1
      IL_0018:  call       instance void [System.Windows.Forms]System
                  .Windows.Forms.Form::Dispose(bool)
      IL_0026:  ret
    }

Was wird also passieren, wenn wir eine neue lokale Variable einfügen und eine Konstante darin speichern, kurz bevor die Methode verlassen wird? Ja, nichts wird passieren, außer vielleicht einer minimalen Verzögerung.

.method family hidebysig virtual instance void
            Dispose(bool disposing) cil managed
    {
      // Code size       39 (0x27)
      .maxstack  2
      .locals init (int32 V_0) //neue lokale Variable deklarieren

          ...

      IL_001d:  ldc.i4     0x74007a //Eine int32 Konstante laden
      IL_0022:  stloc      V_0 //Die Konstante in der Variablen speichern
      IL_0026:  ret
    }

In C# würde diese Methode so aussehen:

//Original
protected override void Dispose( bool disposing ) {
        if( disposing ) {
                if (components != null) {
                        components.Dispose();
                }
        }
        base.Dispose( disposing );
}

//Version mit versteckter Variable
protected override void Dispose( bool disposing ) {
        int myvalue = 0;
        if( disposing ) {
                if (components != null) {
                        components.Dispose();
                }
        }
        base.Dispose( disposing );
        myvalue = 0x74007a;
}

Wir haben gerade vier Bytes in einer Anwendung versteckt! Die IL Datei wird sich wieder fehlerfrei kompilieren lassen, und wenn jemand das neue Assembly disassembliert kann er den Wert 0x74007a wiederfinden.

Einen geheimen Wert tarnen

Um Leuten, die eine Anwendung disassemblieren und nach nutzlosen Variablen suchen, die Arbeit zu erschweren, kann man die versteckten Werte als vergessene Debug-Ausgabe tarnen:

ldstr bytearray(65 00) //Ein "A" laden...
stloc mystringvalue    //...und wegspeichern
.maxstack  2           //Stackgrösse setzen, um Laufzeitfehler auszuschließen
ldstr "DEBUG - current value is: {0}"
ldloc mystringvalue    //vergessenen Debug-Outout vortäuschen
call void [mscorlib]System.Console::WriteLine(string, object)

Um auch in Konsolenanwendungen unsichtbar zu bleiben, sollten wir die Variablen besser als Operationen tarnen. Wir könnten mehr lokale/statische/Instanz-Variablen einfügen, damit es so aussieht, als würden die Werde an anderer Stelle gebraucht werden:

.maxstack  2  //Stack-Grösse anpassen
ldc.i4 65     //"A" laden
ldloc myintvalue //noch eine Variable laden - die Deklaraion steht irgendwo weiter oben
add           //65 + myintvalue
stsfld int32 NameSpace.ClassName::mystaticvalue //Ergebnis vom Stack entfernen

Dieses Beispiel soll demonstrieren, wie Informationen allgemein versteckt werden können, darum werden wir nur diese Variante verwenden:

ldc.i4 65;
stloc myvalue

Man muss nicht für jedes Byte der Nachricht zwei Zeilen einfügen. Wir können bis zu vier Bytes in einen Int32-Wert stecken, und so nur eine halbe Zeile pro verstecktem Byte einfügen. Aber zuerst müssen wir wissen, wo genau wir dieses einfügen.

Ein Disassembly analysieren

Bevor man eine IL Datei bearbeiten kann, wird ILDAsm.exe aufgerufen, um sie aus dem kompilierten Assembly zu erstellen. Später rufen wir ILAsm.exe auf, um die Datei zu re-assemblieren. Der interessante Teil spielt sich dazwischen ab: Wir müssen die Zeilen des IL Assembler Language Codes durchlaufen, die void-Methoden finden, dann ihre jeweils letzte .locals init Zeile, und eine ret-Zeile. Eine Nachricht kann mehr 4-Byte-Blöcke enthalten als void-Methoden vorhanden sind, darum müssen wir die Methoden zählen und die Anzahl der Bytes berechnen, die in jeder davon versteckt werden. Die Methode Analyse sammelt Namespaces, Klassen und void-Methoden:

/// <summary>Namespaces, Klassen und Methoden mit Rückgabetyp "void" auflisten</summary>
/// <param name="fileName">Name der IL Datei</param>
/// <param name="namespaces">Gibt die Namen gefundener Namespaces zurück</param>
/// <param name="classes">Gibt die Namen gefundener Klassen zurück</param>
/// <param name="voidMethods">Gibt die ersten Zeilen aller Methoden-Signaturen zurück</param>
public void Analyse(String fileName,
        out ArrayList namespaces, out ArrayList classes, out ArrayList voidMethods){

        //Rückgabelisten initialisieren
        namespaces = new ArrayList(); classes = new ArrayList(); voidMethods = new ArrayList();
        //Anfang der aktuellen Methode, oder null bei nicht-void Methoden
        String currentMethod = String.Empty;

        //IL Datei zeilenweise lesen
        String[] lines = ReadFile(fileName);

        //Für alle Zeilen der Datei: Listen füllen
        for(int indexLines=0; indexLines<lines.Length; indexLines++){
                if(lines[indexLines].IndexOf(".namespace ") > 0){
                        //Namespace gefunden!
                        namespaces.Add( ProcessNamespace(lines[indexLines]) );
                }
                else if(lines[indexLines].IndexOf(".class ") > 0){
                        //Klassen gefunden!
                        classes.Add( ProcessClass(lines, ref indexLines) );
                }
                else if(lines[indexLines].IndexOf(".method ") > 0){
                        //Methode gefunden!
                        currentMethod = ProcessMethod(lines, ref indexLines);
                        if(currentMethod != null){
                                //Methode gibt void zurück - auflisten
                                voidMethods.Add(currentMethod);
                        }
                }
        }
}

Mit der Anzahl verwendbarer Methoden können wir jetzt die Anzahl versteckter Bytes pro Methode berechnen:

//Länge des Unicode-Strings + 1- Position für die Länge (wird wie immer mit der Nachricht versteckt)
float messageLength = txtMessage.Text.Length*2 +1;
//Bytes pro Methode
int bytesPerMethod = (int)Math.Ceiling( (messageLength / (float)voidMethods.Count));

Endlich können wir anfangen. Die Methode HideOrExtract verwendet den Wert von bytesPerMethod, um die Zeilen für einen oder mehrere 4-Byte-Blöcke über den ret-Anweisungen einzufügen.

/// <summary>Versteckt oder extrahiert eine Nachricht in/aus einer IL Datei</summary>
/// <param name="fileNameIn">Name der IL Datei</param>
/// <param name="fileNameOut">Name für die Ausgabedatei - ignoriert, wenn [hide]==false</param>
/// <param name="message">Nachricht zum Verstecken, oder leerer Stream für die extrahierte Nachricht</param>
/// <param name="hide">true: [message] verstecken; false: eine Nachricht auslesen</param>
private void HideOrExtract(String fileNameIn, String fileNameOut, Stream message, bool hide){
        if(hide){
                //Zieldatei öffnen
                FileStream streamOut = new FileStream(fileNameOut, FileMode.Create);
                writer = new StreamWriter(streamOut);
        }else{
                //Anzahl der Bytes pro Methode ist noch unbekannt
                //und wird der erste ausgelesene Wert sein
                bytesPerMethod = 0;
        }

        //Quelldatei lesen
        String[] lines = ReadFile(fileNameIn);
        //nein, wir sind noch nicht fertig
        bool isMessageComplete = false;

        //Für alle Zeilen
        for(int indexLines=0; indexLines<lines.Length; indexLines++){

                if(lines[indexLines].IndexOf(".method ") > 0){
                        //Methode gefunden!
                        if(hide){
                                //einen Block von Bytes verstecken
                                isMessageComplete = ProcessMethodHide(lines, ref indexLines, message);
                        }else{
                                //Alle in dieser Methode versteckten Bytes auslesen
                                isMessageComplete = ProcessMethodExtract(lines, ref indexLines, message);
                        }
                }else if(hide){
                        //Die Zeile gehört nicht zu einer verwendbaren Methode - einfach kopieren
                        writer.WriteLine(lines[indexLines]);
                }

                if(isMessageComplete){
                        break; //Nichts mehr zu tun
                }
        }

        //Zieldatei schließen
        if(writer != null){ writer.Close(); }
}

Die Nachricht verstecken

Die Methode ProcessMethodHide kopiert die Signatur der Methode und prüft, ob der Rückgabetyp void ist. Dnach wird die letzte .locals init Zeile gesucht. Wird kein .locals init gefunden, dann wird die zusätzliche Variable am Anfange der Methode eingefügt. Die versteckte Variable muss die letzte Variable sein, die in der Methode deklariert wird, weil Compiler die IL Assembler Language ausgeben für lokale Variablen oft Slot Nummern anstelle von Namen verwenden. Stell Dir nur mal so eine Katastrophe vor:

//Ein C# Compiler hat diesen Code produziert, der 5+2 addiert
//Original C# code:
//int x = 5; int y = 2;
//mystaticval = x+y;

.locals init ([0] int32 x, [1] int32 y)
IL_0000:  ldc.i4.5
IL_0001:  stloc.0
IL_0002:  ldc.i4.2
IL_0003:  stloc.1
IL_0004:  ldloc.0
IL_0005:  ldloc.1
IL_0006:  add
IL_0007:  stsfld     int32 Demo.Form1::mystaticval
IL_000c:  ret

Würden wir eine Deklaration am Anfang der Methode einfügen, könnten wir den Code nicht re-assemblieren, da Slot 0 bereits von myvalue verwendet wird:

.locals init (int32 myvalue)
.locals init ([0] int32 x, [1] int32 y) //Fehler!
IL_0000:  ldc.i4.5
IL_0001:  stloc.0
...

Darum muss die zusätzliche lokale Variable nach dem letzten vorhandenen .locals init initialisiert werden. ProcessMethodHide fügt diese neue Variable ein, springt zur ersten ret-Anweisung und fügt ldc.i4/stloc Paare ein. Der erste Wert, der so versteckt wird, ist die Grösse des Nachrichten-Streams - die auslesende Methode braucht diesen Wert, um zu wissen wann sie aufhören muss. Der letzte Wert, der in der ersten Methode versteckt wird, ist die Anzahl von Nachrichten-Bytes pro Methode. Dieser muss direkt über der ret-Zeile stehen, da die auslesende Methode ihn finden muss, ohne zu wissen wie viele Zeilen sie zurück springen muss (weil das von gerade diesem Wert abhängt).

/// <summary>Versteckt ein oder mehrere Bytes des Nachrichten-Streams in der IL Datei</summary>
/// <param name="lines">Zeilen der IL Datei</param>
/// <param name="indexLines">Aktueller Index in [lines]</param>
/// <param name="message">Stream der die Nachricht enthält</param>
/// <returns>true: letztes Byte wurde versteckt; false: noch mehr Nachrichten-Bytes warten</returns>
private bool ProcessMethodHide(String[] lines, ref int indexLines, Stream message){
        bool isMessageComplete = false;
        int currentMessageValue,        //nächstes Byte zum Verstecken
                positionInitLocals,                //Index der letzten ".locals init"-Zeile
                positionRet,                        //Index der "ret"-Zeile
                positionStartOfMethodLine; //Index der ersten Zeile der Methode

        writer.WriteLine(lines[indexLines]); //copy first line

        //Ignorieren, wenn keine "void"-Methode
        if(lines[indexLines].IndexOf(" void ") > 0){
                //"void"-Methode gefunden
                //Der Stack wird am Ende leer sein,
                //also können wir (fast) alles Mögliche einfügen

                indexLines++; //Nächste Zeile
                //Anfang des Methoden-Blocks suchen, alle ausgelassenen Zeilen kopieren
                int oldIndex = indexLines;
                SeekStartOfBlock(lines, ref indexLines);
                CopyBlock(lines, oldIndex, indexLines);

                //Jetzt sind wir bei der öffnenden Klammer der Methode
                positionStartOfMethodLine = indexLines;
                //Zur ersten Zeile der Methode gehen
                indexLines++;
                //get position of last ".locals init" and first "ret"
                positionInitLocals = positionRet = 0;
                SeekLastLocalsInit(lines, ref indexLines, ref positionInitLocals, ref positionRet);

                if(positionInitLocals == 0){
                        //kein .locals - Zeile am Anfang er Methode einfügen
                        positionInitLocals = positionStartOfMethodLine;
                }

                //Von Anfang bis letztem .locals kopieren, oder nichts (wenn kein .locals gefunden)
                CopyBlock(lines, positionStartOfMethodLine, positionInitLocals+1);
                indexLines = positionInitLocals+1;
                //lokale Variable einfügen
                writer.Write(writer.NewLine);
                writer.WriteLine(".locals init (int32 myvalue)");
                //Rest der Methode bis zur Zeile vor "ret" kopieren
                CopyBlock(lines, indexLines, positionRet);

                //Nächste Zeile ist "ret" - auf dem Stack kann nichts kaputtgehen
                indexLines = positionRet;

                //ldc/stloc Paare einfügen für [bytesPerMethod] Bytes aus dem Message Stream
                //4 Bytes zu einem Int32 kombinieren
                for(int n=0; n<bytesPerMethod; n+=4){
                        isMessageComplete = GetNextMessageValue(message, out currentMessageValue);
                        writer.WriteLine("ldc.i4 "+currentMessageValue.ToString());
                        writer.WriteLine("stloc myvalue");
                }

                //bytesPerMethod muss der letzte Wert in der ersten Methode sein
                if(! isBytesPerMethodWritten){
                        writer.WriteLine("ldc.i4 "+bytesPerMethod.ToString());
                        writer.WriteLine("stloc myvalue");
                        isBytesPerMethodWritten = true;
                }

                //Aktuelle Zeile kopieren
                writer.WriteLine(lines[indexLines]);

                if(isMessageComplete){
                        //Nichts mehr gelesen, die Nachricht ist vollständig
                        //Rest der Quelldatei kopieren
                        indexLines++;
                        CopyBlock(lines, indexLines, lines.Length-1);
                }
        }
        return isMessageComplete;
}

Versteckte Werte auslesen

Die Methode ProcessMethodExtract sucht die erste ret-Zeile. Wenn die Anzahl der pro Methode versteckten Bytes noch unbekannt ist, wird zwei Zeile zurück gesprungen, wo die Anzahl aus der ldc.i4-Zeile gelesen wird (die als letzter Wert in die erste Methode eingefügt wurde). Andernfalls wird zwei Zeilen pro erwartetem ldc.i4/stloc Paar zurück gesprungen, von wo dann die 4-Byte-Blöcke extrahiert und in den Nachrichten-Stream geschrieben werden. Wenn kein ldc.i4 gefunden wird, wo eines sein sollte, wird das Programm eine Exception.
Der zweite ausgelesene Wert ist die Länge der folgenden Nachricht. Wenn der Nachrichten-Stream diese erwartete Länge erreicht hat, wird das isMessageComplete Flag gesetzt, HideOrExtract wird verlassen und die extrahierte Nachricht angezeigt. Auslesen funktioniert genauso wie Verstecken in umgekehrter Richtung.

Kein Schlüssel ?!

Bestimmt ist Dir aufgefallen, dass diese Anwendung keinen Schlüssel verwendet, um die Nachricht zu verteilen. Ein durchschnittliches Assembly enthält weniger void-Methoden als ein durchschnittlicher Satz, ein Verteilungsschlüssel wie er auf den letzten Seite verwendet wurde, würde hier nur dazu führen, dass massenweise Nonsense-Zeilen in wenige Methoden geschreiben werden, was allzu offensichtlich wäre.
Eine Schlüssel-Datei könnte hier verwendet werden, um eine wechselnde Tarnung der Nachrichten-Bytes festzulegen - Debug-Ausgabe, Operationen, Instanz-Felder, zusätzliche Methoden, und so weiter. Eine komplexere Variante als dieses Beispiel sollte außerdem alle Methoden (nicht nur voids) verwenden, wobei auch ein Verteilungsschlüssel wieder Sinn macht.

Warnung

Diese Beispiel-Anwendung funktioniert mit den Assemblies, mit denn ich sie getestet habe, könnte mit anderen Assemblies aber genausogut daneben gehen. Falls Du ein Assembly findest, mit dem sie nicht zurecht kommt, schreib mir eine Diese E-Mail-Adresse ist vor Spambots geschützt! Zur Anzeige muss JavaScript eingeschaltet sein! und ich schau nach, was ich falsch gemacht habe.