Spaß auf dem Stack

C# Quellcode - 13.0 Kb

Worum geht es?

Im letzten Artikel wurden nur void-Methoden verwendet, was die Länge der versteckten Nachricht stark eingeschränkt hat. Dieser Artikel erweitert das Programm:

Der Unterschied zwischen void- und nicht-void-Methoden ist nicht sehr groß. Am Ende einer nicht-void-Methode ist der Stack nicht leer, er enthält einen Wert des deklarierten Typs. Das heisst, diese Anwendung muss den Namen des Rückgabetyps aus der Methodensignatur lesen, und eine zusätzliche lokale Variable deklarieren. In der Zeile vor dem ersten “ret” muss sie den Stack-Inhalt (der den Rückgabewert enhält, und sonst nichts) in dieser Variablen speichern, die Zeilen mit den versteckten Bytes einfügen, und dann den Wert zurück auf den Stack laden.

Schau Dir als Beispiel diese int-Methode an:

private int intTest(){
        int a = 1;
        return a;
}

Der C# compiler übersetzt sie so:

.method private hidebysig instance int32
        intTest() cil managed
{
        // Code size       8 (0x8)
        .maxstack  1
        .locals init ([0] int32 a,
                        [1] int32 CS$00000003$00000000)
        IL_0000:  ldc.i4.1
        IL_0001:  stloc.0
        IL_0002:  ldloc.0
        IL_0003:  stloc.1
        IL_0004:  br.s       IL_0006

        IL_0006:  ldloc.1
        IL_0007:  ret
} // end of method Form1::intTest
    Der Compiler hat eine zweite Variable erzeugt, um den Rückgabewert abzulegen.
    Am Ende der Methode wird dieser Wert auf den Stack gelegt, das ist schon alles.
    Also wird nichts kaputt gehen, wenn wir ein paar Zeilen zwischen `IL_0006`
    und `IL_0007` schreiben, und anschließend den Stack aufräumen
    bevor wir wieder den Rückgabewert laden:
.method private hidebysig instance int32
        intTest() cil managed
{
                // Code size       8 (0x8)
        .maxstack 2 //Stack-Größe anpassen
        .locals init ([0] int32 a,
                                [1] int32 CS$00000003$00000000)

        .locals init (int32 myvalue)
        IL_0000:  ldc.i4.1
        IL_0001:  stloc.0
        IL_0002:  ldloc.0
        IL_0003:  stloc.1
        IL_0004:  br.s       IL_0006

        IL_0006:  ldloc.1

        .locals init (int32 returnvalue) //Variable hinzufügen
        stloc returnvalue //Rückgabewert zwischenspeichern
        ldstr "DEBUG - current value is: {0}" //etwas das wie alter Debug-Code aussieht
        ldc.i4 111 //Heir ist unser versteckter Wert
        box [mscorlib]System.Int32
        call void [mscorlib]System.Console::WriteLine(string,
        object)

        ldloc returnvalue //Rückgabewert dorthin zurücklegen, wo er her kam
        IL_0007:  ret
} // end of method Form1::intTest

Jetzt kann ILAsm den Code re-compilieren. Wenn man ihn wieder decompiliert, kann man sehen, dass ILAsm die Variablendeklarationen optimiert hat:

    .method private hidebysig instance int32
            intTest() cil managed
    {
      // Code size       36 (0x24)
      .maxstack  2
      .locals init (int32 V_0, //ILAsm hat die lokalen Variablen zusammengefaßt !
               int32 V_1,
               int32 V_2,
               int32 V_3)
      IL_0000:  ldc.i4.1
      IL_0001:  stloc.0
      IL_0002:  ldloc.0
      IL_0003:  stloc.1
      IL_0004:  br.s       IL_0006

      IL_0006:  ldloc.1
      IL_0007:  stloc      V_3
      IL_000b:  ldstr      "DEBUG - current value is: {0}"
      IL_0010:  ldc.i4     0x6f
      IL_0015:  box        [mscorlib]System.Int32
      IL_001a:  call       void [mscorlib]System.Console::WriteLine(string,
                                                                    object)
      IL_001f:  ldloc      V_3
      IL_0023:  ret
    } // end of method Form1::intTest

ILAsm räumt meine Zeilen auf, ist das nicht nett? Nein, das ist überhaupt nicht nett, denn wir können uns nicht darauf verlassen, dass unsere eingefügten Zeilen noch vorhanden sind, nachdem der IL Code compiliert und wieder decompiliert wurde. Das heisst, was immer wir einfügen, um Teile der geheimen Nachricht zu verstecken, muss einen Sinn ergeben. Eine zusätzliche .maxlength-Zeile wird verschwinden, genau wie eine .locals init-Zeile mit Variablen, die nie verwendet werden. Bitte denke an diesen Effekt, wenn Du Dir neue Byte-Verkleidungen ausdenkst!

Einen Schlüssel-Stream verwenden

Im letzten Artikel wurden immer diese zwei Zeilen verwendet, um einen int32 zu verstecken:

ldc.i4 65;
stloc myvalue

Wie Du schon oben gesehen hast, können diese Zeilen die gleichen Daten verstecken:

ldstr      "DEBUG - current value is: {0}"
ldc.i4     65
box        [mscorlib]System.Int32
call       void [mscorlib]System.Console::WriteLine(string, object)

Es gibt hunderte solcher Blöcke, welche Variantion sollen wir verwenden? Wir werden alle verwenden, oder - um das Beispiel einfach zu lassen - diese zwei Variationen. Der Benutzer kann eine beliebige Datei angeben, und für jeden Vier-Byte-Block liest die Anwendung ein Byte aus dieser Datei: Wenn es eine gerade Zahl ist, wird die erste Variation verwendet, sonst die Zweite.

private bool ProcessMethodHide(String[] lines, ref int indexLines,
                                                                Stream message, Stream key){
        //...
        //Zeilen fü [bytesPerMethod] Bytes aus dem Nachrichten-Stream einfügen
        //jeweils 4 bytes zu einem Int32 kombinieren
        int keyValue; //aktueller Wert aus dem Schlüssel-Stream
        for(int n=0; n<bytesPerMethod; n+=4){
                isMessageComplete = GetNextMessageValue(message, out currentMessageValue);

                //nächstes Bytes aus dem Schlüssel lesen
                if( (keyValue=key.ReadByte()) < 0){
                        key.Seek(0, SeekOrigin.Begin);
                        keyValue=key.ReadByte();
                }

                if(keyValue % 2 == 0){
                        //der Schlüssel ist gerade - erste Variation verwenden
                        writer.WriteLine("ldc.i4 "+currentMessageValue.ToString());
                        writer.WriteLine("stloc myvalue");
                }else{
                  //der Schlüssel ist ungerade - zweite Variation verwenden
                  writer.WriteLine("ldstr \"DEBUG - current value is: {0}\"");
                  writer.WriteLine("ldc.i4 "+currentMessageValue.ToString());
                  writer.WriteLine("box [mscorlib]System.Int32");
                  writer.WriteLine("call void [mscorlib]System.Console::WriteLine(string, ");
                  writer.WriteLine( "object)" ); //ILDAsm fü hier einen Zeilenumbruch ein
                }
        }
        //...
}

Bei der ersten Variation müssen wird die versteckte Konstante in der ersten Zeile suchen, bei der zweiten Variation müssen wir sie aus der zweiten Zeile lesen. Beim Auslesen der versteckten Nachricht müssen wir also die erste Zeile überspringen, wenn das Schlüssel-Byte ungerade ist:

private bool ProcessMethodExtract(String[] lines, ref int indexLines,
                                                                        Stream message, Stream key){
        //[bytesPerMethod] Bytes in den Nachrichten-Stream lesen
        //wenn [bytesPerMethod]==0 ist, wurde es noch nicht gelesen
        for(int n=0; (n<bytesPerMethod)||(bytesPerMethod==0); n+=4){

        if(bytesPerMethod > 0){
                //nächstes Bytes aus dem Schlüssel lesen
                if( (keyValue=key.ReadByte()) < 0){
                        key.Seek(0, SeekOrigin.Begin);
                        keyValue=key.ReadByte();
                }

                if(keyValue % 2 == 1){
                        //ldc.i4 steht in der zweiten Zeile des versteckten Blocks
                        indexLines++;
                }
        }

        //ILDAsm setzt Zeilennummern - Anfange der Anweisung finden
        indexValue = lines[indexLines].IndexOf("ldc.i4");
        if(indexValue >= 0){

        //...

Jetzt können wir Daten verstecken und extrahieren, aber viele re-compilierte Assemblies stürzen mit einer InvalidProgramException ab. Das kommt daher, dass die zweite Variation zwei Werte auf den Stack legt:

        .maxstack 1 //eine kleine Methode verwendet nur eine Variable auf einmal
        ...
        ldstr      "DEBUG - current value is: {0}"
        ldc.i4     0x6f //wir versuchen, einen zweiten Wert auf den Stack zu laden
        box        [mscorlib]System.Int32
        call       void [mscorlib]System.Console::WriteLine(string,
                                                        object)

Deshalb müssen wir sicherstellen, dass der .maxstack-Wert in jeder Methode mindestens 2 ist. Die .maxstack-Zeile ist eine von denen, die wir bisher unbeachtet kopiert haben:

        CopyBlock(lines, startIndex, endIndex);

        //...

        private void CopyBlock(String[] lines, int start, int end){
                String[] buffer = new String[end-start];
                Array.Copy(lines, start, buffer, 0, buffer.Length);
                writer.WriteLine(String.Join(writer.NewLine, buffer));
        }

Jetzt müssen wir die .maxstack-Zeilen finden und anpassen, sonst würden wir Assemblies zerstören, die Methoden mit einem maxstack von 1 enthalten. Diese Zeilen können wir nicht mit Array.IndexOf(".maxstack 1") finden, weil die exakte Zeile unbekannt ist - denk nur mal an die Zeilennummern, Tabs und Leerzeichen, die ILDAsm in jede Zeile einfügt. Also werden wir die Methoden Zeile für Zeile kopieren:

private void CopyBlockAdjustStack(String[] lines, int start, int end){
        for(int n=start; n<end; n++){

                if(lines[n].IndexOf(".maxstack ")>0){
                        //Stack-Größe lesen
                        int indexStart = lines[n].IndexOf(".maxstack ");
                        int maxStack = int.Parse( lines[n].Substring(indexStart+10).Trim() );
                        //maxstack muss 2 oder größer sein
                        if(maxStack < 2){
                                lines[n] = ".maxstack 2";
                        }
                }

                writer.WriteLine(lines[n]);
        }
}

Rückgabewerte behandeln

Der Rückgabetyp einer Methode wird im Header deklariert, das heisst wir müssen ihn lesen und zwischenspeichern, sobald wir eine neue Methde betreten:

private String GetReturnType(String line){
        String returnType = null;
        if(line.IndexOf(" void ") > 0){ returnType = "void"; }
        else if(line.IndexOf(" bool ") > 0){ returnType = "bool"; }
        else if(line.IndexOf(" int32 ") > 0){ returnType = "int32"; }
        else if(line.IndexOf(" string ") > 0){ returnType = "string"; }
        return returnType;
}

private bool ProcessMethodHide(String[] lines, ref int indexLines,
                                                                Stream message, Stream key){

        //..

        //Rückgabewert der aktuellen Methode lesen
        String returnType = GetReturnType(lines[indexLines]);

        if(returnType != null){
                //void/bool/int32/string-Methode gefunden

                //...

                //Position des letzten ".locals init" und ersten "ret" suchen
                positionInitLocals = positionRet = 0;
                SeekLastLocalsInit(lines, ref indexLines,
                                                        ref positionInitLocals, ref positionRet);

                //...

                //Rest der Methode bis zur Zeile vor "ret" kopieren
                CopyBlockAdjustStack(lines, indexLines, positionRet);

                //nächste Zeile ist "ret" - auf dem Stack kann nichts kaputt gehen
                indexLines = positionRet;

                if(returnType != "void"){
                        //not a void method - store the return value
                        writer.Write(writer.NewLine);
                        writer.WriteLine(".locals init ("+returnType+" returnvalue)");
                        writer.WriteLine("stloc returnvalue");
                }

                //Zeile für [bytesPerMethod] Bytes vom Nachrichten-Stream einfügen
                //4 Bytes zu einem Int32 kombinieren
                int keyValue;
                for(int n=0; n<bytesPerMethod; n+=4){
                        //...
                }
                //...

                if(returnType != "void"){
                        //keine void-Methode - Rückgabewert zurück auf den Stack laden
                        writer.WriteLine("ldloc returnvalue");
                }

                //...

        } //else diese Methode auslassen
}

Wir müssen die Zeile ldloc returnvalue beim Extrahieren nur auslassen.

private bool ProcessMethodExtract(String[] lines, ref int indexLines,
                                                                        Stream message, Stream key){
        bool isMessageComplete = false;
        int positionRet,                                //index der "ret"-Zeile
                positionStartOfMethodLine;        //index der ersten Zeile

        String returnType = GetReturnType(lines[indexLines]);
        int keyValue = 0;

        if(returnType != null){
                //void/bool/int32/string-gethode gefunden
                //ein Teil der Nachricht ist hier versteckt

                //...

                //Position des "ret" suchen
                positionRet = SeekRet(lines, ref indexLines);

                if(bytesPerMethod == 0){
                        //zwei Zeilen zurück gehen - dort haben wir "ldc.i4 "+bytesPerMethod eingefügt
                        indexLines = positionRet - 2;
                }else{
                        //[linesPerMethod] Zeilen pro erwartetem Nachrichten-Byte zurück gehen
                        //dort haben wir "ldc.i4 "+currentByte eingefügt
                        linesPerMethod = GetLinesPerMethod(key);
                        indexLines = positionRet - linesPerMethod;
                }

                if(returnType != "void"){
                        indexLines--; //die Zeile "ldloc returnvalue" überspringen
                }

                //...
        }
}

Jetzt können wir eine Schlüssel-Datei anwenden, und die meisten Methoden ausnutzen. Wenn Du mehr Methoden verwenden möchtest, brauchst Du nur die Methode GetReturnType anzupassen. Mehr Variationen von Dummy-Code einzufügen ist etwas aufwändiger, Du musst ProcessMethodHide, ProcessMethodExtract und GetLinesPerMethod anpassen - und denke daran, gegebenenfalls den .maxstack zu vergrößern.

Follow me on Mastodon