Infección de ficheros ELF
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.
Carga en memoria en Linux
El formato ELF es curiosamente mucho más sencillo que infectar que el PE de Windows, y nos permite una interesante variedad de métodos. Explicaré de modo sencillo como afecta la estructura a la ejecución:
Cuando un fichero ELF es ejecutado, es decir, cuando escribimos su nombre en el shell, suceden una serie de cosas; primero, el shell llama a la función execve() de las libc, la libc llama al kernel con sys_execve(), el cual abre el archivo mediante do_execve(), busca el tipo de ejecutable con la función interna search_binary_handler(), carga las librerías en caso de ser ELF que este necesite mediante load_elf_binary(), crea el segmento de código para el programa y finalmente mediante una llamada a start_thread(), pasa a ejecutarse el código del programa.
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
Datos sin inicializar
(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).
Formato ELF, desde dentro
La estructura física de este tipo de fichero es la siguiente:
Cabecera ELF
Program Header Table (opcional)
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
typedef struct { unsigned char e_ident[EI_NIDENT]; Elf32_Half e_type; Elf32_Half e_machine; Elf32_Word e_version; Elf32_Addr e_entry; Elf32_Off e_phoff; Elf32_Off e_shoff; Elf32_Word e_flags; Elf32_Half e_ehsize; Elf32_Half e_phentsize; Elf32_Half e_phnum; Elf32_Half e_shentsize; Elf32_Half e_shnum; Elf32_Half e_shstrndx; } Elf32_Ehdr;
Nos interesarán especialmente:
-e_entry: El puntero (RVA) a inicio de ejecución de un nuevo programa respecto a la localización en memoria (e_entry). Una RVA, que ya definimos en Windows, es un puntero relativo a la dirección base en que el programa se carga en memoria. Es decir, que si el programa se carga en 040000h y el Entry Point es 200h, el programa comenzará su ejecución en 040200h.
-e_phentsize, e_phnum, e_shentsize, e_shnum, e_phoff, e_shoff: Diversos campos de descripción sobre entradas en la PHT (Program Header Table) y la SH (Section Header o Cabecera/Tabla de Secciones).
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).
Un posible algoritmo pues de infección sería el siguiente:
-Apertura del fichero y comprobación de si es un ELF y si es ejecutable
- 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.
Ejemplo de infección de un ELF
Bien, nos ponemos físicamente en el momento en que acabamos de mapear el fichero en memoria; la forma de infección que enseñaré, de tipo "cavity", es la que utilicé al programar el Lotek. Mientras que bajo Windows vimos un tipo de infección en la que nos añadíamos al final del programa, en esta ocasión nos vamos a colar en un hueco ya existente de él, en particular en la sección .notes. En fin, es el tipo de infección que acabamos de explicar y desarrollar en cinco puntos, sólo que ahora la desarrollaremos un poco más:
-Apertura del fichero y comprobación de si es un ELF y si es ejecutable
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:
typedef struct { Elf32_Word sh_name; Elf32_Word sh_type; Elf32_Word sh_flags; Elf32_Addr sh_addr; Elf32_Off sh_offset; Elf32_Word sh_size; Elf32_Word sh_link; Elf32_Word sh_info; Elf32_Word sh_addralign; Elf32_Word sh_entsize; } Elf32_Shdr;
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:
cmp word[eax+10h],tamanyo_virus jb NoHayEspacio
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):
mov edi,[eax+0Ch] add edi,<direccion_base_mapeado> lea esi,[ebp+inicio_de_nuestro_virus] mov ecx,tamanyo_virus rep movsb ; Copiado!
-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:
typedef struct { Elf32_Word p_type; Elf32_Off p_offset; Elf32_Addr p_vaddr; Elf32_Addr p_paddr; Elf32_Word p_filesz; Elf32_Word p_memsz; Elf32_Word p_flags; Elf32_Word p_align; } Elf32_Phdr;
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):
mov edi,[eax+0ch] ; Offset de la seccion sub eax,[ebx+098h] ; Offset seccion - offset segmento add eax,dword[ebx+09Ch] ; Desplazamiento ajustado al segmento mov dword [ebx+18h],eax ; Colocamos el nuevo entry point
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:
mov eax,1000h add dword [ebx+0A4h],eax add dword [ebx+0A8h],eax
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.
|| Sección || Offset en el fichero || Tamaño en fichero (p_filesz) || Tamaño en memoria ||
|| || || || (p_memsz) ||
|| Cabecera || 0 || 100h || No ||
|| Código || 100h || 1986h || 2000h ||
|| Datos || 2086h || 987h || 1000h ||
Técnicas avanzadas en Linux
GOT, PLT y la posibilidad de residencia per-process
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.
La GOT, mantiene direcciones absolutas (el código normal, que no ha de depender de la posición absoluta, no debería tenerlas, para poder ser compartido por distintos procesos). El programa obtendrá estos valores mediante un direccionamiento relativo, con lo cual se podrán redirigir llamadas a direccionamientos relativos a direcciones absolutas. Ojo, que estas direcciones absolutas no serán contenidas por el propio fichero (pues al compilarse no pueden saberse), sino que serán generadas por el linkador dinámico que es quien sabe de estas cosas ;)
Ahora, la PLT (que va a utilizar a la GOT), va a tener como función la conversión de direcciones independientes de la posición del programa a direcciones absolutas; la transferencia de control entre distintos programas que funcionan de modo independiente a su posición es un problema que el linkador dinámico no va a poder resolver por sí mismo, con lo que va a necesitar de esta tabla. Si, suena algo abstracto, transferencia de control entre objetos distintos del proceso y tal, pero, ¿qué es sino una llamada a funciones de libc, por ejemplo? Pues llamar desde un bloque de código independiente de su posición (el programa ejecutable) a la memoria compartida en la que se halla libc (que también funciona independientemente de la posición). Y la traducción para que efectivamente pueda llamársele, va a descansar sobre la PLT y la GOT.
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]
nop nop PLT1:
jmp dword ptr [EBX+Funcion]
push $valor
jmp PLTO PLT2:
[...]
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
2ª ejecución (y subsiguientes): Ya con el valor situado sobre el salto, la primera instrucción que antes era jmp dword ptr [EBX+Funcion] se sustituye por el salto absoluto a la función deseada.
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.
El aspecto de una entrada de la symbol table es el siguiente:
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:
typedef struct { Elf32_Addr r_offset; Elf32_Word r_info; } Elf32_Rel;
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.