Infección de procesos en Linux
Tabla de contenidos
Entre las distintas actividades que debe llevar a cabo un Red Team, hay una que destaca por su artesanía: la introducción de un APT en un sistema y asegurar su persistencia. Desgraciadamente, la mayor parte de estos mecanismos de persistencia se basan en mantener copias de un archivo ejecutable en distintas ubicaciones, con uno o más mecanismos de activación (scripts de arranque del Shell, alias, enlaces, otros scripts de arranque del sistema, etc), por lo que el experto en seguridad del Blue Team o un equipo de Threat hunting sólo tendría que ubicar una copia del fichero y analizarla cómodamente desde su equipo.
Aunque el experto en seguridad tarde o temprano acabará encontrando lo que busca, sí que se pueden llevar a cabo distintas técnicas que dificultan (o al menos retrasan) la detección del APT en la máquina infectada. En esta serie de posts explicaremos un nuevo mecanismo de persistencia basado no en el sistema de ficheros, sino en el árbol de procesos.
Prerequisitos
Esta técnica se llevará a cabo en un sistema GNU/Linux para x86-64, aunque la filosofía de la misma se podría llevar a cabo en cualquier sistema operativo con una API de depuración más o menos completa. Los requisitos serán mínimos: con una versión relativamente moderna de GCC debería ser suficiente.
Usando el espacio de direcciones de otros procesos como almacén
La idea central de esta técnica es aprovechar el espacio de direcciones de los procesos no privilegiados como almacén inyectando dos hilos en el proceso víctima: el primer hilo intentará infectar el resto de procesos, mientras que el segundo contendrá el payload que asegurará la persistencia de cierto fichero ejecutable en el sistema. Si el fichero es borrado, se restaurará con otro nombre.
Evidentemente, esta técnica está fuertemente limitada por el uptime de la máquina, por lo que debería utilizarse sobre todo en sistemas que no se pueden reiniciar fácilmente. En otros sistemas podría verse como un mecanismo de persistencia complementario.
Dando forma a la inyección
Es obvio que una de las fases críticas de esta técnica es la propia inyección del código. Como a priori se desconoce dónde se va a ubicar ese código en el espacio de direcciones de la víctima, este deberá ser PIC (position-independent code). Esto sugiere casi inmediatamente el uso de una biblioteca dinámica, ya que estas se pueden copiar en memoria prácticamente “tal cual”. Sin embargo, esto tiene ciertos puntos en contra:
- Gran parte de la información inyectada serán metadatos (cabeceras y demás)
- El código necesario para procesar y cargar la biblioteca, aunque no es especialmente complejo, no será despreciable respecto del tamaño del payload.
- Las bibliotecas compartidas utilizan un formato ampliamente conocido, y harían del código fácilmente analizable.
Lo ideal sería hacerlo lo más pequeño posible: un par de páginas de código, alguna página de datos, el payload y a volar. Todo esto sería posible con un script del linker. Sin embargo, para esta prueba de concepto, usar una biblioteca como “primer contenedor” será suficiente.
Otra restricción a tener en cuenta es que el proceso objetivo no tiene por qué haber sido cargado desde un ejecutable dinámico (y, por lo tanto, la biblioteca estándar de C puede no estar presente). Además, hacer la resolución de símbolos de una biblioteca compartida a mano es un proceso bastante tedioso, también depende de la ABI y puede ser poco transportable. Esto quiere decir que se tendrán que reescribir muchas de las funciones de la biblioteca estándar de C a mano.
Además, la inyección estará basada en la llamada al sistema ptrace. Si no se cuenta con los suficientes privilegios (o esta funcionalidad está restringida en la propia configuración del sistema), esta técnica simplemente no funcionará.
Y por último, el uso de memoria dinámica. El uso de memoria dinámica involucra modificar el heap. En general, no es deseable dejar trazas demasiado evidentes en el espacio de direcciones del ejecutable. Se intentará restringir el uso de memoria dinámica lo máximo posible.
Hoja de ruta
Esta pequeña prueba de concepto funcionará de la siguiente manera:
- La biblioteca tendrá dos puntos de entrada. La localización de estos puntos de entrada se sabrá de antemano programáticamente (ya que estarán a una distancia fija desde el principio del ejecutable). Estos puntos de entrada se corresponderán con el inicio del código de los dos hilos que inyectaremos (el de infección y el de persistencia).
- El hilo de infección listará todos los procesos del sistema, localizando aquellos que sean potencialmente atacables.
- Se intentará hacer un PTRACE_SEIZE a cada proceso, y se determinará si este no está infectado. Si tiene éxito, se lleva a cabo la inyección.
- Para preparar el espacio de direcciones objetivo, se deben inyectar llamadas al sistema. Estas llamadas deberán hacer la reserva de memoria necesaria para llevar a cabo la inyección.
- Ejecutar los hilos y dejar el proceso funcionando como estaba.
Cada una de estas fases requiere una cuidadosa preparación que se detallarán a continuación.
Preparando el entorno
Para mantener el código lo más simple posible, se partirá de un pequeño programa en C que se compilará como biblioteca y que servirá de esqueleto del programa inyectado. Adicionalmente, para poder hacer pruebas mientras el programa no es autónomo, se escribirá también un pequeño programa en C que ejecutará estos dos puntos de entrada manualmente. Para hacer el desarrollo un poco más cómodo, se partirá también de un pequeño Makefile donde se recogerán las reglas de compilación.
Para la biblioteca inyectable, se partirá de la siguiente plantilla:
void persist(void) { /* Implement me */ } void propagate(void) { /* Implement me */ }
El programa que hará la ejecución inicial de esta biblioteca se llamará spawn.c y tendrá esta otra pinta:
#include <stdio.h> #include <stdlib.h> #include <dlfcn.h> int main(int argc, char *argv[]) { void *handle; void (*entry)(void); if (argc != 3) { fprintf(stderr, "Usage\n%s file symbol\n", argv[0]); exit(EXIT_FAILURE); } if ((handle = dlopen(argv[1], RTLD_NOW)) == NULL) { fprintf(stderr, "%s: failed to load %s: %s\n", argv[0], argv[1], dlerror()); exit(EXIT_FAILURE); } if ((entry = dlsym(handle, argv[2])) == NULL) { fprintf(stderr, "%s: symbol `%s' not found in %s\n", argv[0], argv[2], argv[1]); exit(EXIT_FAILURE); } printf("Symbol `%s' found in %p. Jumping to function...\n", argv[2], entry); (entry) (); printf("Function returned!\n"); dlclose(handle); return 0; }
Por último, el Makefile que compilará ambos programas será el siguiente:
CC=gcc INF_CFLAGS=--shared -fPIE -fPIC -nostdlib all : injectable.so spawn injectable.so : injectable.c $(CC) $(INF_CFLAGS) injectable.c -o injectable.so spawn : spawn.c $(CC) spawn.c -o spawn -ldl
Y bastará con ejecutar make para compilarlo todo cada vez:
% make (…) % ./spawn ./injectable.so propagate Symbol `propagate' found in 0x7ffff76352ea. Jumping to function... Function returned!
Llamadas al sistema
Echándole un vistazo al Makefile anterior se puede ver que injectable.so se está compilando con -nostdlib (esto era un requisito) por lo que se echará en falta, entre muchas otras cosas, la interfaz en C de las llamadas al sistema. Para solucionar esta carencia, se necesitará escribir un conjunto wrappers en C e inline assembly capaces de interactuar con el sistema operativo.
Por regla general, las llamadas al sistema en Linux en x86-64 se hacen mediante la instrucción syscall (a diferencia de sistemas x86, donde se utilizaba la int 0x80 en su lugar). La filosofía a grandes rasgos es la misma: antes de llamar a syscall se inicializan los registros con los distintos parámetros de la llamada. El contenido de %rax se inicializa con el identificador de llamada al sistema, y los argumentos de la misma se pasan siguiendo el orden %rdi, %rsi, %rdx, %r10, %r8, %r9. El valor de retorno de la llamada al sistema se almacena en %rax, y los errores se señalizan con un valor negativo (que se corresponde con el código de error cambiado de signo). Así pues, un “hola mundo” en ensamblador utilizando la llamada al sistema write() podría tener el siguiente aspecto:
movq $1, %rax // La llamada a write tiene el código 1 movq $1, %rdi // Primer argumento: descriptor de fichero (1 = stdout) leaq %rip(saludo), %rsi // Segundo argumento: dirección de la cadena movq $11, %rdx // Tercer argumento: tamaño (“Hola mundo\n” mide 11 bytes) syscall // Todo listo, se llama al sistema […] saludo: .ascii "Hola mundo\n"
Introducir este código en un programa en C es muy fácil gracias al inline assembly de GCC, y se puede hacer en una línea. Un wrapper de write en C se puede resumir en:
#include <unistd.h> #include <syscall.h> ssize_t write(int fd, const void *buffer, size_t size) { size_t result; asm volatile(“syscall” : “=a” (result) : “a” (__NR_write), “S” (fd), “D” (buffer), ”d” (size); return result; }
Los valores pasados después de “syscall” indican cómo se deben inicializar los registros antes de ejecutar el código en ensamblador especificado. En este caso, %rax (especificador “a”) se inicializa con el valor de __NR_write (el código de la llamada al sistema write, especificado en syscall.h), %rdi (especificador “D”) con buffer y %rsi (especificador “S”) con size. El valor devuelto por la llamada al sistema se recoge en %rax (especificador, “=a”, el signo igual indica que “result” es una variable de sólo escritura, y que el compilador no se debe preocupar por su valor inicial).
Por conveniencia, se puede implementar strlen (siguiendo el prototipo de string.h) para tener un mecanismo con el que medir la longitud de cadenas de caracteres:
size_t strlen(const char *buffer) { size_t len = 0; while (*buffer++) ++len; return len; }
Lo cual permite definir la siguiente macro:
#define puts(string) write(1, string, strlen(string))
Y los cuales deberían producir una salida como la siguiente:
% ./spawn ./injectable.so persist Symbol `persist' found in 0x7f3eb58403be. Jumping to function... This is persist() Function returned! % ./spawn ./injectable.so propagate Symbol `propagate' found in 0x7fb8874403db. Jumping to function... This is propagate() Function returned!
Con esto se habría superado el primer escollo al desarrollo de este programa: a partir de aquí, todo se reducirá a escribir wrappers y funciones que sean consistentes con los prototipos de las cabeceras de la biblioteca estándar de C a medida que los vayamos necesitando.
Enumerando procesos
El siguiente paso consistirá en buscar procesos donde podamos introducir nuestro código. Para hacer esto podemos utilizar dos mecanismos:
- Accedemos a /proc y listamos todos los directorios o
- Enumeramos todos los PIDs del sistema enviándoles una señal 0, desde el 2 hasta cierto valor PID_MAX.
Aunque el método 1 parece el más rápido, es al mismo tiempo el más complejo de todos, ya que:
- No es obligatorio que /proc se haya montado o siquiera exista.
- Linux no tiene una llamada al sistema opendir / readdir, sino open / getdents. gentdents devuelve un buffer con una lista de estructuras de tamaño variable que debe procesarse manualmente.
- Los nombres de ficheros deben convertirse a número entero también manualmente. Debido a que no disponemos de las funciones de biblioteca, esta conversión ha de programarse también a mano.
El segundo método, aunque aparentemente es más lento, funciona en prácticamente cualquier sistema operativo. La idea detrás de esta técnica es que cuando se le pide a kill que envíe a cierto proceso la señal 0, esta devuelve un valor exitoso (no negativo) si se le pueden enviar señales a tal proceso, lo cual sucede cuando el propietario de dicho proceso es el mismo que el propietario del proceso que llama a kill (o cuando este último es root).
La única incertidumbre viene de que distintos sistemas tienen distintos valores de PID_MAX. En la práctica, la inmensa mayoría de sistemas tienen el PID_MAX establecido a 32768. La llamada a kill es muy rápida cuando no se envía ninguna señal, por lo que recorrer cerca de 33000 llamadas es un gasto de tiempo asumible.
Para utilizar esta última técnica, necesitaremos añadir un wrapper para kill y recorrer un bucle. Afortunadamente, kill es una llamada al sistema bastante simple:
int kill(pid_t pid, int sig) { int result; asm volatile("syscall" : "=a" (result) : "a" (__NR_kill), "D" (pid), "S" (sig)); return result; }
Llegados a este punto, es interesante incluir una función para escribir números en decimal:
void puti(unsigned int num) { unsigned int max = 1000000000; char c; unsigned int msd_found = 0; while (max > 0) { c = '0' + num / max; msd_found |= c != '0' || max == 1; if (msd_found) write(1, &c, 1); num %= max; max /= 10; } }
Y sólo queda modificar la función propagate() para llevar a cabo esta enumeración.
void propagate(void) { pid_t pid; for (pid = 2; pid < PID_MAX; ++pid) if (kill(pid, 0) >= 0) { puts("Process found: "); puti(pid); puts("\n"); } }
Tras compilar y ejecutar se tiene el siguiente resultado:
% ./spawn ./injectable.so propagate Process found: 1159 Process found: 1160 Process found: 1166 Process found: 1167 Process found: 1176 Process found: 1324 Process found: 1328 Process found: 1352 …
Para un sistema GNU/Linux de escritorio, es común encontrar más de una centena de procesos de usuario a los que se les pueda enviar una señal. Es decir, se tiene más de una centena de posibles objetivos de infección.
Intentando un PTRACE_SEIZE
Este es el principal punto débil de esta de la infección: algunos de los procesos enumerados a través de kill no se podrán depurar debido a restricciones de acceso (por ejemplo, cuando se tratan de procesos setuid). Una llamada a ptrace con PTRACE_SEIZE sobre cada proceso encontrado nos servirá para determinar si el proceso es depurable o no.
Aunque lo primero que se pasa por la cabeza a la hora de depurar un proceso es utilizar el comando PTRACE_ATTACH, esta técnica tiene la desventaja de que, en caso de éxito, el proceso objetivo quedaría parado hasta que se llamase a PTRACE_CONT. Esto podría causar efectos colaterales en la ejecución del proceso (especialmente en procesos sensibles al timing) que podrían levantar sospechas por parte del usuario. El comando PTRACE_SEIZE, introducido en Linux 3.4, sirve para exactamente lo mismo sin tener que detener el proceso.
Como según la libc ptrace es una función variádica, puede ser conveniente simplificar el wrapper de ptrace de forma que este espere siempre 4 argumentos, definiéndolos o no en función del tipo de comando ejecutado:
long ptrace4(int request, pid_t pid, void *addr, void *data) { long result; register void* r10 asm("r10") = data; asm volatile("syscall" : "=a" (result) : "a" (__NR_ptrace), "S" (pid), "D" (request), "d" (addr)); return result; }
Modificando propagate para hacer uso de ptrace4:
void propagate(void) { pid_t pid; int err; for (pid = 2; pid < PID_MAX; ++pid) if (kill(pid, 0) >= 0) { puts("Process found: "); puti(pid); puts(": "); if ((err = ptrace4(PTRACE_SEIZE, pid, NULL, NULL)) >= 0) { puts("seizable!\n"); ptrace4(PTRACE_DETACH, pid, NULL, NULL); } else { puts("but cannot be debugged : ( [errno="); puti(-err); puts("]\n"); } } }
Se podrá listar qué procesos son en realidad depurables, los cuales suelen ser la mayoría en una instalación de GNU/Linux normal y corriente.
Conclusiones (por ahora)
Las pruebas anteriores nos dan una primera idea de la viabilidad de esta técnica. A partir de aquí, todo lo que se va a hacer no difiere demasiado en lo que puede hacer cualquier depurador, aunque de forma automatizada. En la siguiente entrega se verá cómo secuestrar una llamada al sistema del proceso víctima para inyectar llamadas al sistema de forma remota. Estas llamadas remotas se utilizarán para crear las distintas páginas donde se alojará el código inyectado.
Descubre nuestro trabajo y nuestros servicios de ciberseguridad en www.tarlogic.com/es/
En TarlogicTeo y en TarlogicMadrid.
Fe de erratas
- Cambio del comando
./spawn injectable.so propagate
por./spawn ./injectable.so propagate
para evitar una resolución de la ruta no deseada basada en las rutas de búsqueda de bibliotecas del sistema.