Ensamblador inserta ... en C #?

Entonces, esta historia comenzó con una coincidencia de tres factores. Yo:

  1. principalmente escrito en C #;
  2. solo aproximadamente imaginé cómo está organizado y funciona;
  3. se interesó en ensamblador.

Esta mezcla aparentemente inocente dio lugar a una idea extraña: ¿es posible combinar de alguna manera estos idiomas? Agregue en C # la capacidad de hacer insertos de ensamblador, al igual que en C ++.

Si está interesado en las consecuencias que esto provocó, bienvenido a cat.



Primeras dificultades


Incluso en ese momento, me di cuenta de que es muy poco probable que existan herramientas estándar para llamar al código ensamblador desde el código C #; esto contradice demasiado uno de los conceptos importantes del lenguaje: la seguridad de la memoria. Después de un estudio superficial del problema (que, entre otras cosas, confirmó el presentimiento inicial: "fuera de la caja" no existe tal posibilidad), quedó claro que, además del problema ideológico, existe un problema puramente técnico: C #, como saben, se compila en un código de bytes intermedio, que interpretado adicionalmente por la máquina virtual CLR. Y es precisamente aquí donde nos enfrentamos con el mismo problema: por un lado, el compilador (en adelante me referiré a Roslyn de Microsoft, ya que es de hecho el estándar en el campo de los compiladores de C #), obviamente, no puede reconocer y traduce los comandos del ensamblador de una vista de texto a una representación binaria, lo que significa que debemos usar las instrucciones de la máquina directamente en su forma binaria como una inserción, y por otro lado, la máquina virtual tiene su propio código de bytes y no puede reconocer y ejecutar eso comandos de paquetes que su oferta.

La solución teórica a este problema es obvia: debe asegurarse de que el código de inserción binario sea ejecutado por el procesador, evitando la interpretación de la máquina virtual. Lo más simple que viene a la mente es almacenar el código binario como una matriz de bytes, a los cuales el control se transferirá de alguna manera en el momento adecuado. Desde aquí surge la primera tarea: debe encontrar una forma de transferir el control a lo que está contenido en un área de memoria arbitraria.

Primer prototipo: "llamar" a una matriz


Esta tarea es quizás el obstáculo más serio para las inserciones. Usando las herramientas de lenguaje, es fácil obtener un puntero a nuestra matriz, pero en el mundo C # los punteros solo existen en los datos y es imposible convertirlo en un puntero para, por ejemplo, una función para que pueda llamarse más tarde (bueno, o al menos no pude averiguar cómo hacer)

Afortunadamente (o desafortunadamente), nada es nuevo bajo la luna y una búsqueda rápida en Yandex de las palabras "C #" y "insertos de ensamblador" me llevó a un artículo en la edición de diciembre de 2007 de la revista]] [Aker] . Después de haber copiado honestamente la función desde allí y adaptarla a mis necesidades, obtuve

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

La idea principal de este código es reemplazar la dirección de retorno de la función InvokeAsm() en la pila con la dirección de la matriz de bytes a la que desea transferir el control. Luego, después de salir de la función, en lugar de continuar la ejecución del programa, comenzará la ejecución de nuestro código binario.

Trataremos la magia que InvokeAsm() en InvokeAsm() con más detalle. Primero, declaramos una variable local, que, por supuesto, aparece en la pila, luego obtenemos su dirección (obteniendo así la dirección de la parte superior de la pila). A continuación, le agregamos una cierta constante mágica obtenida calculando minuciosamente en el depurador el desplazamiento de la dirección de retorno en relación con la parte superior de la pila, guarda la dirección de retorno y escribe la dirección de nuestra matriz de bytes. El significado sagrado de guardar la dirección del remitente es obvio: necesitamos continuar ejecutando el programa después de nuestra inserción, lo que significa que necesitamos saber dónde transferir el control después de él. Luego viene la llamada a la función WinAPI desde la biblioteca kernel32.dll - VirtualProtect() . Es necesario para cambiar los atributos de la página de memoria en la que se encuentra el código de inserción. Por supuesto, al compilar el programa, aparece en la sección de datos y la página de memoria correspondiente tiene acceso de lectura y escritura. También necesitamos agregar permiso para ejecutar su contenido. Finalmente, devolvemos la dirección de retorno real almacenada. Por supuesto, esta dirección no se devolverá al código que llamó InvokeAsm() , porque ejecución inmediatamente después del return (void*)i; "Fail" en el inserto. Sin embargo, las convenciones de llamada utilizadas por la máquina virtual (llamada estándar con optimización deshabilitada y llamada rápida habilitada) significan devolver el valor a través del registro EAX, es decir Para volver de la inserción, debemos seguir dos instrucciones: push eax (código 0x50) y ret (código 0xC3).

Aclaracion
En el futuro, hablaremos sobre la arquitectura de x86 (o más bien, IA-32), cursi debido al hecho de que en ese momento estaba al menos familiarizado con ella, a diferencia de, por ejemplo, x86-64. Sin embargo, el método de transferencia de control descrito anteriormente debería funcionar para el código de 64 bits.

Finalmente, debe prestar atención a dos argumentos no utilizados: void* firstAsmArg y void* secondAsmArg . Son necesarios para transferir datos de usuario arbitrarios al inserto del ensamblador. Estos argumentos se ubicarán en un lugar conocido de la pila (stdcall) o, nuevamente, en registros conocidos (fastcall).

Un poco sobre optimización
Dado que, desde el punto de vista del compilador, lo que está sucediendo en el código, no entiendo qué, puede arrojar inadvertidamente alguna llamada fundamentalmente importante / agregar algo en línea / no guardar algún argumento "no utilizado" / interferir de alguna manera con la implementación de nuestro plan. Esto está parcialmente resuelto por el [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] , sin embargo, incluso esas precauciones no dan el efecto deseado: por ejemplo, la variable local i , que es clave para toda la función, de repente resulta ser un registro, que obviamente estropea todo . Por lo tanto, para eliminar por completo la probabilidad de que algo salga mal, debe crear una biblioteca con la optimización deshabilitada (deshabilítela en las propiedades del proyecto o use la configuración de Depuración). En consecuencia, se utilizará stdcall, por lo que en el futuro procederé de esta convención de llamadas.

Mejoras


Lo seguro es mejor que lo inseguro


Por supuesto, no se trata de ninguna seguridad (en el sentido con el que se usa esta palabra en C #). Sin embargo, el método InvokeAsm() descrito anteriormente funciona en punteros, lo que significa que solo se puede llamar desde el bloque marcado con la palabra clave unsafe , lo que no siempre es conveniente, al menos requiere compilación con el modificador / inseguro (o la marca de verificación correspondiente en las propiedades del proyecto en VS). Por lo tanto, parece lógico proporcionar un shell que opere al menos IntPtr (en el peor de los casos) e, idealmente, permite al usuario especificar por completo los tipos transferidos y devueltos. Bueno, eso suena como genérico, escribimos genérico, ¿qué más hay, uno pregunta, para hablar? De hecho, hay algo.

Lo más obvio: ¿cómo obtener un puntero a un argumento cuyo tipo es desconocido? Las construcciones del tipo T* ptr = &arg no T* ptr = &arg permitidas en C # y, en general, no es difícil entender la razón: el usuario puede usar uno de los tipos administrados como un parámetro de tipo, un puntero al que no se puede obtener. La solución podría ser limitar un parámetro de tipo unmanaged , pero, en primer lugar, apareció solo en C # 7.3, y en segundo lugar, no permite pasar cadenas y matrices como argumentos, aunque el operador fixed permite que se usen (obtenemos el puntero al primer carácter o elemento de matriz, respectivamente). Bueno, además, me gustaría darle al usuario la oportunidad de operar, incluidos los tipos controlados: desde que comenzamos a violar las reglas del lenguaje, ¡las violaremos hasta el final!

Obtener un puntero a un objeto administrado y un objeto por puntero


Y nuevamente, después de una deliberación no muy fructífera, comencé a buscar las soluciones finales. Esta vez el artículo sobre Habré me ayudó. En resumen, uno de los métodos propuestos en él es escribir una biblioteca auxiliar, y no en C #, sino directamente en IL. Su tarea es insertar un objeto (en realidad una referencia al objeto) en la pila de la máquina virtual, pasarlo como un argumento y luego recuperar algo más de la pila, por ejemplo, un número o IntPtr . Al realizar los mismos pasos en orden inverso, puede convertir el puntero (por ejemplo, devuelto por la inserción del ensamblador) en un objeto. Este método es bueno porque todo lo que sucede es claro y transparente. Pero hay un inconveniente: quería sobrevivir con la menor cantidad de archivos posible, así que en lugar de escribir una biblioteca separada, decidí incrustar el código IL en el principal. La única forma que encontré es escribir métodos stub en C #, construir el proyecto, desmontar el binario usando ildasm, reescribir el código de los métodos stub y volver a armarlo todo con ilasm. Estas son bastantes acciones adicionales, y dado que debe realizarlas cada vez que las construye después de realizar cambios en el código ... En general, me cansé bastante rápido y comencé a buscar alternativas.

Justo en ese momento, un libro maravilloso cayó en mis manos, gracias al cual aprendí mucho por mí mismo: "CLR a través de C #" de Jeffrey Richter. En él, en algún lugar alrededor del vigésimo capítulo, hablamos sobre la estructura GCHandle , que tiene un método Alloc() que toma un objeto y uno de los GCHandleType enumeración GCHandleType . Entonces, si llama a este método pasándolo el objeto deseado y GCHandle.Pinned , puede obtener la dirección de este objeto en la memoria. Además, antes de llamar a GCHandle.Free() objeto es fijo, es decir totalmente protegido de los efectos del recolector de basura. Sin embargo, hay ciertos problemas. En primer lugar, GCHandle no ayuda de ninguna manera a completar la conversión "puntero → objeto", solo "objeto → puntero". Más importante aún, para usar GCHandleType.Pinned clase o estructura del objeto cuya dirección que queremos obtener debe tener el atributo [StructLayout(LayoutKind.Sequential)] , mientras que LayoutKind.Auto usa por LayoutKind.Auto . Por lo tanto, este método es adecuado solo para algunos tipos estándar y para aquellos tipos personalizados que fueron diseñados originalmente con esto en mente. No es exactamente el método universal que nos gustaría encontrar, ¿verdad?

Bueno, inténtalo de nuevo. Ahora prestemos atención a dos funciones no documentadas, que, sin embargo, son compatibles con Roslyn: __makeref() y __refvalue() . El primero de ellos toma un objeto y devuelve una instancia de la estructura TypedReference que almacena una referencia al objeto y su tipo, mientras que el segundo extrae el objeto de la instancia transmitida typedReference . ¿Por qué estas características son importantes para nosotros? ¡Porque TypedReference es una estructura! En el contexto de la discusión, esto significa que podemos obtener un puntero que, en combinación, será un puntero al primer campo de esta estructura. Es decir, almacena el enlace al objeto que nos interesa. Luego, para obtener un puntero a un objeto administrado, necesitamos leer el valor de un puntero a lo que __makeref() devolverá y convertirlo en un puntero. Para obtener un objeto por puntero, debe llamar a __makeref() desde un objeto condicionalmente vacío del tipo requerido, obtener un puntero a la instancia TypedReference devuelta, escribir un puntero al objeto en él y luego llamar a __refvalue() . El resultado es algo como este código:

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

Observación
Volviendo a la tarea de escribir un contenedor seguro para InvokeAsm() , debe tenerse en cuenta que el método para obtener punteros usando __makeref() y __refvalue() , a diferencia de usar GCHandle.Alloc(GCHandleType.Pinned) , no garantiza que nuestro recolector de basura no esté en ninguna parte El objeto no se moverá. Por lo tanto, el contenedor debe comenzar apagando el recolector de basura y terminando con la restauración de su funcionalidad. La solución es bastante grosera, pero efectiva.

Para aquellos que no recuerdan los códigos de operación


Entonces, aprendimos cómo llamar al código binario, aprendimos a pasarlo como argumentos no solo valores inmediatos, sino también indicadores a cualquier cosa ... Solo hay un problema. ¿Dónde obtener el mismo código binario? Puede armarse con un lápiz, un bloc de notas y una tabla de código de operación (por ejemplo, este ) o tomar un editor hexadecimal con soporte de ensamblador x86 o incluso un traductor completo, pero todas estas opciones significan que el usuario tendrá que usar algo más que la biblioteca. Esto no es exactamente lo que quería, así que decidí incluir mi traductor en la biblioteca, que tradicionalmente se llamaba SASM (abreviatura de Stack Assembler; no tiene nada que ver con el IDE ).

Descargo de responsabilidad
No soy bueno para analizar cadenas, así que el código del traductor ... bueno, imperfecto, por decir lo menos. Además, no soy fuerte en las expresiones regulares, por lo que no están allí. Y en general, un analizador iterativo.

Probablemente no voy a hablar sobre el proceso de creación de este "milagro". No hay nada interesante en esta historia, pero describiré brevemente las características principales. La mayoría de las instrucciones x86 son compatibles actualmente. Las instrucciones de coprocesador matemático para trabajar con números de coma flotante y desde extensiones (MMX, SSE, AVX) aún no son compatibles. Es posible declarar constantes, procedimientos, variables de pila locales, variables globales, cuya memoria se asigna durante la traducción directamente en una matriz con código binario (si estas variables se nombran usando etiquetas, entonces su valor también se puede obtener de C # después de realizar la inserción llamando a métodos GetBYTEVariable() , GetWORDVariable() , GetDWORDVariable() , GetAStringVariable() y GetWStringVariable() del objeto SASMCode ), las macros addr e SASMCode están presentes. Una de las características importantes es el soporte para importar funciones de bibliotecas externas usando la construcción extern < > lib < > .

asmret macro merece un párrafo separado. En el proceso de traducción, se desarrolla en 11 instrucciones que forman el epílogo. El prólogo se agrega al comienzo del código traducido de forma predeterminada. Su tarea es guardar / restaurar el estado del procesador. Además, el prólogo agrega cuatro constantes: $first , $second , $this y $return . Durante la traducción, estas constantes se reemplazan por direcciones en la pila, en las cuales, respectivamente, se encuentran el primer y el segundo argumento pasados ​​al inserto del ensamblador, la dirección del primer comando de inserción y la dirección de retorno.

Resumen


El código dirá mucho más que palabras, y sería extraño no compartir el resultado de un trabajo bastante largo, por lo que invito a todos los que estoy interesado a GitHub .

Sin embargo, si trato de generalizar de alguna manera todo lo que se ha hecho, entonces, en mi opinión, ha resultado un proyecto interesante e incluso, hasta cierto punto, no inútil. Por ejemplo, algoritmos idénticos para ordenar insertos en C # y usar insertos de ensamblador difieren en velocidad en más de dos veces (por supuesto, a favor del ensamblador). En proyectos serios, por supuesto, no se recomienda usar la biblioteca resultante (los efectos secundarios impredecibles son posibles, aunque no muy probables), pero es bastante posible para usted.

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


All Articles