_ .-') _ ('-. ('-. _ .-') _ .-. .-') .-') _ ('-. .-') ( ( OO) ) _( OO) ( OO ).-.( ( OO) ) \ ( OO ) ( OO) ) _( OO) ( OO ). \ .'_ (,------./ . --. / \ .'_ ;-----.\ ,--. ,--./ '._(,------.(_)---\_) ,`'--..._) | .---'| \-. \ ,`'--..._) | .-. | \ `.' / |'--...__)| .---'/ _ | | | \ ' | | .-'-' | | | | \ ' | '-' /_).-') / '--. .--'| | \ :` `. | | ' |(| '--.\| |_.' | | | ' | | .-. `.(OO \ / | | (| '--. '..`''.) | | / : | .--' | .-. | | | / : | | \ || / /\_ | | | .--' .-._) \ | '--' / | `---.| | | | | '--' / | '--' /`-./ /.__) | | | `---.\ / `-------' `------'`--' `--' `-------' `------' `--' `--' `------' `-----' ~ xcellerator [ Traducción por @Rinxlr ] ¡Hola entusiastas de ELF! En este artículo, quiero presentarles una pequeña librería en la que he estado trabajando llamada LibGolf. La cual comenzó como simplemente una manera para entender mejor EFL y las cabeceras de programas, pero desde entonces se ha convertido en algo razonablemente práctico. Es muy sencillo generar un binario consistiendo de una cabecera ELF, seguido de una cabecera de un programa simple, seguido de un segmento cargable. Por defecto, todos los archivos de cabecera contienen valores íntegros, pero existe una manera sencilla de modificar estos valores por defecto - ¡y eso es de lo que trata este artículo! Voy a demostrar como uso LibGolf para enumerar precisamente cuales bytes son necesarios y cuales son ignorados por el cargador de Linux. Afortunadamente, resulta que el cargador es uno de los parsers menos meticulosos de las herramientas estándar de linux. Antes de continuar, veamos como algunas de las herramientas de análisis más populares sucumben ante nuestro ELF corrupto, mientras que el cargador continúa alegremente cargando y leyendo los bytes que elegimos. +------------------------------+ |--[ Introducción a LibGolf ]--| +------------------------------+ Tiempo atrás, escribía mis ELF's a mano en NASM. Aunque fue divertido por un tiempo (y ciertamente tiene sus beneficios). Me dí cuenta que me faltaba toda la diversión que las estructuras de C tienen para ofrecer. En particular, como estoy seguro que muchos lectores saben, <linux/elf.h>, es empaquetado lleno de cosas divertidas como 'Elf64_Ehdr' y 'Elf32_Phdr' listas para declararse. No deseando que esas cabeceras tan útiles se desaprovechen, elegí tomarlas y darles un buen uso. Producto de estos esfuerzos, nació libgolf.h, una librería que facilita incrustar shellcode a una función ejecutable. Sé lo que estás pensando - "¡esto solo suena como un enlazador horrible!", y puede ser que tengas razón. Sin embargo, lo que resalta aquí es que puedes modificar fácilmente los encabezados *antes* de que el archivo binario sea construido. Echemos un vistazo a cómo funciona. Si quieres jugar en casa también, puedes encontrar el código fuente para todo esto en [0]. Puedes encontrar el código para este artículo en 'examples/01_dead_bytes'. La configuración básica necesita dos archivos: un archivo C y un shellcode.h. En lo que respecta al shellcode, me gusta ir por el viejo confiable 'b0 3c 48 31 ff 0f 05', el cual se desensambla como: mov al, 0x3c @ b0 3c xor rdi, rdi @ 48 31 ff syscall @ 0f 05 (Sí - llamar esto "shellcode" es empujar las cosas) Esencialmente, esto solo invoca exit(0). Está bien porque nos permite fácilmente corroborar que estos bytes se ejecutaron exitosamente con la expanción del shell $?. Arroja esto o cualquier otro shellcode (pero asegurate que es PIC - aún no se soportan símbolos reubicados) en el buffer llamado buf[] en shellcode.h y vayamos de vuelta a nuestro archivo C. Si tu sólo quieres obtener un binario que ejecute tu shellcode, entonces esto es todo lo que necesitas: #include "libgolf.h" #include "shellcode.h" int main(int argc, char **argv) { INIT_ELF(X86_64,64); GEN_ELF(); return 0; } Compilando esto y corriendo el ejecutable resultante generará un archivo .bin - este es tu nuevo ELF! ¿Bastante simple cierto? Pero la simplicidad usualmente está acompañada de lo aburrido, como es el caso aquí, así que hagamos algo más interesante! Antes de continuar, vale la pena explicar que hacen estos dos macros detrás de escenas. Primero INIT_ELF() toma dos argumentos, el conjunto de instrucciones y la arquitectura. Actualmente, LibGolf soporta X86_64, ARM32, y AARCH64 como conjuntos de intrucciones válidos y arquitectura de 32 o 64. Primero configura algunas estructuras internas de control, y decide si utilizar el objeto Elf32_* o Elf64_* para las cabeceras. También de manera automática asigna apuntadores al ELF y las cabeceras del programa, llamada ehdr y phdr respectivamente. Son estos los que ocuparemos para modificar fácilmente los campos. Aparte de eso, también copia el búfer shellcode e ingresa datos al ELF y a las cabeceras del programa antes de calcular un punto de entrada íntegro. Ahora llega GEN_ELF(), el cuál, simplemente imprime algunas estadísticas a stdout y luego escribe las estructuras apropiadas en el archivo .bin. El nombre del .bin lo determina argv[0]. Entonces, después de que se usa el macro INIT_ELF(), tenemos ehdr y phdr disponible para dereferenciar. Supongamos que queremos modificar el campo e_version de la cabecera ELF. Todo lo que debemos hacer es agregar una sola línea: #include "libgolf.h" #include "shellcode.h" int main(int argc, char **argv) { INIT_ELF(X86_64); // Set e_version to 12345678 ehdr->e_version = 0x78563412; GEN_ELF(); return 0; } Compilamos y ejecutamos, y tendremos otro archivo .bin esperandonos. Echa un vistazo a este archivo en xxd, hexyl o tu manipulador de bits preferido, veras un pequeño y hermoso '12 34 56 78' asomándose en el inicio de 0x14, ¿acaso no fue sencillo? Para hacer las cosas un poco más rápidas, me gusta usar el siguiente Makefile: .PHONY golf clean CC=gcc CFLAGS=-I. PROG=golf golf: @$(CC) -o $(PROG) $(PROG).c @./$(PROG) @chmod +x $(PROG).bin @rm $(PROG) $(PROG).bin (Este es el Makefile que encontrarás en el repo [0]) +---------------------------------------+ |--[ Cayendo con el primer obstáculo ]--| +---------------------------------------+ Como muchos sabrán, los parsers de archivos son cosas horribles. Mientras que las especificaciones usualmente tienen objetivos serios, raramente son respetados por aquellos que deberían saber más. Rey de esos blasfemos es el cargador ELF de linux mismo. LibGolf facilita investigar la extensión de estos crímenes contra elf.h. Un buen lugar para comenzar es el comienzo, es decir la cabecera ELF misma. Al comienzo de cualquier archivo ELF está, por supuesto, el conocido 0x7f seguido del ELF, conocido entre sus amigos como EI_MAG0 hasta EI_MAG3. Sin sorprender, modificando cualquiera de estos cuatro bytes resulta en que el cargador Linux rechace el archivo. ¡Gracias a Dios por eso! ¿Qué tal el byte 0x5? Nuestra confiable especificación nos dice que este es el byte EI_CLASS y denota la arquitectura objetivo. Los valores aceptables son 0x01 y 0x02, para 32-bit y 64-bit respectivamente. Lo diré de nuevo: valores aceptables son 0x01 y 0x02. ¿Qué pasa si lo igualamos a 0x58 (o 'X' para los amantes de ASCII)? Podemos hacerlo agregando: (ehdr->e_ident)[EI_CLASS] = 0x58; a nuestro archivo C generado. (¿Por qué 0x58? ¡Porque es claramente identificable en la salida de xxd/hexyl!) Una vez que tenemos nuestro .bin para jugar, antes de intentar ejecutarlo, vamos a intentar un par de herramientas de parseo de ELF para buscar futuros culpables. El primero en la lista es gbd. Adelante, yo te espero. ¿Ves que es lo que pasa? "not in executable format: file format not recognized" ("no esta en formato ejecutable: formato de archivo no reconocido") De igual manera, objdump dará un resultado similar. Parece que estos parsers están haciendo su trabajo adecuadamente. Intentemos ejecutar el binario de manera normal. <revelación>Funciona perfectamente</revelación> Si estás usando mi shellcode de ejemplo, entonces una consulta con $? te informará lleno de arrepentimiento que el binario se ejecutó exitosamente. Los mismos crímenes se cometen asignando valores ilegales a EI_DATA y EI_VERSION. +------------------------------------------+ |--[ Llevando la corrupción hasta el 11 ]--| +------------------------------------------+ Entonces, ¿qué tan lejos podemos llegar? ¿Qué tanto del ELF y de las cabeceras del programa va a ignorar el cargador de Linux? Ya convertimos EI_CLASS, EI_DATA y EI_VERSION, pero resulta que EI_OSABI es ignorado. Eso nos lleva hasta el offset 0x8. De acuerdo con las especificaciones, los siguientes serían EI_ABIVERSION y EI_PAD los cuales, juntos, nos llevarían hasta el byte 0xf. Pero tal parece que a nadie le importan, así que podemos asignar todos ellos con 0x58 sin temor. Yendo más adelante, nos encontramos con un campo que parece resistirse a ser modificado: e_type. Entendiblemente, el cargador de Linux no le gusta si no le decimos que tipo de archivo ELF le estamos proveyendo (¡es bueno saber que sí tiene *algunos* estándares!). Nosotros necesitamos que esos dos bytes se queden 0x0002 (o ET_EXEC para los acĺitos de elf.h). A continuación está otro byte quisquilloso, en el muy conocido offset 0x12: e_machine, el cual designa el conjunto de instrucciones objetivo. En lo que a nosotros concierne, en la especificación X86_64 corresponde al primer argumento de INIT_ELF(), este byte ya ha sido llenado con 0x3e por nosotros mediante LibGolf. ¡Repentinamente, un e_version salvaje apareció! Nos enfrentamos a otro disidente, el cuál supuestamente siempre debería de ser los bytes 0x00000001. Sin embargo, en practica, nadie parece estar interesado, así que asignemosle el valor 0x58585858. Siguiendo esta cadena de herejes, tenemos un par de campos importantes que parecen resistirse a ser corrompidos; e_entry y e_phoff. Estoy seguro que no debo entrar en mucho detalle acerca de e_entry; es el punto de entrada del binario, donde la ejecución arranca una vez que las secciones de carga están en memoria. Mientras que uno puede esperar que el cargador pueda arreglárselas sin saber cual es el offset a las cabeceras del programa, parecece que no es tan inteligente como para arreglárselas sin tener que ser llevado de la mano. Mejor dejemos esos dos en paz. LibGolf aún no tiene soporte para secciones de cabecera (y dado su uso en producir *pequeños* binarios, es poco probable que los soporte en el futuro). Esto significa que, enfrentados con cualquier cabecera relacionada a ellos, podemos falsearlo con lo que dicte nuestro corazón. Eso incluye e_shoff, e_shentsize, eh_shnum e incluso e_shstrndx. ¡Si nosotros no tenemos ninguna sección de cabeceras, no podemos ser culpable de corromperlas! Los siguientes campos que parecen ser importantes para el cargador de Linux son e_ehsize, e_phentsize, y e_phnum. De nuevo, esto no es una sorpresa, al ver que se relacionan con cargar el único segmento cargable en memoria antes de entregar el control. Para refrescar la memoria, e_ehsize es el tamaño de la cabecera ELF (la cual es 0x34 o 0x40 para 32- y 64-bit respectivamente), eh_phentsize es el tamaño de la siguiente cabecera del programa (de nuevo, asignado a 0x20 o 0x38 para arquitecturas de 32- y 64-bit). Si el cargador hubiera sido un poco más meticuloso con EI_CLASS, no hubiese necesitado estos dos campos. Finalmente, e_phnum es sólo el número de entradas en la cabecera del programa - para nosotros siempre es 0x1. Sin duda, este es usado en algún ciclo en la rutina cargadora de memoria, pero no he investigado a fondo aún. Aún queda un campo en la cabecera ELF que no hemos tocado, el cual es e_flags. La razón es sencilla, depende de la arquitectura. Para x86_64, no importa para nada porque es indefinido (¡aunque *es* importante para algunas plataformas ARM! Echa un vistazo al ejemplo arm32 en [0]). De esta manera terminamos con la cabecera ELF. Para aquellos que perdieron el conteo, poco más del 50% de la cabecera ELF es ignorada por el cargador. Pero, ¿qué pasa con la cabecera del programa? Resulta que las cabeceras del programa tiene menos margen de maniobra, pero no por las razones que uno esperaría. De hecho, *cualquier* corrupción en las cabeceras del programa no va a afectar al cargador de Linux. Nosotros pordiamos llenarla toda con nuestro confiable 0x58, y al cargador no le importaría. ¡Pero ten cuidado, valiente aventurero, manipula el byte incorrecto y terminarás en el calabozo de la segmentación defectuosa! Entonces, ¿existe algo susceptible a coerción en la cabecera del programa? Parece que hay dos campos que, no por errores propios, simplemente porque actualmente son irrelevantes: p_paddr y p_align. El primero fue importante durante los embriagadores días antes de la memoria virtual, cuando 4GB de RAM no era más que un sueño y por lo tanto, era importante informar al cargador el segmento de memoria físico que debía ser cargado. La alineación de memoria es un caso divertido. Supuestamente, p_vaddr esta destinado a igualar p_offset modulo p_align. Archivos ELF "adecuados" (al menos los compilados con GCC) parece que solo asignan p_offset igual a p_vaddr y siguen adelante. Esto también es lo que hace LibGolf por defecto y asigna p_align de ¡manera totalmente superflua! Considerando todo, no tan divertido como la cabecera ELF, pero aún ofrece algunas ganancias. El binario generando archivos C ahora se ve así: #include "libgolf.h" #include "shellcode.h" int main(int argc, char **argv) { INIT_ELF(X86_64,64); /* * Rompe las herramientas de análisis estático comunes como gdb y objdump */ (ehdr->e_ident)[EI_CLASS] = 0x58; // Arquitectura (ehdr->e_ident)[EI_DATA] = 0x58; // Endianness (ehdr->e_ident)[EI_VERSION] = 0x58; // Supuestamente, siempre 0x01 (ehdr->e_ident)[EI_OSABI] = 0x58; // Sistema Operativo objetivo // Ciclo para el resto de e_indent int i; for ( i = 0 ; i < 0x10 ; i++ ) (ehdr->e_ident)[i] = 0x58; ehdr->e_version = 0x58585858; // Supuestamente, siempre 0x00000001 // ¿Cabeceras de sección? ¡Nosotros no necesitamos apestosas cabeceras de sección! ehdr->e_shoff = 0x5858585858585858; ehdr->e_shentsize = 0x5858; ehdr->e_shnum = 0x5858; ehdr->e_shstrndx = 0x5858; ehdr->e_flags = 0x58585858; // x86_64 no tiene banderas definidas phdr->p_paddr = 0x5858585858585858; // La dirección física es ignorada phdr->p_align = 0x5858585858585858; // p_vaddr = p_offset, así que es irrelevante GEN_ELF(); return 0; } Si compilas y corres el programa obtienes el siguiente binario: 00000000: 7f45 4c46 5858 5858 5858 5858 5858 5858 .ELFXXXXXXXXXXXX 00000010: 0200 3e00 5858 5858 7800 4000 0000 0000 ..>.XXXXx.@..... 00000020: 4000 0000 0000 0000 5858 5858 5858 5858 @.......XXXXXXXX 00000030: 5858 5858 4000 3800 0100 5858 5858 5858 XXXX@.8...XXXXXX 00000040: 0100 0000 0500 0000 0000 0000 0000 0000 ................ 00000050: 0000 4000 0000 0000 5858 5858 5858 5858 ..@.....XXXXXXXX 00000060: 0700 0000 0000 0000 0700 0000 0000 0000 ................ 00000070: 5858 5858 5858 5858 b03c 4831 ff0f 05 XXXXXXXX.<H1... Este archivo tiene 127 bytes de tamaño, pero somos capaces de reemplazar un total de 50 bytes con 'X', lo que significa que ¡menos del 40% de este binario es ignorado por el cargador ELF de Linux¡ ¿Cuánto se puede hacer con 50 bytes? Resulta que - bastante. Hace algunos años una investigación increíble de netspooky demostró como uno puede apilar porciones de una cabecera de programa dentro de una cabecera ELF. Combinado con almacenar tu shellcode dentro de una de estas regiones de bytes muertos, y algunos otros trucos, es posible reducir un ELF hasta solo 84 bytes - una reducción del 34% con los mejores esfuerzos de LibGolf. Te encamino a esta dirección en la increíble serie "ELF Mangling" en [1]. Otro aspecto interesante de estas técnicas se pasa por alto fácilmente. Aunque el cargador de Linux parece importarle poco la estructura de un ELF más allá de lo que necesita para obtener el código máquina, otras herramientas son más quisquillosas. Nosotros ya vimos objdump y gdb, pero muchas de las soluciones AV también fallan al encarar un ELF corrupto. En mi investigación, el único producto que (más o menos) lo logra es ClamAV, con un resultado positivo para "Heuristics.Broken.Executable". Por supuesto, análisis dinámico es aun la apuesta de todos. +--------------------------+ |--[ Siguiendo adelante ]--| +--------------------------+ ¡x86_64 no es el único conjunto de instrucciones soportado por LibGolf! También se puede usar para construir pequeños ejecutables para plataformas ARM32 y AARCH64. En el repositorio en GitHub [0], encontrarás algunos ejemplos para ambas plataformas ARM (incluyendo los bytes muertos de este artículo). ¡Pero al diablo los ejemplos! Ojalá la mayoría de los que llegaron hasta acá quieran echar un vistazo a libgolf.h. Como mencioné al principio, todo esto comenzó como un ejercicio de aprendizaje, así que puse especial atención en comentar el código con tanto detalle como me fue posible. +---------------------------------------+ |--[ Una nota sobre la repicabilidad ]--| +---------------------------------------+ Durante esta investigación, yo testé principalmente en Ubuntu 20.04 con kernel 5.4.0-65-generic, pero también verifiqué que los mismos resultados se obtuvieran en 5.11.11-arch1-1. He escuchado que algunas cosas extrañas pueden suceder en los kernel WSL, pero no lo he investigado - ¡tal vez tú puedas hacerlo! +-----------------------+ |--[ Agradecimientos ]--| +-----------------------+ ¡Un "hurra" especial a todos en Thugcrowd, Symbolcrash, y el grupo de soporte de The Mental ELF! +-------------------+ |--[ Referencias ]--| +-------------------+ [0] https://www.github.com/xcellerator/libgolf [1] https://n0.lol/ebm/1.html