Assembler-Einfügungen ... in C #?

Diese Geschichte begann also mit einem Zusammentreffen von drei Faktoren. Ich:

  1. meistens in C # geschrieben;
  2. nur grob vorgestellt, wie es angeordnet ist und funktioniert;
  3. interessierte sich für Assembler.

Diese scheinbar unschuldige Mischung brachte eine seltsame Idee hervor: Ist es möglich, diese Sprachen irgendwie zu kombinieren? Fügen Sie in C # die Möglichkeit hinzu, Assembler-Einfügungen durchzuführen, ähnlich wie in C ++.

Wenn Sie daran interessiert sind, zu welchen Konsequenzen dies geführt hat, begrüßen Sie bitte bei cat.



Erste Schwierigkeiten


Selbst in diesem Moment wurde mir klar, dass es sehr unwahrscheinlich ist, dass es Standardtools zum Aufrufen von Assembler-Code aus C # -Code gibt - dies widerspricht zu sehr einem der wichtigen Konzepte der Sprache: der Speichersicherheit. Nach einer oberflächlichen Untersuchung des Problems (die unter anderem die anfängliche Vermutung bestätigte - „out of the box“ gibt es keine solche Möglichkeit) wurde klar, dass es neben dem ideologischen Problem ein rein technisches Problem gibt: C # wird, wie Sie wissen, zu einem Zwischenbytecode zusammengestellt, der weiter interpretiert von der virtuellen CLR-Maschine. Und genau hier stehen wir vor dem eigentlichen Problem: Einerseits kann der Compiler (im Folgenden meine ich Roslyn von Microsoft, da er de facto der Standard auf dem Gebiet der C # -Compiler ist) offensichtlich nicht erkennen und Assembler-Befehle aus einer Textansicht in eine binäre Darstellung übersetzen, was bedeutet, dass wir Maschinenbefehle direkt in ihrer binären Form als Einfügung verwenden müssen. Andererseits hat die virtuelle Maschine ihren eigenen Bytecode und kann diesen nicht erkennen und ausführen Gebündelte Befehle, die wir ihr bieten.

Die theoretische Lösung für dieses Problem liegt auf der Hand: Sie müssen sicherstellen, dass der binäre Einfügecode vom Prozessor ausgeführt wird, wobei die Interpretation der virtuellen Maschine umgangen wird. Am einfachsten ist es, den Binärcode als Array von Bytes zu speichern, auf die die Steuerung zum richtigen Zeitpunkt übertragen wird. Von hier aus taucht die erste Aufgabe auf: Sie müssen einen Weg finden, um die Kontrolle auf das zu übertragen, was in einem beliebigen Speicherbereich enthalten ist.

Erster Prototyp: Ein Array „aufrufen“


Diese Aufgabe ist vielleicht das schwerwiegendste Hindernis für Einsätze. Es ist einfach, ein Sprachwerkzeug zu verwenden, um einen Zeiger auf unser Array zu erhalten, aber in der C # -Welt existieren Zeiger nur für Daten, und es ist unmöglich, daraus einen Zeiger auf beispielsweise eine Funktion zu machen, damit Sie sie später aufrufen können (na ja, oder zumindest konnte ich es nicht herausfinden zu tun).

Glücklicherweise (oder leider) ist nichts unter dem Mond neu und eine schnelle Suche in Yandex nach den Wörtern "C #" und "Assembler Inserts" führte mich zu einem Artikel in der Dezember 2007 Ausgabe des Magazins]] [Aker] . Nachdem ich die Funktion von dort ehrlich kopiert und an meine Bedürfnisse angepasst hatte, bekam ich

[DllImport("kernel32.dll")] extern bool VirtualProtect(int* lpAddress, uint dwSize, uint flNewProtect, uint* lpflOldProtect); public void* InvokeAsm(void* firstAsmArg, void* secondAsmArg, byte[] code) { int i = 0; int* p = &i; p += 0x14 / 4 + 1; i = *p; fixed (byte* b = code) { *p = (int)b; uint prev; VirtualProtect((int*)b, (uint)code.Length, 0x40, &prev); } return (void*)i; } 

Die Hauptidee dieses Codes besteht darin, die Rücksprungadresse der Funktion InvokeAsm() auf dem Stapel durch die Adresse des Byte-Arrays zu ersetzen, an das Sie die Steuerung übertragen möchten. Nach dem Beenden der Funktion beginnt die Ausführung unseres Binärcodes, anstatt die Ausführung des Programms fortzusetzen.

Wir werden uns detaillierter mit der Magie befassen, die in InvokeAsm() . Zuerst deklarieren wir eine lokale Variable, die natürlich auf dem Stapel erscheint, dann erhalten wir ihre Adresse (wodurch wir die Adresse der Oberseite des Stapels erhalten). Als nächstes fügen wir eine bestimmte magische Konstante hinzu, die durch sorgfältige Berechnung des Versatzes der Rücksprungadresse relativ zum oberen Rand des Stapels im Debugger erhalten wird, speichern die Rücksprungadresse und schreiben stattdessen die Adresse unseres Byte-Arrays. Die heilige Bedeutung des Speicherns der Absenderadresse liegt auf der Hand - wir müssen das Programm nach dem Einfügen weiter ausführen, was bedeutet, dass wir wissen müssen, wohin die Kontrolle danach übertragen werden soll. Als nächstes kommt der Aufruf der WinAPI-Funktion aus der kernel32.dll-Bibliothek - VirtualProtect() . Es wird benötigt, um die Attribute der Speicherseite zu ändern, auf der sich der Einfügecode befindet. Wenn Sie das Programm kompilieren, wird es natürlich im Datenbereich angezeigt, und die entsprechende Speicherseite hat Lese- und Schreibzugriff. Wir müssen auch die Berechtigung hinzufügen, um den Inhalt auszuführen. Schließlich geben wir die gespeicherte reale Rücksendeadresse zurück. Natürlich wird diese Adresse nicht an den Code zurückgegeben, der InvokeAsm() aufgerufen InvokeAsm() , weil Ausführung unmittelbar nach der return (void*)i; "Fail" in der Beilage. Die von der virtuellen Maschine verwendeten Aufrufkonventionen (stdcall mit deaktivierter Optimierung und fastcall mit aktivierter Option) bedeuten jedoch, dass der Wert über das EAX-Register zurückgegeben wird, d. H. Um von der Einfügung zurückzukehren, müssen wir zwei Anweisungen befolgen: Drücken Sie push eax (Code 0x50) und ret (Code 0xC3).

Klarstellung
In Zukunft werden wir über die Architektur von x86 (oder besser IA-32) sprechen - kitschig, weil ich zu dieser Zeit zumindest irgendwie damit vertraut war, im Gegensatz zu beispielsweise x86-64. Die oben beschriebene Steuerungsübertragungsmethode sollte jedoch für 64-Bit-Code funktionieren.

Schließlich sollten Sie zwei nicht verwendete Argumente void* firstAsmArg : void* firstAsmArg und void* secondAsmArg . Sie werden zum Übertragen beliebiger Benutzerdaten an die Assembler-Einfügung benötigt. Diese Argumente befinden sich entweder an einer bekannten Stelle auf dem Stapel (stdcall) oder wiederum in bekannten Registern (fastcall).

Ein bisschen über Optimierung
Da der Code aus Sicht des Compilers nicht versteht, was er ist, kann er versehentlich einen grundlegend wichtigen Aufruf / Inline-Vorgang auslösen / ein "unbenutztes" Argument nicht speichern / die Implementierung unseres Plans irgendwie stören. Dies wird teilweise durch das [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] gelöst, aber selbst solche Vorsichtsmaßnahmen haben nicht den gewünschten Effekt: Beispielsweise stellt sich plötzlich heraus, dass die lokale Variable i , die für die gesamte Funktion von entscheidender Bedeutung ist, ein Register ist, das offensichtlich alles verdirbt . Um die Wahrscheinlichkeit eines Fehlers vollständig auszuschließen, sollten Sie eine Bibliothek mit deaktivierter Optimierung erstellen (entweder in den Projekteigenschaften deaktivieren oder die Debug-Konfiguration verwenden). Infolgedessen wird stdcall verwendet, daher werde ich in Zukunft von dieser Aufrufkonvention ausgehen.

Verbesserungen


Sicher ist besser als unsicher


Natürlich gibt es keine Frage der Sicherheit (in dem Sinne, in dem dieses Wort in C # verwendet wird). Die oben beschriebene InvokeAsm() -Methode arbeitet jedoch mit Zeigern. Dies bedeutet, dass sie nur aus dem Block aufgerufen werden kann, der mit dem unsafe Schlüsselwort gekennzeichnet ist. Dies ist nicht immer praktisch - zumindest muss sie mit dem Schalter / unsafe (oder dem entsprechenden Häkchen in den Projekteigenschaften in VS) kompiliert werden. Daher erscheint es logisch, eine Shell bereitzustellen, die mindestens IntPtr (im schlimmsten Fall) ausführt, und im Idealfall kann der Benutzer die zu übertragenden und zurückzugebenden Typen angeben. Nun, das klingt nach generisch, wir schreiben generisch, worüber gibt es sonst noch etwas, worüber man sprechen kann? In der Tat - da ist etwas.

Das offensichtlichste: Wie bekomme ich einen Zeiger auf ein Argument, dessen Typ unbekannt ist? Konstruktionen vom Typ T* ptr = &arg in C # nicht zulässig, und im Allgemeinen ist der Grund nicht schwer zu verstehen: Der Benutzer kann durchaus einen der verwalteten Typen als Typparameter verwenden, auf den kein Zeiger abgerufen werden kann. Die Lösung könnte darin bestehen, einen Parameter vom Typ nicht unmanaged einzuschränken. Erstens wurde er nur in C # 7.3 angezeigt, und zweitens können keine Zeichenfolgen und Arrays als Argumente übergeben werden, obwohl der fixed Operator die Verwendung dieser Parameter zulässt (wir erhalten den Zeiger auf den ersten Zeichen bzw. Array-Element). Nun, außerdem möchte ich dem Benutzer die Möglichkeit geben, einschließlich kontrollierter Typen zu arbeiten - da wir angefangen haben, die Regeln der Sprache zu verletzen, werden wir sie bis zum Ende verletzen!

Abrufen eines Zeigers auf ein verwaltetes Objekt und eines Objekts per Zeiger


Und wieder begann ich nach nicht sehr fruchtbaren Überlegungen, nach den endgültigen Lösungen zu suchen. Diesmal hat mir der Artikel über Habré geholfen. Kurz gesagt, eine der darin vorgeschlagenen Methoden besteht darin, eine Hilfsbibliothek zu schreiben, und zwar nicht in C #, sondern direkt in IL. Seine Aufgabe besteht darin, ein Objekt (tatsächlich eine Referenz auf das Objekt) auf den Stapel der virtuellen Maschine zu verschieben, das als Argument übergeben wird, und dann etwas anderes vom Stapel abzurufen - beispielsweise eine Zahl oder IntPtr . Wenn Sie dieselben Schritte in umgekehrter Reihenfolge ausführen, können Sie den Zeiger (z. B. vom Assembler-Insert zurückgegeben) in ein Objekt konvertieren. Diese Methode ist gut, weil alles, was passiert, klar und transparent ist. Aber es gibt ein Minus: Ich wollte mit so wenig Dateien wie möglich auskommen. Anstatt eine separate Bibliothek zu schreiben, habe ich beschlossen, den IL-Code in die Hauptbibliothek einzubetten. Die einzige Möglichkeit, die ich gefunden habe, besteht darin, Stub-Methoden in C # zu schreiben, ein Projekt zu erstellen, die Binärdatei mit ildasm zu zerlegen, den Code für die Stub-Methoden neu zu schreiben und alles wieder mit ilasm zusammenzusetzen. Dies sind einige zusätzliche Aktionen, und da Sie sie jedes Mal ausführen müssen, wenn Sie sie erstellen, nachdem Sie Änderungen am Code vorgenommen haben ... Im Allgemeinen hatte ich es ziemlich schnell satt und suchte nach Alternativen.

Gerade zu dieser Zeit fiel mir ein wundervolles Buch in die Hände, dank dem ich viel für mich selbst gelernt habe - „CLR via C #“ von Jeffrey Richter. Darin haben wir irgendwo im zwanzigsten Kapitel über die GCHandle Struktur gesprochen, die eine Alloc() -Methode enthält, die ein Objekt und eines der GCHandleType Aufzählungselemente GCHandleType . Wenn Sie diese Methode aufrufen und das gewünschte Objekt und GCHandle.Pinned , können Sie die Adresse dieses Objekts im Speicher GCHandle.Pinned . Darüber hinaus wird vor dem Aufrufen von GCHandle.Free() Objekt festgelegt, d.h. vollständig vor den Auswirkungen des Müllsammlers geschützt. Es gibt jedoch bestimmte Probleme. Erstens GCHandle in keiner Weise, die Konvertierung „Zeiger → Objekt“ GCHandle , GCHandle nur „Objekt → Zeiger“. Noch wichtiger ist, dass zur Verwendung von GCHandleType.Pinned Klasse oder Struktur des Objekts, dessen Adresse wir erhalten möchten, das Attribut [StructLayout(LayoutKind.Sequential)] haben muss, während LayoutKind.Auto verwendet wird. Diese Methode eignet sich daher nur für einige Standardtypen und für benutzerdefinierte Typen, die ursprünglich unter diesem Gesichtspunkt entwickelt wurden. Nicht genau die universelle Methode, die wir gerne finden würden, oder?

Versuchen Sie es erneut. Achten wir nun auf zwei undokumentierte Funktionen, die jedoch von Roslyn unterstützt werden: __makeref() und __refvalue() . Der erste nimmt ein Objekt und gibt eine Instanz der TypedReference Struktur zurück, in der ein Verweis auf das Objekt und seinen Typ typedReference , während der zweite das Objekt aus der übertragenen typedReference Instanz typedReference . Warum sind uns diese Funktionen wichtig? Weil TypedReference eine Struktur ist! Im Kontext der Diskussion bedeutet dies, dass wir einen Zeiger darauf erhalten können, der in Kombination ein Zeiger auf das erste Feld dieser Struktur sein wird. Es speichert nämlich genau die Verknüpfung zu dem Objekt, das uns interessiert. Um dann einen Zeiger auf ein verwaltetes Objekt zu erhalten, müssen wir den Wert durch einen Zeiger auf das __makeref() , was __makeref() und ihn in einen Zeiger konvertieren. Um ein Objekt per Zeiger __makeref() , müssen Sie __makeref() von einem bedingt leeren Objekt des erforderlichen Typs TypedReference , einen Zeiger auf die zurückgegebene TypedReference Instanz TypedReference , einen Zeiger auf das Objekt darauf schreiben und dann __refvalue() aufrufen. Das Ergebnis ist ungefähr so ​​wie dieser Code:

 public static Tout ToInstance<Tout>(IntPtr ptr) { Tout temp = default; TypedReference tr = __makeref(temp); Marshal.WriteIntPtr(*(IntPtr*)(&tr), ptr); Tout instance = __refvalue(tr, Tout); return instance; } public static void* ToPointer<T>(ref T obj) { if (typeof(T).IsValueType) { return *(void**)&tr; } else { return **(void***)&tr; } } 

Bemerkung
Zurück zur Aufgabe, einen sicheren Wrapper für InvokeAsm() zu InvokeAsm() , sollte beachtet werden, dass die Methode zum InvokeAsm() von Zeigern mit __makeref() und __refvalue() im Gegensatz zu GCHandle.Alloc(GCHandleType.Pinned) nicht garantiert, dass sich unser Garbage Collector nirgendwo befindet Das Objekt bewegt sich nicht. Daher sollte der Wrapper zunächst den Garbage Collector ausschalten und mit der Wiederherstellung seiner Funktionalität enden. Die Lösung ist eher unhöflich, aber effektiv.

Für diejenigen, die sich nicht an Opcodes erinnern


Wir haben also gelernt, wie man Binärcode aufruft und nicht nur unmittelbare Werte, sondern auch Zeiger auf alles als Argumente übergibt ... Es gibt nur ein Problem. Woher bekomme ich den gleichen Binärcode? Sie können sich mit einem Bleistift, einem Notizblock und einer Opcode-Tabelle (z. B. diesem ) ausrüsten oder einen Hex-Editor mit x86-Assembler-Unterstützung oder sogar einen vollwertigen Übersetzer verwenden. All diese Optionen bedeuten jedoch, dass der Benutzer etwas anderes als die Bibliothek verwenden muss. Dies ist nicht ganz das, was ich wollte, deshalb habe ich beschlossen, meinen Übersetzer in die Bibliothek aufzunehmen, die traditionell SASM genannt wurde (kurz für Stack Assembler; es hat nichts mit der IDE zu tun).

Haftungsausschluss
Ich bin nicht gut darin, Zeichenfolgen zu analysieren, also der Übersetzercode ... na ja, gelinde gesagt, unvollkommen. Außerdem bin ich nicht stark in regulären Ausdrücken, so dass sie nicht da sind. Und im Allgemeinen - ein iterativer Parser.

Ich werde wahrscheinlich nicht über den Entstehungsprozess dieses "Wunders" sprechen - diese Geschichte enthält nichts Interessantes, aber ich werde kurz die Hauptmerkmale beschreiben. Die meisten x86-Anweisungen werden derzeit unterstützt. Mathematische Coprozessoranweisungen zum Arbeiten mit Gleitkommazahlen und von Erweiterungen (MMX, SSE, AVX) werden noch nicht unterstützt. Es ist möglich, Konstanten, Prozeduren, lokale Stapelvariablen und globale Variablen zu deklarieren, deren Speicher während der Übersetzung direkt in einem Array mit Binärcode zugewiesen wird (wenn diese Variablen mit Beschriftungen benannt werden, kann ihr Wert auch nach dem Einfügen durch Aufrufen von Methoden aus C # abgerufen werden GetBYTEVariable() , GetWORDVariable() , GetDWORDVariable() , GetAStringVariable() und GetWStringVariable() des SASMCode Objekts), addr und addr sind vorhanden. Eine der wichtigen Funktionen ist die Unterstützung für den Import von Funktionen aus externen Bibliotheken mithilfe des Konstrukts extern < > lib < > .

asmret Makro verdient einen separaten Absatz. Während der Übersetzung entfaltet es sich in 11 Anweisungen, die den Epilog bilden. Der Prolog wird standardmäßig am Anfang des übersetzten Codes hinzugefügt. Ihre Aufgabe ist es, den Status des Prozessors zu speichern / wiederherzustellen. Zusätzlich fügt der Prolog vier Konstanten hinzu - $first , $second , $this und $return . Während der Übersetzung werden diese Konstanten durch Adressen auf dem Stapel ersetzt, bei denen jeweils das erste und das zweite Argument an die Assembler-Einfügung übergeben werden, die Adresse des ersten Einfügebefehls und die Rücksprungadresse.

Zusammenfassung


Der Code sagt viel mehr als Worte, und es wäre seltsam, das Ergebnis einer ziemlich langen Arbeit nicht zu teilen. Deshalb lade ich alle, die mich interessieren, zu GitHub ein .

Wenn ich jedoch versuche, alles, was getan wurde, irgendwie zu verallgemeinern, dann hat sich meiner Meinung nach ein interessantes und teilweise sogar nicht nutzloses Projekt herausgestellt. Beispielsweise unterscheiden sich identische Algorithmen zum Sortieren von Einfügungen in C # und zum Verwenden von Assembler-Einfügungen in der Geschwindigkeit um mehr als das Zweifache (natürlich zugunsten von Assembler). In ernsthaften Projekten wird natürlich nicht empfohlen, die resultierende Bibliothek zu verwenden (unvorhersehbare Nebenwirkungen sind möglich, aber nicht sehr wahrscheinlich), aber es ist für Sie durchaus möglich.

Source: https://habr.com/ru/post/de464931/


All Articles