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.
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.
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.
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 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;
}
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.
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 geschrieben werden, was allzu offensichtlich wäre.
Im nächsten Artikel werden alle Methoden (nicht nur voids) verwendet,
so dass ein Verteilungsschlüssel wieder Sinn macht.