En este capítulo vamos a presentar algunos conceptos avanzados en la programación en lenguaje ensamblador, como la relación con la API. También, listaré toda una serie de instrucciones que me parecen importantes a la hora de programar, y que aún no han sido mencionadas. Todo, irá acompañado de ejemplos de código, que a estas alturas ya deberíamos de saber manejar un poco.
Así como los apartados 5.1, 5.2 y 5.3 son bastante importantes, a quienes se vean abrumados por todo esto ya les digo que pueden saltarse tranquilamente el apartado 5.4 (dedicado al coprocesador) . Se van a perder poco si se los saltan en el sentido de que si les resulta ya agotador todo lo aprendido hasta el momento, esto puede que les despiste del verdadero objetivo en el sentido de que no es necesario entenderlo para escribir virus ni para aprender ensamblador; se trata de un poquito de "cultura general" sobre cómo funcionan las cosas internamente (en cualquier caso he intentado dar una visión poco profunda en ese apartado precisamente para no marear a nadie).
Interrupciones y API
Introducción
La API es, como decíamos en el segundo capítulo de este tutorial, la herramienta por la cual nos comunicamos con el sistema operativo y las funciones que este tiene para hacernos la vida más fácil. Una operación puede ser abrir un fichero, escribir sobre él, cambiar el directorio actual o escribir en la pantalla.
Hay dos métodos que vamos a ver en que se usa API del sistema operativo; por interrupciones pasando los parámetros en registros como hace Ms-Dos, por llamadas a subrutina como hace Win32, y un híbrido con llamadas a interrupción y paso de parámetros en pila, el sistema operativo Linux.
Interrupciones en Ms-Dos
Vale, lo primero que hay que tener en la cabeza es que en Ms-Dos *todo* se hace a través de interrupciones; y que distintas interrupciones llaman a servicios orientados hacia algo distinto.
¿Qué es una interrupción software?. Se trata de un tipo muy especial de llamada a una función del sistema operativo (o de otros programas residentes, el sistema es bastante flexible). La instrucción para hacerlo es INT, y viene acompañada siempre de un número del 0 al 255 (decimal), es decir, del 00h al 0FFh en hexadecimal.
¿Dónde se va la ejecución cuando escribimos por ejemplo "INT 21h"? Bien, en Ms-Dos, en la posición de memoria 0000:0000 (en Ms-Dos usamos un direccionamiento de 16 bits pero paso de explicarlo porque a estas alturas es un tanto ridículo jugar con el Ms-Dos) hay una "Tabla de Vectores de Interrupción" o IVT. Esta IVT, contiene 256 valores que apuntan a distintas direcciones de memoria, a las que va a saltar la ejecución cuando se haga una INT.
Entonces, si escribimos algo como "INT 21h", lo que va a hacer es leer en la posición de memoria 0000 + (21*4), el valor que hay, para luego pasar (como si fuera un CALL, empujando en la pila la dirección de retorno) a ejecutar en esa posición de memoria. En realidad, la única diferencia con un CALL es que no le indicamos la dirección a la que saltar (el procesador la lee de la tabla de interrupciones), y que además de empujar el valor de la dirección de retorno, se empuja también el registro de FLAGS.
Por ello, cuando se acaba de ejecutar el servicio solicitado de la interrupción, esta rutina no acaba en un RET, sino en lo que antes habíamos mencionado, en un IRET; la función de esta instrucción es sencilla: saca la dirección de retorno y los flags, en lugar de tan sólo la dirección de retorno.
Como ejemplo práctico, el tipo de función en Ms-Dos dentro de una interrupción suele indicarse en EAX, y los parámetros en el resto de registros (EBX, ECX y EDX normalmente). Por ejemplo, cuando queramos abrir un fichero como sólo lectura tenemos que hacer lo siguiente:
mov ax, 3D02h ; el 3D indica "abrir fichero", y el 02h indica "en lectura y escritura" mov dx, offset Fichero ; Apuntamos al nombre del fichero int 21h ; Ahora, se abrirá el fichero (paso de explicar todavia qué es un handler xD) Fichero: db 'fichero.txt',0
Bueno, nos hemos encontrado (qué remedio) una cosa nueva de la que no habíamos hablado antes... esto de "db" significa "data byte", vamos, que estamos indicando datos "a pelo", en este caso el nombre de un fichero. Y sí, hay una coma, indicando que después de esos datos "a pelo" se ponga un byte con valor cero (para delimitar el fin del nombre del fichero). DX va a apuntar a ese nombre de fichero y AX indica la función... y voilá, fichero abierto.
Destacar otra cosa: existen dos instrucciones que sirven para activar o inhabilitar las interrupciones (ojo, que inhabilitar no las deshace por completo, pero sí impide la mayor parte; es útil por ejemplo al cambiar los valores de SS/SP para que no nos pete en la cara). CLI (CLear Interrupts) inhabilita las interrupciones, y STI (SeT Interrupts) las activa.
Otra cosa: se puede ver que no estoy usando registros extendidos, que uso AX y DX en vez de EAX y EDX... en fin, recordad hace cuánto que existe el Ms-Dos y así respondéis a la pregunta :-)
Y en fin, que esto es el sistema de interrupciones en Ms-Dos, que me niego a volver a tocar porque es perder el tiempo: a quien le interese que escriba en un buscador algo así como "Ralf Brown Interrupt List", que es una lista salvaje que tiene todas las funciones habidas y por haber para interrupciones de Ms-Dos. Las más importantes están dentro de la INT 21h, que controla cosas como el acceso a ficheros (creacion, lectura/escritura, borrado...) y directorios.
La Int80h y Linux
En Linux pasa tres cuartas de lo mismo, pero todas las funciones del sistema están reunidas bajo una sóla interrupción, la 80h. Vamos a tener 256 posibilidades, que se indican en AL (bueno, podemos hacer un MOV EAX, igualmente).
Hay algunas diferencias básicas con el sistema de Ms-Dos. La primera es más "teórica" y hace referencia a la seguridad. Cuando estamos ejecutando normalmente, el procesador tiene privilegio de "usuario". Cuando llamamos a la INT80h, pasamos a estado de supervisor y el control de todo lo toma el kernel. Al terminar de ejecutarse la interrupción, el procesador vuelve a estar en sistema usuario (y por supuesto con nivel de usuario el proceso no puede tocar la tabla de interrupciones). Con Ms-Dos digamos que siempre estamos en supervisor, podemos cambiar los valores que nos salga de la tabla de interrupciones y hasta escribir sobre el kernel... pero vamos, lo importante, que con este sistema, en Linux está todo "cerrado", no hay fisuras (excepto posibles "bugs", que son corregidos, a diferencia de Windows).
Respecto al paso de parámetros, se utilizan por órden EBX, ECX, etc (y si la función de interrupción requiere muchos parámetros y no caben en los registros, lo que se hace es almacenar en EBX un puntero a los parámetros.
mov eax, 05h ; Función OpenDir (para abrir un directorio para leer sus contenidos) lea ebx, [diractual] ; LEA funciona como un "MOV EBX, offset diractual"; es más cómodo. xor ecx, ecx int 080h diractual: db '.',0 ; Queremos que habra el directorio actual, o sea, el '.'
Para más información, recomiendo echar un vistazo a www.linuxassembly.org, de donde tiene que colgar algún link hacia listas de funciones. Y también cuidado porque aunque en Linux parece que todo está documentado hay mucho que o no lo está o incluso está mal (si alguien se ha tenido que mirar la estructura DIRENT sabrá de qué le hablo, es más difícil hacer un FindFirst/FindNext en ASM en Linux que infectar un ELF xD)
DLL's y llamadas en Windows
Bajo Win32 (95/98/Me/NT/etc) no vamos a utilizar interrupciones por norma general. Resulta que la mayor parte de funciones del sistema están en una sóla librería, "KERNEL32.DLL", que suelen importar todos los programas.
DLL significa Librería Dinámica, y no solo tiene porque tener funciones "básicas" (por ejemplo, en Wininet.DLL hay toda una serie de funciones de alto nivel como enviar/coger fichero por FTP). Lo que sucede es que cuando un programa quiere hacer algo así (pongamos estas funciones de FTP) tiene dos posibilidades: uno, las incorpora a su código, y dos, las coge de una librería dinámica. ¿Cuál es la ventaja de esto? Bien, cuando usamos una librería dinámica no tenemos que tener diez copias de esa rutina en cada uno de los programas compilados; al estar en la DLL, el primer programa que la necesite la pide, la DLL se carga en memoria, y se usa una sóla copia en memoria para todos los programas que pidan servicios de ella.
En palabras sencillas; tenemos la función MessageBox, por ejemplo, que abre una ventana en pantalla mostrando un mensaje y con algún botón del tipo OK, Cancelar y tal. ¿Qué es más eficiente, tener una librería que sea consultada por cada programa, o tener una copia en cada uno?. Si cada programa ocupa 100Kb de media y la librería 10Kb, al arrancar 10 veces el programa si tuviéramos el MessageBox en DLLs, el espacio en memoria sería de 1010Kb (y en disco, igual). En caso de que no usáramos DLLs y la función MessageBox estuviera en cada programa, tendríamos 1100Kb de memoria ocupada (y de disco). Por cierto, que el Linux también usa librerías dinámicas, sólo que para programar en ASM sobre él normalmente nos va a sobrar con lo que tengamos en la Int80h.
Volviendo al tema, la forma de llamar a una función de la API en Win32 es como lo que comentábamos de paso de parámetros al final del apartado dedicado a subrutinas. Todos los valores que han de pasársele a la función se empujan a la pila, y luego se hace un CALL a la dirección de la rutina. El aspecto de una llamada a la API de Win32 (exáctamente a MessageBox), es así:
push MB_ICONEXCLAMATION ; El tipo de ventana a mostrar push offset Azathoth ; Esto, el título de la ventana que va a aparecer. push offset WriteOurText ; Y esto el texto de dentro de la ventana. push NULL call MessageBoxA ; Llamamos tras empujar los parámetros, y luego seguimos ejecutando
|| WriteOurText: texto. || db || 'H0 H0 H0 NOW I HAVE A MACHINE GUN',0 || ; El 0 delimita el final del ||
|| Azathoth: || || db || 'Hiz :P',0 || ||
|| || || || || ||
La mayoría de las funciones que se van a utilizar están en KERNEL32.DLL. No obstantes hay otras, como USER.DLL, bastante importantes. Podemos ver si un ejecutable las importa si están en su "tabla de importaciones" dentro del fichero (es decir, que está indicado que se usen funciones de ellas). Una de las cosas más interesantes sobre este sistema, será que podemos cargar DLLs (con la API LoadLibrary) aún cuando ya se haya cargado el programa, y proveernos de servicios que nos interesen.
Una lista bastante interesante de funciones de la API de Windows está en el típico CD del SDK de Windows; puede encontrarse también por la Red, se llama win32.hlp y ocupa más de 20Mb (descomprimido).
Representación de datos, etiquetas y comentarios
Buena parte de lo que voy a explicar ahora ha aparecido irremediablemente en ejemplos de código anteriores; no obstante, creo que ya es hora de "formalizarlo" y detallarlo un poco. Así pues, hablemos de estos formalismos utilizados en lenguaje ensamblador (tomaré como base los del Tasm, algunos de los cuales lamentablemente no son aplicables al Nasm de Linux):
Datos
La forma más básica de representar datos "raw", o sea, "a pelo", es usar DB, DW o DD. Como se puede uno imaginar, B significa byte, W word y D Dword (es decir, 8, 16 y 32 bits). Cuando queramos usar una cadena de texto - que encerraremos entre comillas simples -, usaremos DB. Así, son válidas expresiones como las siguientes:
db 00h, 7Fh, 0FFh, 0BAh dw 5151h dd 18E7A819h db 'Esto también es una cadena de datos' db 'Y así también',0 db ?,?,? ; así también... esto indica que son 3 bytes cuyo valor nos es indiferente.
Hay una segunda forma de representar datos que se utiliza cuando necesitamos poner una cantidad grande de ellos sin describir cada uno. Por ejemplo, pongamos que necesito un espacio vacío de 200h bytes cuyo contenido quiero que sea "0". En lugar de escribirlos a pelo, hacemos algo como esto:
db 200h dup (0h)
Etiquetas
Ya hemos visto el modo más sencillo de poner una etiqueta; usar un nombre (ojo, que hay que estar pendiente con mayúsculas/minúsculas porque para ensambladores como Tasm, "Datos" no es lo mismo que "dAtos" o que "datos"), seguido de un símbolo de ":". Cualquier referencia a esa etiqueta (como por ejemplo, MOV EAX,[Datos]), la utiliza para señalar el lugar donde ha de actuar.
Pero hay más formas de hacer referencias de este tipo; podemos marcar con etiqueta un byte escribiendo el nombre y a continuación "label byte" (etiquetar byte). Un ejemplo (y de paso muestro algo más sobre lo que se puede hacer) sería esto:
virus_init label byte <código> <código> virus_end label byte virus_length equ virus_end - virus_init
Parece que siempre que meto un ejemplo saco algo nuevo de lo que antes no había hablado... pero bueno, creo que se entiende; marcamos con label byte inicio y fin del virus, y hacemos que el valor virus_length equivalga gracias al uso de "equ", a la diferencia entre ambos (es decir, que si el código encerrado entre ambas etiquetas ocupa 300 bytes, si hacemos un "MOV EAX, virus_length" en nuestro código, EAX pasará a valer 300).
Comentarios
Conocemos en este punto de sobra la forma standard de incluir comentarios al código, esto es, utilizando el punto y coma. Todo lo que quede a la derecha del punto y coma será ignorado por el programa ensamblador, con lo que lo utilizaremos como comentarios al código.
Hay otro método interesante presente en el ensamblador Tasm, que señala el inicio de un comentario por la existencia de la cadena "Comment %". Todo lo que vaya después de esto será ignorado por el ensamblador hasta que encuentre otro "%", que marcará el final:
Comment % Esto es un comentario para Tasm y puedes escribir lo que quieras entre los porcentajes. %