El proceso de infección de un binario empieza, como no podría ser de otro modo, con la inyección de un código que, uhm... digamos que "amplía el abanico sus potenciales características" (seguro que si lo escribo así puedo venderle un binario infectado a alguien a precio de versión profesional).
Esta es la fase más crítica de la infección, ya que si no la hacemos bien podemos corromper el binario a atacar, quedando nuestras acciones al descubierto. Para este fin, he enumerado una serie de técnicas a las que podemos recurrir, con sus pros y sus contras. Intentaré hacer una implementación de todas ellas, las cuales se compilarán de la misma forma que compilamos nuestro primer código PIC en la entrega anterior
A lo largo de esta entrega sólo me centraré en cómo inyectar, independientemente de las dificultades que tengamos en cada caso para lanzar u ocultar lo que hemos inoculado. De hecho, las pruebas de concepto que enlazaré inyectan código que no se puede ejecutar debido a llamadas de tipo DEBUG que dependen de cadenas ubicadas en el segmento de datos. Cómo disparar el código inyectado y qué ajustes deben hacerse en la práctica es algo que veremos en la siguiente entrega.
Inyectando al final del fichero: sobreescribiendo una cabecera poco útil
Esta es la forma más sencilla que se me ocurre, a la par que la menos portable y la que más efectos colaterales puede tener. Consiste en encontrar una cabecera de tipo PT_NOTE, transformarla en una cabecera de tipo PT_LOAD apuntando a nuestro código y a volar. Esto hoy por hoy no es tan peligroso como podría serlo antaño, pero el riesgo está ahí. Y esto se debe a que PT_NOTE informa sobre el tipo de sistema para el que el binario ha sido compilado. Varios sabores de Unix como Solaris o BSD utilizan esta información para ejecutar binarios de otros sistemas (como Linux) e infectar un binario "extranjero" utilizando esta técnica lo dejaría totalmente inútil. No es una característica de la que pueda dar un ejemplo práctico, pero las especificaciones están ahí.
Bueno, ¿y en qué dirección virtual lo cargamos? Pues podríamos cargarlo justo antes del segmento de texto (la dirección más baja, vaya), ya que por lo general el segmento de código suele cargarse en direcciones muy altas del espacio de direcciones. Ojo: esto podría no ser así en x86-64, donde los programas se cargan en direcciones muy bajas y podríamos estar muy cerca si no ya en el límite.
No podríamos cargarla, por ejemplo, justo después del segmento de datos ya que por ahí se encuentra el program break, que marca el final del heap. Debemos dejar espacio por arriba para que el programa crezca a medida que malloc (o similares, además de brk y sbrk) lo pida.
El plan de acción es el siguiente:
- Nos inyectamos al final del archivo en un desplazamiento alineado al tamaño de página. En x86 esto supone hasta 4096 bytes (Linux, por ejemplo, me exige que los segmentos tengan el mismo alineamiento en el archivo que en memoria. Esto es consecuencia directa del modo de carga de binarios el Linux: el archivo se mapea -no se lee- por lo que los desplazamientos relativos se mantienen)
- Buscamos la cabecera PT_NOTE, y si la hay, la chafamos con nuestra información de carga.
La prueba de concepto la tenemos aquí, en la cual me he abstenido de implementar la llamada a exit ya que en la práctica nunca tendríamos que llamarla:
Este código busca un binario llamado "victim" en el directorio actual y le inocula una copia suya al final del archivo para que se cargue antes del segmento de texto. Lo llamativo en este caso es que modificamos la cabecera PT_NOTE antes de hacer la inyección. Esto se hizo así debido a que así comprobábamos de paso si había una cabecera PT_NOTE que infectar. Si lo hiciésemos al revés, podríamos infectar varias veces el mismo archivo. Así, ante la ausencia de una PT_NOTE, falla.
La forma "correcta" sería buscar la PT_NOTE, salir si no se encuentra, inyectar y modificar la PT_NOTE. Pero esto no es más que añadir código redundante y aunque podría incluir la inyección directamente en replace_note he decidido moverme un poco más a lo conceptual y separar ambos procedimientos.
Se puede comprobar con varios ejecutables -y por favor, si falla, hacédmelo saber- que la inyección funciona (basta con lanzar un pmap / gdb y depurar la memoria del binario infectado) y lo mejor de todo: que es asintomático.
Inyectando al final del archivo: desplazando la tabla de cabeceras de programa
Es una mejora respecto a la anterior. Consiste en copiar toda la tabla de cabeceras de programa al "espacio sin referenciar" ampliando el segmento de texto y dando espacio así para una cabecera más de tipo PT_LOAD y así no suprimimos tal información. Pros: muy poco impacto en la tabla de cabeceras, no tenemos que borrar información, sólo añadir. Contras: de ese espacio sin referenciar, sólo podemos utilizar el relleno del segmento de texto (es decir, antes de que los bits del desplazamiento del fichero se hagan 0 y el alineamiento marque el final de la última página de código), y la cota inferior de este relleno es 0, o sea que a veces podremos explotarlo, a veces no. Es muy dependiente de la implementación de cargador (en Linux, por ejemplo, si movemos la tabla de cabeceras al final del fichero no funciona: la tabla de cabeceras DEBE estar dentro del segmento de texto). Perdemos espacio sin referenciar que podríamos utilizar para inocular nuestro código. Y tenemos que encontrar una forma de saber si el binario ha sido infectado dos veces, lo cual ya implica la existencia de una forma de detectar binarios infectados.
Hay una pega más, y es que el segmento de datos puede empezar antes del final teórico del segmento de texto (es decir, antes de que el desplazamiento se haga múltiplo de 4096). O sea que debemos comprobar que el espacio que vamos a utilizar no sea utilizado por otro segmento.
El plan de acción sería el siguiente:
- Buscar el relleno del segmento de texto, y calcular si "cabe" la tabla extendida (primero comprobamos el alineamiento y después si no se nos monta encima de otro segmento).
- Si cabe, copiamos la vieja añadiendo la nueva entrada PT_LOAD con la información que nos plazca.
- Modificamos el desplazamiento de la tabla (tanto en la cabecera del binario como en la cabecera PT_PHDR), y ampliamos el segmento de texto de forma que ahora contenga esos bytes extra.
- Copiamos el código al final del fichero, alineado debidamente a la página.
Si hacemos esto, ya podemos olvidarnos de utilizar ese agujero para inocular nuestro código. Ese espacio es precioso como ya veremos después, y es una pena desperdiciarlo de esta forma (aunque podríamos recuperarlo un poco utilizando la técnica de troceado de código que utilizaba CIH, por ejemplo). Aún así, incluyo una pequeña implementación de esta técnica:
Si probamos este código, veremos que fallará más veces (o mejor dicho, nos dirá que la infección no se pudo llevar a cabo porque no se pudieron mover las cabeceras). Esto es debido a que esa brecha entre el código y los datos no siempre estará presente. En mi sistema ha funcionado con los binarios de cat, nm, rm y octave, pero falló con los de bash, ls, gcc y dir. Esto está bien si nos importa más la calidad que la cantidad. Tendremos menos binarios infectados, pero los infectados al menos conservan el PT_NOTE.
Inyectando al final del archivo: estirando el segmento de datos
Esta es quizá la más extrema y menos viable de todas. Pegamos al final del fichero, y extendemos el último segmento hasta cubrir todo hasta el final. Pros: no tenemos que chafar una cabecera de programa existente ni añadir una nueva cabecera. Contras: hacer que esto funcione implica modificar el segmento de datos del binario a infectar para estirarlo y marcarlo como ejecutable, lo cual implica un segmento con todos los permisos encendidos y vaya, que es un canteo. Además, estirar el segmento de datos tiene como consecuencia algo desagradable: toda la basura sin referenciar que aparece detrás y que no debería cargarse se trae a memoria. Esa zona de la RAM debería estar llena de ceros debido a que pertenece a la región de variables globales sin inicializar (el .bss), por lo tanto tendríamos que arreglarlo nosotros, dando espacio suficiente para cuadrar nuestro código por un lado y anulándolo después. Otro problema, es que esta información debemos incluirla en cada inyección. Y por último, que no hay cotas superiores razonables de tamaño para el .bss, o sea que esto puede suponer un brutal (y sospechoso) incremento del tamaño del binario.
Algo que parecía sencillo se ha complicado muchísimo por una tontería. Estamos forzados a limpiar el .bss, y me niego a implementar esta monstruosidad. Pero el plan de ataque sería el siguiente:
- Tenemos que añadir espacio para dos números de 32 bits al inicio (o al final, por ejemplo) de nuestro código, incluyendo de dónde a donde va la zona que debemos anular. Siempre tiene que estar ahí, y necesitamos que sea fácil de ubicar.
- Abrimos el fichero a infectar, examinamos sus cabeceras de programa y rellenamos con 0 al final hasta cubrir todo el .bss
- Inyectamos nuestro código, modificando apropiadamente ese par de enteros que contienen los límites del .bss que luego deberán ser recuperados.
- Modificamos la tabla de cabeceras de programa para que el código inoculado sea visible en memoria. Escojo el segmento de datos y no otro porque en los ejecutables este suele ser el último segmento PT_LOAD que hay (el que se carga en zonas más altas, tanto en memoria como en el archivo).
Este truco no funciona, por ejemplo, si los segmentos están desordenados. Es decir, lo que es el último segmento en memoria no aparece como el último segmento en el archivo. Esta situación es harto rara (yo no conozco ningún ejecutable que lo haga), pero tampoco es imposible.
Inyección en el relleno del segmento de texto
Esta es, a mi ver, la forma más bonita de infectar un binario. Buscamos su segmento de texto, comprobamos si hay espacio de la misma forma que hicimos antes para ubicar una nueva tabla de cabeceras de programa, la estiramos y metemos en esos bytes de relleno (a partir de ahora, "la brecha código / datos", o simplemente "la brecha" o "the gap") nuestro código. Pros: la única forma de detectar que el binario ha cambiado sería comparando byte a byte con el original. El binario no crece en absoluto, no aparecen nuevos segmentos PT_LOAD (estiramos uno existente), no cambiamos más que su tamaño y por lo que resta es totalmente asintomático. Contras: los años ochenta han quedado atrás y hablar en código máquina ya no está de moda, la única forma de que esta técnica fuese viable sería reducir nuestro código a la mínima expresión -y para eso necesitaríamos escribirlo enteramente en ensamblador, con los consiguientes problemas de mantenimiento y depuración que ello implica-, y aún así las situaciones en las que encontrásemos una brecha lo suficientemente grande no serían especialmente numerosas.
Este método de infección queda reservado para los paranoicos de la optimización y gente a la que su religión le impide utilizar más de una instrucción para intercambiar el valor de dos variables. Si alguien es lo suficientemente hábil como para explotar esta técnica y hacer uso útil de ella, a saber de qué otras cosas será capaz. Temblad.
En vez de hacer una prueba de concepto de esto esperaré hasta la siguiente técnica, que la completa y la contiene, haciendo este tipo de infección un poco más viable.
Inyección segmentada: rellenando agujeros del segmento de código
La mejor forma de introducir esta técnica es hablando de la instrucción NOP. La instrucción NOP significa "No OPeration", cuando la CPU la lee pierde un ciclo y lee la siguiente instrucción. Esta instrucción tiene varios usos de cara a la optimización, a veces perder un ciclo haciendo nada evita la sobrecarga del procesador en ciertos bucles, a nivel de kernel también se suele utilizar para esperar un número concreto de ciclos hasta que un dispositivo termine de cambiar su estado, e incluso en algunas arquitecturas sirve para introducir burbujas artificiales y evitar los denominados "riesgos" del pipeline de la CPU.
Pero lo importante aquí es cuando se usa para rellenar. GCC (y muchísimos compiladores y enlazadores) intenta alinear funciones de vez en cuando, y los agujeros (que nunca se alcanzan por el flujo del programa) acaban convertidos en NOPs. Esto se hace sobre todo para que las zonas de código no ocupadas sean coherentes con el tipo de segmento en el que se encuentran, y la mejor forma de decir que esos bytes no sirven para nada (no hacen nada), es estableciéndolas a NOP.
La instrucción NOP en x86 mide un byte, que es 0x90. En algunos ejecutables existe una cantidad ingente de NOPs seguidos que podrían ser utilizados para incrustar en ellos una versión troceada de nuestro código (y acompañada de los debidos metadatos). Y si no me creéis, adjunto una herramienta que contabiliza estas regiones. Allá donde encuentre más de 5 o más NOPs seguidos, asume que es un relleno y los suma a cierto contador. Así cuenta cuántos bytes útiles podríamos utilizar en un binario para ubicar nuestro código.
Esto es muy similar a lo que hacía CIH, con la salvedad de que no se aprovecha de espacios entre secciones en sí, si no que usa espacios dentro del propio código.
Pros: en binarios grandes, por ejemplo, esto es una fiesta. La libc6 tiene más de 9 kilobytes en NOPs. Y una vez infectado, encontrar los pedazos que conforman el código puede ser infernal a menos que se conozca con exactitud dónde se encuentran. El binario no crece y tampoco aparecen cabeceras extras, sólo crece un pelín el segmento de código, no mucho más. Contras: la cota inferior de NOPs por binario es 0. En los casos más comunes encontraremos 200 o 300 NOPs usables, y encontrar más de 500 ya es complicado. El único sentido que se le puede dar a esto es como complemento a la técnica de inyección en la brecha. Tampoco tenemos garantía de que esas tiras de 5 nops seguidos no se usen para algo. Normalmente nunca se suelen ejecutar, pero tampoco es algo seguro. Además, hay que incluir una rutina de reensamblado, lo más pequeña posible y escrita en ensamblador que no se trocee.
Para la prueba de concepto, vamos a ponernos en un caso realista: queremos aprovechar conjuntamente la brecha y los NOPs.
Primero pensemos en cómo trocear nuestro código. Para reducir al máximo los metadatos que debemos usar para controlar los trozos (donde empiezan y cuánto miden), yo describiría toda esa información en 4 bytes por trozo: en 3 bytes metería el desplazamiento del trozo (eso nos da 24 generosos bits para direccionar, no vamos a encontrar muchos binarios con 16 MiB de código por ahí) y en el byte restante introduciría el tamaño del tozo (y me parece hasta demasiado, las tiras de NOPs no suelen superar los 16 bytes, ya ni digamos 255).
Esto quiere decir que por cada trozo, tenemos que guardar 4 bytes adicionales. ¿Dónde metemos esa información? Pues a mí se me ocurren dos sitios. El primero, al principio de cada trozo. Como forzamos a que cada uno tenga 5 bytes, así aseguro al menos un byte libre. El segundo, en la brecha.
Cada cual tiene sus más y sus menos, aunque ambos gastan exactamente la misma cantidad de memoria. Sin embargo, la brecha es un lugar muy especial: cuando existe normalmente es pequeña, por lo que meter todos los metadatos ahí va a ser una pérdida dolorosa, y necesitamos esos bytes lo más libres posibles para que al menos la rutina de reensamblado quepa entera (la cual tiene que hacer mmap, entre otras cosas). La otra técnica implica más código contiguo en la brecha (lo cual es bueno) a costa de complicar el código de reensamblado.
Es mejor estudiar esto con binarios reales, o sea que me tomé la molestia de escribir una pequeña aplicación -en el formato de código inyectable que estoy utilizando como ejemplo- que contabiliza el espacio usable en un binario víctima y evalúa su viabilidad. En mi caso, tengo unas estadísticas de viabilidad similares a las que tenía con la técnica del desplazamiento de cabeceras (aunque, claro esta, dicha viabilidad comenzará a caer a medida que el código crezca). No está tan mal.
Lo principal ahora es comprobar que no haya efectos colaterales. Y para ello recurriremos a la implementación difícil, añadiendo un criterio de "mínima contigüidad": si no caben 64 bytes contiguos de nuestro código en el relleno del segmento de texto, entonces la rutina de desensamblado no cabe y no puede hacerse. Puse 64 por poner algo, esto habría que afinarlo después. He modificado el programa anterior hasta llegar a este código que prueba la viabilidad de este ataque en concreto, lo que nos da un código ligeramente más pequeño. Si repetimos el experimento con varios binarios de nuestro sistema, las estadísticas se ajustan un poquito más a la realidad.
Pero bueno, basta de hacer estimaciones teóricas, y vayamos a la práctica. ¿Puede hacerse realmente?
Para la prueba de concepto reservaré espacio en .code_bottom para un par de enteros más, destinados a ubicar el tamaño de la zona contigua y el desplazamiento del siguiente trozo (si lo hay). Es una cantidad que debo leer en el reensamblado para saber si el código se ha cortado o no. Si está a 0, es que todo está en la brecha y fin de la historia. Si está a distinto de 0, entonces es el tamaño de la zona contigua me viene dado por ese valor, y el desplazamiento relativo me viene en el siguiente entero. Eso implica que tengo que llamar a mmap, reservar una página (o más) y ensamblar nuestro código ahí.
En este caso es importante que ahora compilemos con -fno-align-functions, para evitar que GCC nos rellene el código con NOPs y pudiendo estropear así el algoritmo de búsqueda.
Esta implementación la tuve que reducir bastante, y aquí abandoné los open / reads por mmaps. Mucho más sencillo de usar, menos llamadas a funciones y menor tamaño en general.
A pesar de todo, este código es grande, muy grande, y hace cosas redundantes. Así me pongo en un caso muy malo: en la mayor parte de los intentos de infección, o no cabe, o cabe todo en la brecha. Sólo he encontrada dos casos en los que la inyección segmentada es la solución: en los binarios de mplayer y busybox, y sólo en el de mplayer la infección es asintomática (en busybox algo extraño pasa, que parece leer cosas del segmento de código y los comandos fallan).
Mientras escribo estas líneas escucho música desde el mplayer infectado y aún ayer lo utilicé para ver relajadamente un par de películas de fansub patatero. De aquí os podéis descargar el binario original, y de aquí el infectado. Haciendo un objdump -sdx (o un simple cmp -l) podréis encontrar las diferencias. Y como prueba de que la información se puede recuperar, he escrito un pequeño programa que detecta un binario infectado y extrae el código inyectado.
Y en el próximo episodio...
Ya hemos visto las diversas técnicas que tenemos a nuestra disposición a la hora de inyectar en ELF. Probablemente haya más, pero con casi toda seguridad se podrán describir en términos de combinación de ideas de las anteriores.
Sin embargo, si la inyección era la parte más delicada (ya que si no la hacemos bien podemos destrozar lo que estamos infectando) ahora queda la parte más importante, a partir de la cual podemos empezar a tratar con binarios infectados que realmente ejecutan código extra. En la próxima entrega veremos a qué punteros podremos engancharnos, cómo detectar nuestro punto de entrada y la tan ansiada rutina de reensamblado requerida por la técnica de infección segmentada.
------------------------------------------------------------------------------
Artículo cortesía de BatchDrake
2 comments :
El nivel técnico de este artículo es de lujo. Estoy muy contento de que material tan bueno sea compartido en la red. Ánimo BatchDrake! Estamos impacientes por leer la 3ª parte.
Excelente las entregas, se agradece todo el tiempo invertido en escribir estos textos, se espera con ansias la siguiente parte
Saludos!
Publicar un comentario