miércoles, 25 de enero de 2012

Elevación de privilegios en el kernel Linux y un exploit interesante

El pasado día 17 de enero, Linus Torvalds hizo un commit para corregir una elevación de privilegios en el kernel Linux 2.6 (CVE-2012-0056). El fallo fue descubierto por Jüri Aedla y según comenta Torvalds en el propio parche, se debe a una incorrecta comprobación de privilegios al abrir el '/proc/#pid#/mem' de un proceso.


Este error no pasó desapercibido a Jason A. Donenfeld que ha escrito y publicado un interesante exploit. Lo ha bautizado como 'Mempodipper'.

Donenfeld estudió en detalle el proceso de explotación. De partida se encontró con que efectivamente la comprobación de permisos era débil y además, que la protección contra escritura en el espacio de memoria del proceso fue eliminada del código del kernel. De hecho puede leerse en el comentario del commit correspondiente, que se eliminaba la restricción porque no se consideraba un peligro la escritura en /proc/#pid#/mem.

Esto hace virtualmente vulnerables a los núcleos con versión 2.6.39 o superior.

Cómo funciona

Cuando se abre /proc/#pid#/mem se usa la función 'mem_open'. Dicha función almacena el 'self_exec_id' correspondiente al proceso que solicita la apertura. Esto se hace para comprobar posteriormente si son suficientes privilegios cuando se efectúan operaciones de lectura o escritura sobre el descriptor de fichero obtenido.

static int mem_open(struct inode* inode, struct file* file)
{
    file->private_data = (void*)((long)current->self_exec_id);

En el caso de la operación de escritura se hace la comprobación del 'self_exec_id' además de llamar a 'check_mem_permission'. En teoría, para escribir en /proc/#pid#/mem, o se es el mismo proceso #pid# o el proceso tiene ciertos permisos especiales relacionados con ptrace.

static ssize_t mem_write(struct file * file, const char __user *buf,
             size_t count, loff_t *ppos)
{
...
...

Se llama a 'check_mem_permission' y si 'mm' vuelve con error no se efectúa la operación:

mm = check_mem_permission(task);
    copied = PTR_ERR(mm);
    if (IS_ERR(mm))
        goto out_free;
                       
'check_mem_permission' simplemente llama a '__check_mem_permission' y como se observa dentro de su código efectúa la comprobación "task ==current":

static struct mm_struct *__check_mem_permission(struct task_struct *task)
{
    struct mm_struct *mm;

    mm = get_task_mm(task);
    if (!mm)
        return ERR_PTR(-EINVAL);
        
    if (task == current)
        return mm;
    
    ...
    ...

Si no coinciden devuelve error. Luego debe ser el mismo proceso el que escriba en su propia memoria y si queremos elevar privilegios necesitaríamos un proceso con 'suid'.

Primera aproximación al exploit

Donenfeld se figuró cómo hacer esto:

$ su "yeeeee haw I am a cowboy"
Unknown id: yeeeee haw I am a cowboy

Cualquier cosa que se escriba pasa por la memoria del proceso creado por 'su' (que posee 'suid'). Parecia obvio qué hacer:

Abrimos '/proc/self/mem' y obtenemos su descriptor de archivo. Ejecutamos 'dup2' para multiplexarlo con 'stderr'. Escribimos nuestro shellcode en él y ejecutando posteriormente con exec obtendríamos root... pero no.

Esto no iba a funcionar debido a que aun queda otra restricción más:

if (file->private_data != (void *)((long)current->self_exec_id))
        goto out_mm;

El 'self_exec_id' del proceso que va a ejecutar la escritura 'exec' ha de ser el mismo que abrió originalmente '/proc/#pid#/mem', recordad más arriba cuando se llama a 'mem_open'.

¿Por qué ha cambiado 'self_exec_id'?

Cuando se llama a 'exec' el 'self_exec_id', es aumentado en 1 impidiendo que esto ocurra:

void setup_new_exec(struct linux_binprm * bprm)
{
    ...
    ...
    current->self_exec_id++;

Resumiendo. No podemos abrir el '/proc/#pid#/mem' de un proceso cualquiera, hacer 'dup2' con 'stderr' y luego hacer 'exec' de un proceso con 'suid' para escribir allí, ya que al hacer 'exec' no van a coincidir los 'self_exec_id'.

Donenfeld solucionó esto de manera sencilla pero ingeniosa, muy ingeniosa.

El exploit

Se hace 'fork' de un proceso y en este hijo se ejecuta 'exec' a un nuevo proceso. De este modo el 'self_exec_id', que se ha heredado del padre, ahora tiene un valor de +1 con respecto al padre.

Ahora hacemos que el padre ejecute 'exec' sobre un proceso 'suid' que va a escribir nuestro shellcode. En este momento, al ejecutar 'exec' en el padre su 'self_exec_id' acaba de aumentar a +1 coincidiendo con el del hijo.

Mientras tanto en el proceso 'exec' que ha llamado el hijo se obtiene, al abrir, el descriptor del '/proc/#parent_pid#/mem'; y es posible abrirlo porque no existe restricción en la operación de apertura. Es decir, no van a ser comprobados los privilegios del 'exec' que ha creado el hijo.

En este momento tenemos un 'exec' del padre con un proceso 'suid' dispuesto a escribir el shellcode. Un 'exec' del hijo con un descriptor del '/proc/#pid#/mem' del padre y la salida 'stderr' acoplada a ese descriptor (llamada a 'dup2') y lo más importante, tenemos los 'self_exec_id' igualados por lo tanto la restricción ha sido evadida.

Solo queda que el hijo pase el descriptor al padre y éste va a escribir el shellcode allí ya que cuando llame a 'mem_write', como hemos dicho, las comprobaciones van a ser correctas.

Pero aún queda más, averiguar "dónde" se debe escribir en memoria para poder ejecutar de manera exitosa el shellcode. Se necesita una dirección fija de memoria, algo que podría ser complicado si se usa ASLR, pero Donenfeld observó que la mayoría de binarios 'su' de las distribuciones no están compilados con 'PIE'(Position-independent executable)

Podemos observarlo si hacemos:

$ readelf -h /bin/su | grep Type
  Type:                              EXEC (Executable file)

Como el mismo Donenfeld apunta, si el 'Type' fuera 'DYN' entonces si podría protegerse con ASLR debido a que el ejecutable permitiría ser reubicado en distintas posiciones de memoria. Al no ser el caso el desplazamiento en memoria siempre va a ser el mismo.

Buscando Donenfeld obtuvo la dirección 0x402178, correspondiente a una llamada a 'exit@plt'. Tan solo tenía que escribir en 0x402178 menos la longitud de la cadena 'Unknown id: ' y su shellcode se ejecutaría.

En definitiva, un exploit más interesante que la propia vulnerabilidad que explota y una muestra de ingenio por parte de Donenfeld al conseguir la explotación de la forma que hemos visto.

Más información:

Parche de Linus Torvalds

Commit de la eliminación de la protección de escritura

* Linux Local Privilege Escalation via SUID /proc/pid/mem Write


David García


6 comentarios:

  1. He sido un usuario anónimo y silencioso durante años de "una al día", y en más de una ocasión me he quedado fascinado por el tratamiento que hacen en sus comentarios de la seguridad.
    Hoy ha sido uno de esos, en donde algo que puede ser bastante difícil de entender (un exploit en un sistema operativo), lo han explicado de una manera que incluso... puedo considerar que hasta le entendí :) , y no solo eso, hasta me divertí leyéndolo.
    Mis felicitaciones, siempre es un placer poder leer lo que ustedes escriben.

    ResponderEliminar
  2. @anonimo 11:26

    Tal vez deberías de mirar como funciona fork y exec en sistemas UNIX.

    Me ha gustado.

    Lo que no entiendo es como se ha podido relajar la seguridad simplemente por una consideración.

    ResponderEliminar
  3. Muy interesante, Os felicito al igual que Jaime Martínez por la explicación del exploit.

    Un saludo

    ResponderEliminar
  4. Yo más bien os felicito por la traducción/resumen de esta entrada de blog:

    http://blog.zx2c4.com/749

    ResponderEliminar
  5. Gracias! Es la primera vez que leo una vulnerabilidad en el kernel y me entero de como funciona. Un placer leeros.

    ResponderEliminar