Im letzten Artikel wurden nur
void-Methoden verwendet, was die Länge der versteckten Nachricht stark eingeschränkt hat.
Dieser Artikel erweitert das Programm:
void, bool, int32 oder string können verwendet werden.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!
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]);
}
}
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.