Guía definitiva sobre la inyección de procesos en Linux
Introducción
Las técnicas de inyección de procesos son una parte importante del conjunto de herramientas de un atacante. Pueden permitir a los atacantes ejecutar código malicioso dentro de un proceso legítimo para evitar la detección o colocar un interceptor (hook) en procesos remotos para modificar su comportamiento.
El tema de la inyección de procesos en equipos Windows ha sido ampliamente investigado y existe un conocimiento relativamente bueno del mismo. En el caso de los equipos Linux, este no es exactamente el caso. Aunque ya se han publicado magníficos recursos sobre el tema, el conocimiento de las diferentes técnicas de inyección en Linux parece ser relativamente bajo, especialmente si se compara con Windows.
Nos hemos inspirado en una descripción general sobre la inyección de procesos de Windows escrita por Amit Klein e Itzik Kotler de SafeBreach, y nuestro objetivo es proporcionar una documentación completa de la inyección de procesos en Linux. Nos centraremos en las técnicas de "verdadera inyección de procesos", dirigidas a los procesos activos en ejecución. Esto significa que excluiremos los métodos que requieran modificar el binario en el disco, la ejecución del proceso con variables de entorno específicaso abusar del proceso de carga.
Describiremos las características del SO que facilitan la inyección de procesos en Linux, y las diferentes primitivas de inyección que permiten. Abordaremos técnicas que ya se han descrito antes y resaltaremos las variantes de inyección que no se han documentado previamente. Finalizaremos cubriendo estrategias de detección y mitigación para las técnicas reseñadas.
Además de este artículo del blog, estamos lanzando un repositorio que contiene un conjunto completo de código de prueba de concepto (PoC) para los diferentes métodos y primitivas de inyección descritos en la entrada. Estas pruebas de concepto inocuas tienen por objeto ayudar a entender cómo podría producirse una implementación maliciosa de las técnicas, lo que puede ayudar a desarrollar y probar las capacidades de detección. Para obtener más información, consulte el archivo README.
La inyección en Linux frente a la inyección en Windows
El número de técnicas de inyección conocidas en equipos Windows es enorme y sigue creciendo, de las colas de APC y las transacciones NTFS a tablas atom y conjuntos de subprocesos. Windows expone muchas interfaces que permiten a los atacantes interactuar con procesos remotos, así como realizar ataques de inyección.
La situación es muy diferente en el ámbito de Linux. La interacción con procesos remotos se limita a un pequeño conjunto de llamadas al sistema y muchas de las características que facilitan la inyección en equipos Windows no se dan en este contexto. No existen API para asignación de memoria en un proceso remoto o modificación de la protección de memoria de forma remota.Definitivamente tampoco para crear subprocesos remotos.
Esta diferencia afecta a la estructura del ataque de inyección. En Windows, la inyección de procesos normalmente consta de tres pasos: asignar → escribir → ejecutar. En primer lugar, asignamos memoria en el proceso remoto que se utilizará para almacenar nuestro código, luego escribimos nuestro código en esta memoria y, finalmente, lo ejecutamos.
Con Linux, carecemos de la capacidad para realizar el primer paso: la asignación. No hay forma directa de asignar memoria en un proceso remoto. Por ello, el caudal de inyección será ligeramente diferente: sobrescribir → ejecutar → recuperar. Sobrescribimos memoria existente en el proceso remoto con nuestra carga, lo ejecutamos y, a continuación, recuperamos el estado anterior del proceso para permitir que continúe ejecutándose con normalidad.
Métodos de interacción de procesos remotos
En Linux, la interacción con la memoria de los procesos remotos se limita a tres métodos principales: ptrace, procfsy process_vm_writev. En las siguientes secciones se proporcionan breves descripciones de cada uno de ellos.
ptrace
ptrace es una llamada al sistema que se utiliza para depurar procesos remotos. El proceso de inicio puede inspeccionar y modificar la memoria y los registros de procesos depurados. Los depuradores, como GDB, se implementan usando ptrace para controlar el proceso que está siendo depurado.
ptrace soporta diferentes operaciones, que se especifican mediante un código de solicitud ptrace . Algunos ejemplos destacados incluyen PTRACE_ATTACH (que se adjunta a un proceso), PTRACE_PEEKTEXT (que lee de la memoria del proceso) y PTRACE_GETREGS (que recupera los registros del proceso). El fragmento de código 1 muestra un ejemplo de uso de ptrace.
// Attach to the remote process
ptrace(PTRACE_ATTACH, pid, NULL, NULL);
wait(NULL);
// Get registers state
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, NULL, ®s);
Fragmento de código 1: Ejemplo de uso de ptrace para recuperar los registros de un proceso remoto.
procfs
procfs es un pseudosistema de archivos especial que actúa como interfaz para ejecutar procesos en el sistema. Se puede acceder al mismo a través del directorio /proc (Figura 1).
Cada proceso se representa como un directorio, que recibe su nombre según su PID. En este directorio podemos encontrar archivos que proporcionan información sobre el proceso. Por ejemplo, el archivo cmdline contiene la línea de comandos de proceso, el archivo environ contiene las variables de entorno de proceso, etc.
procfs también nos proporciona la capacidad de interactuar con la memoria de procesos remotos. Dentro de cada directorio de procesos encontraremos el archivo mem, un archivo especial que representa todo el espacio de direcciones del proceso. Acceder al archivo mem de un proceso en una posición específica es equivalente a acceder a la memoria del proceso en la misma dirección.
En el ejemplo de la figura 2, usamos la herramienta xxd para leer 100 bytes desde el archivo mem del proceso, comenzando en una posición específica.
Si inspeccionamos la misma dirección en la memoria mediante GDB, notaremos que el contenido es idéntico (Figura 3).
El archivo maps es otro archivo interesante que se puede encontrar en el directorio de procesos (Figura 4). Este archivo contiene información sobre las diferentes regiones de memoria del espacio de direcciones de proceso, incluidos sus intervalos de direcciones y permisos de memoria.
En las próximas secciones, veremos cómo la capacidad de identificar regiones de memoria con permisos específicos puede ser muy útil.
process_vm_writev
El tercer método para interactuar con la memoria de procesos remotos es la llamada al sistema process_vm_writev . Esta llamada al sistema permite escribir datos en el espacio de direcciones de un proceso remoto.
process_vm_writev recibe un puntero a un búfer local y copia su contenido en una dirección especificada dentro del proceso remoto. El fragmento de código 2 muestra un ejemplo de process_vm_writev en uso.
// Initialize local and remote iovec structs used to perform the syscall
struct iovec local[1];
struct iovec remote[1];
// Place our data in the local iovec
local[0].iov_base = data;
local[0].iov_len = data_len;
// Point the remote iovec to the address in the remote process
remote[0].iov_base = (void *)remote_address;
remote[0].iov_len = data_len;
// Write the local data to the remote address
process_vm_writev(pid, local, 1, remote, 1, 0);
Fragmento de código 2: Uso de process_vm_writev para escribir datos en un proceso remoto.
Escritura de código en un proceso remoto
Ahora que conocemos los diferentes métodos para interactuar con otros procesos, veamos cómo se pueden utilizar para realizar la inyección de código. El primer paso del ataque de inyección será escribir nuestro código de shell en la memoria del proceso remoto. Como mencionamos anteriormente, en Linux no existe una forma directa de asignar nueva memoria en un proceso remoto. Esto significa que no podemos crear una nueva sección de memoria y tendremos que utilizar la memoria existente del proceso de destino.
Para que nuestro código pueda ejecutarse, tendremos que escribirlo en una región de memoria con permisos de ejecución. Podemos encontrar dicha región analizando el archivo maps de procfs mencionado anteriormente e identificando una región de memoria con permisos de ejecución (x) (Figura 5).
Hay dos tipos de regiones ejecutables que podemos encontrar: modificable y no modificable. Las siguientes secciones muestran cuándo y cómo se puede utilizar cada una de ellas.
Escritura de código en la memoria RX
Aplicable a: ptrace, mem de procfs
Lo ideal sería identificar una región de memoria con permisos de escritura y ejecución, lo que nos permitiría escribir nuestro código y ejecutarlo. En realidad, la mayoría de los procesos no tendrán una región con dichos permisos, ya que se considera una mala práctica asignar la memoria WX. En su lugar, normalmente estaremos limitados a permisos de lectura y ejecución.
Curiosamente, resulta que esta limitación puede ser subvertida usando dos de los métodos que acabamos de describir: ptrace y mem de procfs. Ambos mecanismos se implementan de forma que les permite omitir los permisos de memoria y escribir en cualquier dirección, incluso sin permisos de escritura. Puede encontrar más información sobre este comportamiento para procfs en esta entrada de blog..
Esto significa que, independientemente de los permisos de escritura, siempre podemos utilizar ptrace o mem de procfs para escribir nuestro código en una región de memoria ejecutable remota.
ptrace
Para escribir nuestra carga en un proceso remoto podemos utilizar las solicitudes ptrace POKETEXT o POKEDATA. Estas solicitudes idénticas permiten escribir una palabra de datos en la memoria del proceso remoto. Al llamarlas repetidamente, podemos copiar toda nuestra carga en la memoria del proceso de destino. El fragmento de código 3 muestra un ejemplo de esto.
ptrace(PTRACE_ATTACH, pid, NULL, NULL);
wait(NULL);
// write payload to remote address
for (size_t i = 0; i < payload_size; i += 8, payload++)
{
ptrace(PTRACE_POKETEXT, pid, address + i, *payload);
}
Fragmento de código 3: Uso de ptrace POKETEXT para escribir nuestra carga en la memoria de procesos remotos.
mem procfs
Para escribir nuestra carga en un proceso remoto mediante procfs, simplemente necesitamos escribirla en el archivo mem en el desplazamiento correcto. Cualquier cambio que se realice en el archivo mem se aplica a la memoria de proceso. Para realizar estas operaciones, podemos utilizar las API de archivo normales (fragmento de código 4).
// Open the process mem file
FILE *file = fopen("/proc/<pid>/mem", "w");
// Set the file index to our required offset, representing the memory address
fseek(file, address, SEEK_SET);
// Write our payload to the mem file
fwrite(payload, sizeof(char), payload_size, file);
Fragmento de código 4: Uso del archivo mem de procfs para escribir datos en una memoria de proceso remota.
Escritura de código en la memoria WX
Aplicable a: ptrace, mem de procfs, process_vm_writev
Como hemos comentado anteriormente, tanto ptrace como mem de procfs omiten los permisos de memoria y nos permiten escribir nuestro código en regiones de memoria no modificables. Sin embargo, con process_vm_writev, este no es el caso. process_vm_writev se adhiere a los permisos de memoria y, por lo tanto, solo nos permite escribir datos en regiones de memoria de escritura.
Debido a esto, nuestra única opción es buscar regiones de escritura. No todos los procesos contendrán dichas regiones, pero sin duda podemos encontrar unas que sí lo hagan.
El comando del fragmento de código 5 explorará el archivo maps de todos los procesos del sistema e identificará las regiones con permisos de escritura y ejecución (Figura 6).
find /proc -type f -wholename "*/maps" -exec grep -l "wx" {} +
Fragmento de código 5: Uso del comando "find" para identificar procesos con regiones de memoria de escritura y ejecución.
Después de identificar dicha región, podemos usar process_vm_writev para escribir nuestro código en ella (fragmento de código 6).
// Initialize local and remote iovec structs used to perform the syscall
struct iovec local[1];
struct iovec remote[1];
// Place our payload in the local iovec
local[0].iov_base = payload;
local[0].iov_len = payload_len;
// Point the remote iovec to the address of our wx memory region
remote[0].iov_base = (void *)wx_address;
remote[0].iov_len = payload_len;
// Write the local data to the remote address
process_vm_writev(pid, local, 1, remote, 1, 0);
Fragmento de código 6: Uso del proceso process_vm_writev para escribir una carga en una región remota de WX.
Secuestro del flujo de ejecución remota
Después de escribir nuestro código en la memoria de procesos remotos, tendremos que ejecutarlo. En las próximas secciones, describiremos las diferentes técnicas que podemos utilizar para lograrlo.
Nuestra investigación se centró en equipos amd64. Pueden aplicarse algunas pequeñas diferencias a otras arquitecturas, pero los conceptos generales deben seguir siendo los mismos.
Modificación del puntero de instrucción de proceso
Aplicable a: ptrace
Cuando nos asociamos a un proceso mediante ptrace, su ejecución se detiene y podemos inspeccionar y modificar los registros de procesos, incluido el puntero de instrucciones. Esto se puede realizar mediante las solicitudes ptrace SETREGS y GETREGS. Para modificar el flujo de ejecución del proceso, podemos usar ptrace para modificar el puntero de instrucción a la dirección de nuestro código de shell.
En el ejemplo del fragmento de código 7, hemos realizado los tres pasos siguientes:
Recuperar los valores de registro actuales mediante la solicitud ptrace GETREGS.
Modificar la dirección del puntero de instrucción para que apunte a nuestra dirección de carga (incrementada en 2, algo que trataremos más tarde).
Aplicar el cambio al proceso mediante la solicitud SETREGS.
// Get old register state.
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, NULL, ®s);
// Modify the instruction pointer to point to our payload
regs.rip = payload_address + 2;
// Modify the registers
ptrace(PTRACE_SETREGS, pid, NULL, ®s);
Fragmento de código 7: Uso de SETREGS de ptrace para dirigir el puntero de la instrucción a nuestra carga.
SETREGS es la forma "tradicional" y más documentada de modificar los registros de procesos, pero también se puede utilizar otra solicitud ptrace para lograrlo, POKEUSER.
La solicitud POKEUSER permite escribir datos en el área de proceso USER , una estructura (definida en sys/user.h) que contiene información sobre el proceso, incluidos los registros. Al llamar a POKEUSER con el desplazamiento correcto, podemos sobrescribir el puntero de instrucción con la dirección de nuestro código y lograr el mismo resultado que antes (fragmento de código 8).
// calculate the offset of the RIP register, based on the USER struct definition
rip_offset = 16 * sizeof(unsigned long);
ptrace(PTRACE_POKEUSER, pid, rip_offset, payload_address + 2);
Fragmento de código 8: Uso de POKEUSER de ptrace para dirigir el puntero de la instrucción a nuestra carga.
Nuestra implementación del uso de POKEUSER para modificar RIP se puede encontrar en nuestro repositorio.
RIP += 2: ¿Cuándo y por qué?
Como se muestra en el fragmento de código 7 y el fragmento de código 8, cuando modificamos RIP a la dirección de nuestra carga, también lo estamos incrementando en 2. Esto se hace para dar cabida a un comportamiento interesante de ptrace. En ocasiones, después de separarse de un proceso con ptrace, el valor de RIP se decrementará en 2. Comprendamos por qué sucede esto.
Cuando nos conectamos a un proceso usando ptrace, podemos interrumpir una llamada al sistema que se esté ejecutando en ese momento en el kernel. Para asegurarse de que la llamada al sistema se ejecuta correctamente, el kernel lo volverá a ejecutar cuando nos desvinculemos del proceso.
Mientras se ejecuta la llamada al sistema, RIP ya señala hacia la siguiente instrucción que se va a ejecutar. Para volver a ejecutar la llamada al sistema, el kernel reducirá el valor de RIP en 2, el tamaño de la instrucción de la llamada al sistema en amd64. Después de este cambio, RIP apuntará nuevamente a la instrucción del sistema, lo que hará que se ejecute otra vez (Figura 7).
Si interrumpimos un proceso durante una llamada al sistema al realizar la inyección de código, pueden producirse problemas. Después de modificar RIP para que apunte a nuestro código, el kernel seguirá disminuyendo el nuevo valor en 2, lo que provocará un hueco de 2 bytes antes de nuestro código de shell, lo que probablemente hará que falle (Figura 8).
Para adaptarnos a este comportamiento, realizaremos dos acciones: anteponer a nuestro código de shell dos instrucciones de no operación (NOP) y apuntar a RIP a la dirección de nuestro código de shell + 2. Estos dos pasos garantizarán que nuestro código se ejecute correctamente.
Si interrumpimos el proceso durante una llamada al sistema, el núcleo disminuirá el nuevo valor RIP, lo que hará que apunte a la dirección inicial del código de shell que contiene dos NOP que introduciremos en nuestro código real.
Si no interrumpimos el proceso durante una llamada al sistema, el nuevo RIP no se decrementará, lo que dará como resultado que se omitan los dos NOP y que se ejecute nuestro código. Estos dos escenarios se muestran en la figura 9.
Modificación de la instrucción actual
Aplicable a: ptrace, mem de procfs
Otro archivo interesante en procfs es el archivo syscall . Este archivo contiene información sobre la llamada al sistema que está ejecutando el proceso en ese momento: el número de llamada al sistema, los argumentos que se le han pasado, el puntero de la pila y (lo más interesante para nuestra causa) el puntero de la instrucción del proceso (Figura 10). Incluso si el proceso no está ejecutando actualmente una llamada al sistema, la pila y los punteros de instrucción del proceso seguirán estando presentes en el archivo syscall.
Esta información puede permitirnos tomar el control sobre el flujo de ejecución del proceso. Conocer la dirección de la siguiente instrucción a ejecutar nos permite sobrescribirla con nuestras propias instrucciones.
Para implementar esto, un atacante puede realizar los cuatro pasos siguientes:
Detener la ejecución del proceso enviando una señal SIGSTOP.
Identificar la dirección de la siguiente instrucción que se va a ejecutar leyendo el archivo syscall del proceso.
Escribir código de shell en la dirección identificada.
Reanudar la ejecución del proceso enviando una señal SIGCONT.
El fragmento de código 9 proporciona un pseudocódigo para este proceso.
// Suspend the process by sending a SIGSTOP signal
kill(pid, SIGSTOP);
// Open the syscall file
FILE *syscall_file = fopen("/proc/<pid>/syscall", "r");
// Extract the instruction pointer from the syscall file
long instruction_pointer = ...
// Write our payload to the address of the current instruction pointer using
procfs mem
FILE *mem_file = fopen("/proc/<pid>/mem", "w");
fseek(mem_file, instruction_pointer, SEEK_SET);
fwrite(payload, sizeof(char), payload_size, mem_file);
// Resume execution by sending a SIGCONT signal
kill(pid, SIGCONT);
Fragmento de código 9: Uso de mem de procfs para modificar la memoria de proceso en la dirección actual del puntero de instrucción para secuestrar el flujo de ejecución del proceso.
El ejemplo del fragmento de código 9 implementa esta técnica utilizando el archivo mem de procfs, pero es importante tener en cuenta que POKETEXT de ptrace también se puede utilizar para escribir la carga en memoria.
Como hemos mencionado, process_vm_writev está limitado por permisos de memoria, lo que significa que solo puede modificar regiones de memoria de escritura. La probabilidad de encontrar código ejecutándose desde una región de memoria WX es baja, lo que reduce la fiabilidad de process_vm_writev para esta primitiva.
Visite nuestra implementación de esta técnica utilizando el archivo mem de procfs.
Secuestro de pilas
Aplicable a: ptrace, archivo mem de procfs, process_vm_writev
Otra región de memoria interesante es la pila de procesos, que también puede identificarse mediante el archivo maps. Aunque la memoria de pila no es ejecutable (Figura 11), podemos utilizarla para secuestrar el flujo de ejecución del proceso.
Cada vez que se llama a una función, la dirección de retorno de la función de llamada se coloca en la pila. Cuando la función termina su ejecución, el procesador toma esta dirección de retorno de la pila y salta a ella (Figura 12).
Para abusar de este mecanismo, podemos identificar una dirección de retorno en la pila y sobrescribirla con una nueva dirección que apunte a nuestro código de shell. En cuanto la función actual termine de ejecutarse, se ejecutará nuestro código (Figura 13).
Para identificar la parte superior de la pila, podemos analizar el archivo syscall procfs mencionado anteriormente, que también contiene el valor del registro de puntero de pila.
Para realizar esta técnica se pueden seguir los seis pasos siguientes:
Detener la ejecución del proceso enviando una señal SIGSTOP.
Identificar el puntero de pila del proceso analizando el archivo syscall procfs.
Escanear la pila de procesos e identificar una dirección de devolución.
Utilizar cualquiera de las primitivas de escritura mencionadas anteriormente para inyectar nuestra carga en la memoria de proceso.
Sobrescribir la dirección del remitente con la dirección de nuestra carga.
Reanudar la ejecución del proceso enviando una señal SIGCONT.
Cuando la función actual finaliza la ejecución, se ejecuta nuestra carga.
Como todos los métodos de interacción de procesos nos permiten modificar la pila, se pueden utilizar todos ellos para implementar esta técnica. Nuestra implementación de esta técnica utilizando la llamada al sistema process_vm_writev se puede encontrar en nuestro repositorio.
Secuestro de pila ROP
Aplicable a: ptrace, archivo mem de procfs, process_vm_writev
La técnica de secuestro de pila es interesante porque nos permite secuestrar el flujo de ejecución del proceso sin modificar ninguna memoria ejecutable ni registros. A pesar de eso, para que sea utilizable, sigue siendo necesario saltar al código shell que reside en una región de memoria ejecutable. Podemos intentar encontrar una región WX (como hemos descrito) o utilizar mem de ptrace/procfs para escribir en memoria no modificable.
Pero, ¿y si queremos evitar estas acciones? Bueno, tenemos otro as en la manga: la programación orientada a retornos (ROP, por sus siglas en inglés). Al utilizar nuestra capacidad para escribir en la pila de procesos, podemos sobrescribirla con una cadena ROP (Figura 14). Como nos basamos en gadgets ejecutables que ya residen en la memoria del proceso, podemos desarrollar una carga sin escribir ningún código ejecutable nuevo.
Esta técnica consta de los siguientes siete pasos:
Detener la ejecución del proceso enviando una señal SIGSTOP.
Identificar el puntero de pila del proceso analizando el archivo syscall procfs.
Escanear la pila de procesos e identificar una dirección de devolución.
Utilizar cualquiera de las primitivas de escritura mencionadas anteriormente para inyectar nuestra carga en una región de memoria de escritura sin permisos de ejecución.
Desarrollar una cadena de ROP para llamar a mprotect y marcar la región de memoria del ejecutable de nuestro código de shell.
Sobrescribir la pila con la cadena ROP, comenzando desde la dirección del retorno identificado.
Reanudar la ejecución del proceso enviando una señal SIGCONT.
Cuando la función actual termina de ejecutarse, nuestra cadena ROP se ejecuta, haciendo ejecutable el código de shell y saltando al mismo.
Este concepto fue demostrado por Rory McNamara, del AON Cyber Labs, en su publicación de blog que cubre la inyección en memoria a través de mem de procfs.
Esta técnica no requiere modificar ninguna región de memoria no modificable y, por lo tanto, puede realizarse usando todas las técnicas de interacción de procesos, incluyendo process_vm_writev.
Visite nuestra implementación de esta técnica mediante process_vm_writev. Hasta donde tenemos conocimiento, esta es la primera demostración pública de una técnica de inyección que se basa únicamente en la llamada al sistema process_vm_writev.
Secuestro de GOT
Aplicable a: ptrace, archivo mem de procfs, process_vm_writev
Otra sección interesante de la memoria que será modificable es la GOT. La tabla de desplazamiento global (GOT, por sus siglas en inglés) es una sección de memoria utilizada como parte del proceso de reubicación de archivos ELF vinculados dinámicamente. No entraremos aquí en todos los detalles, sino que nos centraremos en la parte que es relevante para nuestro propósito: la sección que almacena las direcciones de las funciones importadas por el programa. Siempre que el programa llama a una función desde una biblioteca remota, resuelve su dirección de memoria accediendo a la GOT (Figura 15).
Un atacante puede abusar de este mecanismo para secuestrar el flujo de ejecución del proceso. La memoria de la GOT es normalmente modificable, lo que significa que un atacante puede sobrescribir cualquier una de las direcciones dentro de ella con la dirección de su carga. La próxima vez que el proceso llame a la función, se ejecutará el código del atacante (Figura 16).
Esta técnica consta de los cuatro pasos siguientes:
Detener la ejecución del proceso enviando una señal SIGSTOP.
Identificar la región de memoria de la GOT al analizar el archivo maps.
Sobrescribir las direcciones en la sección con la dirección de nuestra carga.
Reanudar la ejecución del proceso enviando una señal SIGCONT.
Cuando se llama a cualquiera de nuestras funciones sobrescritas, se ejecuta nuestra carga.
Una protección de memoria que podría afectar este ataque es RELRO completo: compilar un binario con esta configuración hará que la memoria de la GOT tenga permisos de solo lectura, lo que podría impedir sobrescrituras.
A pesar de ello, RELRO no podrá prevenir este ataque en la mayoría de los casos.
Ptrace y procfs de mem omiten los permisos de memoria, lo que hace que RELRO sea irrelevante
RELRO afecta al propio binario del proceso, pero no a sus bibliotecas cargadas. Si el proceso cargará alguna biblioteca compilada sin RELRO, su GOT sería modificable, permitiendo sobrescribirla.
Nuestra implementación de esta técnica utilizando la llamada al sistema process_vm_writev se puede encontrar en nuestro repositorio.
Resumen de primitivas de ejecución
La tabla resume todas las primitivas de ejecución posibles que hemos descrito y con qué métodos se podrían implementar.
Limitaciones en la interacción de procesos remotos
Hay varios ajustes que determinarán nuestra capacidad de interactuar con procesos remotos mediante los métodos que acabamos de describir. En esta sección, trataremos brevemente los dos principales.
ptrace_scope
ptrace_scope es un valor que determina quién puede utilizar ptrace en procesos remotos. Puede tener los siguientes valores:
0: los procesos se pueden asociar a cualquier otro proceso del sistema, siempre que tenga el mismo UID.
1: los procesos normales solo pueden asociarse a sus procesos secundarios. Los procesos con privilegios (con CAP_SYS_PTRACE) podrán seguir asociándose a procesos no relacionados. Esta es la configuración predeterminada en muchas distribuciones.
2: solo los procesos con CAP_SYS_PTRACE se pueden asociar a procesos. Esta capacidad suele concederse solo a los usuarios root.
3: la conexión a procesos remotos está desactivada.
A pesar de su nombre, este ajuste también afectará a la capacidad de acceder al archivo mem de procfs de procesos remotos, y de usar process_vm_writev en ellos.
El atributo "dumpable".
Cada proceso en Linux se configura con el atributo "dumpable", que se establece en True de forma predeterminada. Un proceso se convertirá en no volcable (undumpable) automáticamente bajo ciertas circunstancias, o se configurará como tal manualmente mediante una llamada a prctl.
Si un proceso no tiene el atributo "dumpable", no podremos acceder al mismo de manera remota con ninguno de los métodos anteriormente mencionados. Este ajuste anulará otros; un proceso no volcable no se puede modificar de forma remota.
Nota sobre la recuperación de procesos
Todos los métodos de inyección que hemos destacado requieren modificar el estado del proceso de alguna manera, modificando los registros del proceso o sobrescribiendo la memoria ejecutable, una dirección de retorno en la pila, o la GOT. Todas estas acciones alterarán el flujo normal de ejecución del proceso y darán lugar a un comportamiento inesperado una vez finalizada nuestra carga.
Esto puede ser problemático cuando queremos que el proceso objetivo continúe ejecutándose junto con nuestra carga inyectada. Para asegurarnos de que el proceso continúa ejecutándose con normalidad, tendremos que restaurar su estado original. El flujo de recuperación general consta de los ocho pasos siguientes:
Realizar una copia de seguridad del contenido de la memoria que queremos sobrescribir mediante una primitiva de lectura remota.
Realizar una copia de seguridad del contenido actual de los registros del proceso; esto podría realizarse utilizando ptrace o mediante nuestro código de shell.
Ejecutar nuestra carga (por ejemplo, cargar un archivo de objeto compartido (SO) que ejecute código en un subproceso independiente).
Cuando se complete nuestra carga, indicar al proceso de inyección que la ejecución ha finalizado; esto podría implementarse lanzando una interrupción.
Detener el proceso remoto.
Restaurar el estado del registro del proceso.
Restaurar memoria sobrescrita.
Reanudar la ejecución del proceso.
Los detalles de la implementación variarán ligeramente en función del método de inyección utilizado, pero se debe seguir este esquema general. La entrada de blog sobre inyección de ptrace en Linux de Adam Chester proporciona un ejemplo detallado de la recuperación de procesos después de una inyección basada en ptrace.
Nuestro objetivo con este post es proporcionar una visión general de las técnicas de inyección, que los defensores pueden utilizar para familiarizarse con las técnicas y luego desarrollar una detección adecuada. Como nos centramos en la defensa, hemos optado por no detallar los pasos de recuperación de las diferentes técnicas, que los atacantes necesitan para convertirlas en armas.
Detección y mitigación
Como acabamos de comentar, existen numerosas técnicas que permiten a los atacantes realizar inyecciones de procesos en equipos Linux. Afortunadamente para nosotros, todos estos métodos requieren la realización de acciones anómalas que proporcionan oportunidades de detección. En las siguientes secciones se detallan las diferentes estrategias que se podrían implementar para detectar y mitigar la inyección de procesos en Linux.
"Inyección de llamadas al sistema"
A lo largo de esta publicación, utilizamos tres métodos para interactuar con procesos remotos: ptrace, procfs y process_vm_writev. Debido a su potencial de uso malicioso, estos métodos deben supervisarse.
Comience instalando una solución de registro en equipos Linux. La monitorización de la ejecución de syscall puede activarse utilizando una utilidad de registro basada en eBPF como Sysmon for Linux o Tracee de Aqua Security (que ya implementa reglas que cubren muchas de las técnicas descritas en esta publicación).
Una vez establecido el registro, se recomienda a las organizaciones que analicen el uso normal de los centros de inyección en su entorno y establezcan una línea base de casos de uso válidos conocidos. Después de crear dicha línea base, se debe investigar cualquier desviación de la misma para descartar un posible ataque. En las próximas secciones se describirán consideraciones adicionales para cada llamada al sistema.
Lo ideal es utilizar ptrace_scope cuando sea posible, reducir al mínimo la utilización de estas llamadas al sistema o evitarlas completamente.
ptrace
En la mayoría de los entornos de producción, el uso de la llamada al sistema ptrace será muy poco frecuente. Después de establecer una línea base de uso válido de ptrace, recomendamos analizar cualquier uso anormal de ptrace.
Las siguientes solicitudes ptrace permiten la modificación de procesos remotos y deben considerarse altamente sospechosas:
POKEDATA/POKETEXT
POKEUSER
SETREGS
procfs
La escritura en el archivo mem de procfs tiene algunos casos de uso legítimo, pero este comportamiento probablemente no será muy común. Después de crear una línea base de casos de uso válidos, recomendamos analizar cualquier operación de escritura anómala.
También es importante tener en cuenta el directorio procfs /proc/<pid>/task . Este directorio expone información sobre los diferentes subprocesos del proceso. Cada subproceso tendrá su propio directorio procfs, que contendrá todos los archivos procfs principales que hemos tratado, incluidos los archivos mem, maps y syscall.
En la figura 17, podemos ver que la lectura del archivo syscall del directorio /proc/<pid> es equivalente a la lectura del directorio /proc/<pid>/task/<pid>, que representa el hilo principal del proceso.
process_vm_writev
Una vez más, al desarrollar una línea base de usos legítimos de esta llamada al sistema, podemos identificar desviaciones anómalas. Cualquier proceso desconocido que escriba en la memoria de otros procesos debe considerarse sospechoso y analizarse.
Detección de anomalías en procesos
Además de detectar directamente la inyección del proceso, también podemos intentar detectar sus efectos secundarios. Cuando se inyecta código en un proceso remoto, cambiará la forma en que se comporta. Además de las acciones normales realizadas por el proceso, las acciones de la carga también se realizarán en el mismo proceso.
Este cambio de comportamiento puede proporcionar una oportunidad de detección. Mediante la creación de una línea base del comportamiento normal del proceso, podemos identificar desviaciones sospechosas del mismo que pueden indicar que se ha producido una inyección de código. Algunos ejemplos de estos comportamientos pueden incluir la generación de procesos secundarios anómalos, la carga de archivos de SO que no se habían visto anteriormente o la comunicación a través de puertos anómalos.
Los investigadores de Akamai han documentado este enfoque y han demostrado cómo identificar la inyección de código mediante el análisis de anomalías en la red.
Resumen
Los atacantes tienen muchas opciones diferentes para realizar ataques de inyección en equipos Linux. Aunque estas técnicas pueden ser muy útiles para los atacantes, también proporcionan valiosas oportunidades de detección para los defensores. Mediante la implementación de sólidas capacidades de registro y detección en máquinas Linux, las organizaciones pueden mejorar significativamente su estado de seguridad.