_ .-') _ ('-. ('-. _ .-') _ .-. .-') .-') _ ('-. .-')
( ( 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