Assembleur insère ... en C #?

Donc, cette histoire a commencé avec une coïncidence de trois facteurs. Je:

  1. écrit principalement en C #;
  2. seulement imaginé grossièrement comment il est organisé et fonctionne;
  3. s'est intéressé à l'assembleur.

Ce mélange apparemment innocent a donné lieu à une idée étrange: est-il possible de combiner d'une manière ou d'une autre ces langues? Ajoutez en C # la possibilité de faire des insertions d'assembleur, un peu comme en C ++.

Si vous êtes intéressé par les conséquences que cela a entraîné, bienvenue chez cat.



Premières difficultés


Même à ce moment-là, j'ai réalisé qu'il est très peu probable qu'il existe des outils standard pour appeler du code assembleur à partir de code C # - cela contredit trop l'un des concepts importants du langage: la sécurité de la mémoire. Après une étude superficielle de la question (qui, entre autres, a confirmé le pressentiment initial - «hors de la boîte», il n'y a pas une telle possibilité), il est devenu clair qu'en plus du problème idéologique, il y a un problème purement technique: C #, comme vous le savez, est compilé dans un bytecode intermédiaire, qui interprété par la machine virtuelle CLR. Et c'est précisément ici que nous sommes confrontés au problème même: d'une part, le compilateur (ci-après je parlerai de Roslyn de Microsoft, car il est de facto la norme dans le domaine des compilateurs C #), évidemment, ne peut pas reconnaître et traduire les commandes assembleur d'une vue texte en une représentation binaire, ce qui signifie que nous devons utiliser les instructions de la machine directement sous leur forme binaire en tant qu'insert, et d'autre part, la machine virtuelle a son propre bytecode et ne peut pas reconnaître et exécuter cela commandes groupées que nous lui offrir.

La solution théorique à ce problème est évidente - vous devez vous assurer que le code d'insertion binaire est exécuté par le processeur, en contournant l'interprétation de la machine virtuelle. La chose la plus simple qui me vient à l'esprit est de stocker le code binaire sous la forme d'un tableau d'octets, vers lequel le contrôle sera en quelque sorte transféré au bon moment. De là se profile la première tâche: vous devez trouver un moyen de transférer le contrôle à ce qui est contenu dans une zone de mémoire arbitraire.

Premier prototype: «appeler» un tableau


Cette tâche est peut-être l'obstacle le plus sérieux aux encarts. En utilisant les outils de langage, il est facile d'obtenir un pointeur vers notre tableau, mais dans le monde C #, les pointeurs n'existent que sur les données et il est impossible de le transformer en un pointeur vers, disons, une fonction pour qu'elle puisse être appelée plus tard (enfin, ou du moins je ne pouvais pas comprendre comment à faire).

Heureusement (ou malheureusement), rien n'est nouveau sous la lune et une recherche rapide dans Yandex pour les mots "C #" et "assembleur inserts" m'a conduit à un article dans le numéro de décembre 2007 du magazine]] [Aker] . Ayant honnêtement copié la fonction à partir de là et l’ayant adaptée à mes besoins, j’ai

[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; } 

L'idée principale de ce code est de remplacer l'adresse de retour de la fonction InvokeAsm() sur la pile par l'adresse du tableau d'octets vers lequel vous souhaitez transférer le contrôle. Ensuite, après avoir quitté la fonction, au lieu de continuer l'exécution du programme, l'exécution de notre code binaire commencera.

Nous traiterons plus en détail de la magie qui InvokeAsm() dans InvokeAsm() . Tout d'abord, nous déclarons une variable locale, qui, bien sûr, apparaît sur la pile, puis nous obtenons son adresse (obtenant ainsi l'adresse du haut de la pile). Ensuite, nous y ajoutons une certaine constante magique obtenue en calculant minutieusement dans le débogueur le décalage de l'adresse de retour par rapport au haut de la pile, sauvegardons l'adresse de retour et écrivez plutôt l'adresse de notre tableau d'octets. La signification sacrée de l'enregistrement de l'adresse de retour est évidente - nous devons continuer à exécuter le programme après notre insertion, ce qui signifie que nous devons savoir où transférer le contrôle après. Vient ensuite l'appel à la fonction WinAPI à partir de la bibliothèque kernel32.dll - VirtualProtect() . Il est nécessaire pour changer les attributs de la page mémoire sur laquelle se trouve le code d'insertion. Bien sûr, lors de la compilation du programme, il apparaît dans la section des données et la page mémoire correspondante a un accès en lecture et en écriture. Nous devons également ajouter la permission d'exécuter son contenu. Enfin, nous renvoyons l'adresse de retour réelle stockée. Bien sûr, cette adresse ne sera pas retournée au code qui a appelé InvokeAsm() , car exécution immédiatement après le return (void*)i; "Échec" dans l'encart. Cependant, les conventions d'appel utilisées par la machine virtuelle (stdcall avec optimisation désactivée et fastcall avec activé) signifient renvoyer la valeur via le registre EAX, c'est-à-dire pour revenir de l'insert, nous devons suivre deux instructions: push eax (code 0x50) et ret (code 0xC3).

Clarification
À l'avenir, nous parlerons de l'architecture de x86 (ou plutôt de IA-32) - ringard du fait qu'à cette époque, je le connaissais au moins d'une manière ou d'une autre, contrairement, disons, à x86-64. Cependant, la méthode de transfert de contrôle décrite ci-dessus devrait fonctionner pour le code 64 bits.

Enfin, vous devez faire attention à deux arguments inutilisés: void* firstAsmArg et void* secondAsmArg . Ils sont nécessaires pour transférer des données utilisateur arbitraires vers l'insert d'assembleur. Ces arguments seront situés soit à un endroit connu de la pile (stdcall), soit, là encore, dans des registres bien connus (fastcall).

Un peu d'optimisation
Puisque, du point de vue du compilateur, ce qui se passe dans le code, ne comprend pas quoi, il peut par inadvertance lancer un appel fondamentalement important / ajouter quelque chose en ligne / ne pas sauvegarder un argument "inutilisé" / interférer d'une manière ou d'une autre avec la mise en œuvre de notre plan. Ceci est partiellement résolu par l' [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] , cependant, même de telles précautions ne donnent pas l'effet souhaité: par exemple, la variable locale i , qui est la clé de toute la fonction, se révèle soudainement être un registre, ce qui gâche évidemment tout . Par conséquent, afin d'éliminer complètement la probabilité que quelque chose se passe mal, vous devez créer une bibliothèque avec l'optimisation désactivée (désactivez-la dans les propriétés du projet ou utilisez la configuration de débogage). Par conséquent, stdcall sera utilisé, donc à l'avenir je procéderai de cette convention d'appel.

Améliorations


La sécurité vaut mieux que l'insécurité


Bien sûr, il n'est pas question de sécurité (au sens où ce mot est utilisé en C #). Cependant, la méthode InvokeAsm() décrite ci-dessus fonctionne sur des pointeurs, ce qui signifie qu'elle ne peut être appelée qu'à partir du bloc marqué avec le mot-clé unsafe , ce qui n'est pas toujours pratique - au moins elle nécessite une compilation avec le commutateur / unsafe (ou la coche correspondante dans les propriétés du projet dans VS). Par conséquent, il semble logique de fournir un shell qui fonctionne au moins IntPtr (au pire), et idéalement, il permet à l'utilisateur de spécifier les types à transmettre et à renvoyer. Eh bien, ça sonne comme générique, on écrit générique, qu'est-ce qu'il y a d'autre, on demande, de parler? En fait - il y a quelque chose.

Le plus évident: comment obtenir un pointeur sur un argument dont le type est inconnu? Les constructions de type T* ptr = &arg ne T* ptr = &arg pas autorisées en C # et, en général, il n'est pas difficile de comprendre la raison: l'utilisateur peut bien utiliser l'un des types gérés comme paramètre de type, un pointeur vers lequel ne peut pas être obtenu. La solution pourrait être de limiter un paramètre de type unmanaged , mais, premièrement, il n'apparaît qu'en C # 7.3, et deuxièmement, il ne permet pas de passer des chaînes et des tableaux comme arguments, bien que l'opérateur fixed permette de les utiliser (nous obtenons le pointeur vers le premier caractère ou élément de tableau, respectivement). Eh bien, en plus, je voudrais donner à l'utilisateur la possibilité d'opérer, y compris les types contrôlés - puisque nous avons commencé à violer les règles de la langue, nous les violerons jusqu'à la fin!

Obtention d'un pointeur vers un objet géré et un objet par pointeur


Et encore une fois, après des délibérations peu fructueuses, j'ai commencé à chercher les solutions finales. Cette fois, l' article sur Habré m'a aidé. En bref, l'une des méthodes proposées consiste à écrire une bibliothèque auxiliaire, et non pas en C #, mais directement en IL. Sa tâche consiste à pousser un objet (en fait une référence à l'objet) sur la pile de la machine virtuelle, passé en argument, puis à récupérer quelque chose d'autre dans la pile - par exemple, un nombre ou IntPtr . En effectuant les mêmes étapes dans l'ordre inverse, vous pouvez convertir le pointeur (par exemple, renvoyé par l'insert d'assembleur) en objet. Cette méthode est bonne car tout ce qui se passe est clair et transparent. Mais il y a un inconvénient: je voulais m'en sortir avec le moins de fichiers possible, donc au lieu d'écrire une bibliothèque séparée, j'ai décidé d'incorporer le code IL dans la principale. La seule façon que j'ai trouvée est d'écrire des méthodes de stub en C #, de construire le projet, de désassembler le binaire en utilisant ildasm, de réécrire le code des méthodes de stub et de tout remettre ensemble avec ilasm. Ce sont quelques actions supplémentaires, et étant donné que vous devez les faire à chaque fois que vous les construisez après avoir apporté des modifications au code ... En général, je m'en suis lassé assez rapidement et j'ai commencé à chercher des alternatives.

À ce moment-là, un merveilleux livre est tombé entre mes mains, grâce auquel j'ai beaucoup appris par moi-même - «CLR via C #» de Jeffrey Richter. Dans ce document, quelque part autour du vingtième chapitre, nous avons parlé de la structure GCHandle , qui a une méthode Alloc() qui prend un objet et l'un des GCHandleType énumération GCHandleType . Donc, si vous appelez cette méthode en lui passant l'objet souhaité et GCHandle.Pinned , vous pouvez obtenir l'adresse de cet objet en mémoire. De plus, avant d'appeler GCHandle.Free() objet est fixe, c'est-à-dire entièrement protégé contre les effets du ramasse-miettes. Cependant, il y a certains problèmes. Tout d'abord, GCHandle n'aide en aucune façon à terminer la conversion «pointeur → objet», seulement «objet → pointeur». Plus important encore, pour utiliser GCHandleType.Pinned classe ou la structure de l'objet dont nous voulons obtenir l'adresse doit avoir l' [StructLayout(LayoutKind.Sequential)] , tandis que LayoutKind.Auto utilisé par LayoutKind.Auto . Cette méthode ne convient donc que pour certains types standard et pour les types personnalisés qui ont été initialement conçus dans cet esprit. Pas exactement la méthode universelle que nous aimerions trouver, non?

Eh bien, essayez encore. __makeref() maintenant attention à deux fonctions non documentées, qui sont néanmoins prises en charge par Roslyn: __makeref() et __refvalue() . Le premier prend un objet et retourne une instance de la structure TypedReference qui stocke une référence à l'objet et à son type, tandis que le second extrait l'objet de l'instance typedReference transmise. Pourquoi ces fonctionnalités sont-elles importantes pour nous? Parce que TypedReference est une structure! Dans le contexte de la discussion, cela signifie que nous pouvons obtenir un pointeur vers celui-ci, qui, en combinaison, sera un pointeur vers le premier champ de cette structure. À savoir, il stocke le lien même avec l'objet qui nous intéresse. Ensuite, pour obtenir un pointeur sur un objet géré, nous devons lire la valeur par un pointeur sur ce que __makeref() renverra et le convertir en pointeur. Pour obtenir un objet par pointeur, vous devez appeler __makeref() partir d'un objet conditionnellement vide du type requis, obtenir un pointeur sur l'instance TypedReference retournée, écrire un pointeur sur l'objet dessus, puis appeler __refvalue() . Le résultat est quelque chose comme ce 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; } } 

Remarque
Revenant à la tâche d'écrire un wrapper sûr pour InvokeAsm() , il convient de noter que la méthode d'obtention de pointeurs à l'aide de __makeref() et __refvalue() , contrairement à l'utilisation de GCHandle.Alloc(GCHandleType.Pinned) , ne garantit pas que notre garbage collector est nulle part l'objet ne bougera pas. Par conséquent, l'encapsuleur doit commencer par désactiver le garbage collector et se terminer par la restauration de ses fonctionnalités. La solution est plutôt grossière, mais efficace.

Pour ceux qui ne se souviennent pas des opcodes


Nous avons donc appris à appeler du code binaire, appris à transmettre non seulement des valeurs immédiates, mais aussi des pointeurs vers n'importe quoi comme arguments ... Il n'y a qu'un seul problème. Où obtenir le même code binaire? Vous pouvez vous armer d'un crayon, d'un bloc-notes et d'une table d'opcode (par exemple, celui-ci ) ou prendre un éditeur hexadécimal avec le support de l'assembleur x86 ou même un traducteur à part entière, mais toutes ces options signifient que l'utilisateur devra utiliser autre chose que la bibliothèque. Ce n'était pas tout à fait ce que je voulais, j'ai donc décidé d'inclure mon traducteur dans la bibliothèque, qui était traditionnellement appelée SASM (abréviation de Stack Assembler; cela n'a rien à voir avec l' IDE ).

Clause de non-responsabilité
Je ne suis pas bon pour analyser les chaînes, donc le code du traducteur ... enfin, imparfait, c'est le moins qu'on puisse dire. De plus, je ne suis pas fort dans les expressions régulières, donc elles ne sont pas là. Et en général - un analyseur itératif.

Je ne vais probablement pas parler du processus de création de ce "miracle" - il n'y a rien d'intéressant dans cette histoire, mais je vais décrire brièvement les principales caractéristiques. La plupart des instructions x86 sont actuellement prises en charge. Les instructions mathématiques du coprocesseur pour travailler avec des nombres à virgule flottante et à partir d'extensions (MMX, SSE, AVX) ne sont pas encore prises en charge. Il est possible de déclarer des constantes, des procédures, des variables de pile locales, des variables globales, dont la mémoire est allouée lors de la traduction directement dans un tableau avec du code binaire (si ces variables sont nommées à l'aide d'étiquettes, leur valeur peut également être obtenue à partir de C # après avoir effectué l'insertion en appelant des méthodes GetBYTEVariable() , GetWORDVariable() , GetDWORDVariable() , GetAStringVariable() et GetWStringVariable() de l'objet SASMCode ), des macros addr et invoke sont présentes. L'une des fonctionnalités importantes est la prise en charge de l'importation de fonctions à partir de bibliothèques externes à l'aide de la construction extern < > lib < > .

asmret macro asmret mérite un paragraphe séparé. En cours de traduction, il se déroule en 11 instructions formant l'épilogue. Le prologue est ajouté par défaut au début du code traduit. Leur tâche consiste à sauvegarder / restaurer l'état du processeur. De plus, le prologue ajoute quatre constantes - $first , $second , $this et $return . Lors de la traduction, ces constantes sont remplacées par des adresses sur la pile, auxquelles correspondent respectivement les premier et deuxième arguments passés à l'insert d'assembleur, l'adresse de la première commande d'insertion et l'adresse de retour.

Résumé


Le code en dira beaucoup plus que des mots, et il serait étrange de ne pas partager le résultat d'un travail assez long, donc j'invite tous ceux qui m'intéressent à GitHub .

Si, néanmoins, j'essaie de généraliser d'une manière ou d'une autre tout ce qui a été fait, alors, à mon avis, un projet intéressant et même, dans une certaine mesure, pas inutile s'est avéré. Par exemple, des algorithmes identiques pour trier les insertions en C # et utiliser les insertions d'assembleur diffèrent en vitesse plus de deux fois (bien sûr, en faveur de l'assembleur). Dans les projets sérieux, bien sûr, il n'est pas recommandé d'utiliser la bibliothèque résultante (des effets secondaires imprévisibles sont possibles, mais peu probables), mais c'est tout à fait possible pour vous-même.

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


All Articles