Try   HackMD

最近のLinuxカーネルexploit問に対するテクニック集2

tags: kernel ctf pwn

概要

この記事は,CTF Advent Calendar 2018 の5日目の記事です.
4日目は私の「最近のLinuxカーネルexploit問に対するテクニック集1」でした.

今回は

  • userfaultfdを使ったテクニック

について書きます.残念ながら新規性はありません.日本にはこういった記事はあまりありませんが,海外では普通に公開されている内容です.

またLinux以外のOS(例えばWindows)には通用しないと思いますので,ご注意ください.

はじめに

大前提として,Linuxのカーネルexploit(権限昇格)についてある程度知っている方向けの内容となります.

全く知らないよ!って方は,nokia31337の解説資料で図を入れて解説したので,先にそちらで精進したほうが良いかと思います(2014年当時のARMにおけるLinuxカーネルExploit問です).

また私はLinuxカーネルのソースを全て読んだことはなく,必要に応じてチラ見した程度なので,そこまで深い知識は持っていません.もし記事の内容に間違いがありましたら,優しく教えてください.マサカリ投げるのは禁止!

userfaultfdについて

本日焦点を当てるのは,userfaultfd()と呼ばれるシステムコールです.

このシステムコールは,ユーザランドのメモリがページフォルトを起こしたタイミングで,登録しておいたコールバック関数を呼びだす,というものです.監視対象のメモリ範囲を決めておくための面倒な手順があったり,存在しないページの監視はできない(SIGSEGVを起こすようなアクセスには対処できない)といった制約は有りますが,ページフォールトをユーザランドでハンドリングできる,という点が強みです.

userfaultfdの使い方

userfaultfd()の使い方の参考までに,まずはコードを貼っておきましょう.簡単のため,エラーハンドリングは全て省略しています.

// gcc userfaultfd.c -lpthread #include <linux/userfaultfd.h> #include <sys/syscall.h> #include <sys/types.h> #include <sys/stat.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <stdint.h> #include <string.h> #include <assert.h> #include <unistd.h> #include <pthread.h> #include <poll.h> #include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <errno.h> #define PAGE_SIZE 0x1000 static void *handler(void *uffd) { for (;;) { struct pollfd pollfd[1]; pollfd[0].fd = (long)uffd; pollfd[0].events = POLLIN; poll(pollfd, 1, 2000); struct uffd_msg msg; read((long)uffd, &msg, sizeof(msg)); // page faultしたページを特定の値で初期化 if (msg.event & UFFD_EVENT_PAGEFAULT) { static int count = 0; printf("page fault is handled. (count:%d)\n", count); // 最初にページサイズ分のデータを用意 char init_data[PAGE_SIZE] = {0}; snprintf(init_data, PAGE_SIZE, "hogehoge%d", count++); // ioctlで初期化 struct uffdio_copy copy = { .src = (long long)init_data, .dst = (long long)msg.arg.pagefault.address, .len = PAGE_SIZE, .mode = 0, }; ioctl((long)uffd, UFFDIO_COPY, &copy); } } return NULL; } int main(int argc, char **argv) { // userfault fdを開く long uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); // APIを有効化 struct uffdio_api uffdio_api = { .api = UFFD_API, .features = 0 }; ioctl(uffd, UFFDIO_API, &uffdio_api); // 監視対象メモリ空間を作成 long num_pages = 5; char *region = (char*)mmap(NULL, PAGE_SIZE * num_pages, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); // 監視対象メモリ空間を登録 struct uffdio_register uffdio_register = { .mode = UFFDIO_REGISTER_MODE_MISSING, .range = { .start = (unsigned long)region, .len = PAGE_SIZE * num_pages } }; ioctl(uffd, UFFDIO_REGISTER, &uffdio_register); // 監視対象メモリ空間でpage faultが起きた時のハンドラと引数を登録 pthread_t uffd_thread; pthread_create(&uffd_thread, NULL, handler, (void*)uffd); // 監視対象メモリ空間に触る for (int i = 0; i < num_pages; i++) { char v = region[i*PAGE_SIZE]; // page fault } // 監視対象メモリ空間を表示してみると,確かに初期化されている for (int i = 0; i < num_pages; i++) { printf("%s\n", &region[i*PAGE_SIZE]); // page faultは起きない } return 0; }

雰囲気で分かると思いますが,5つのメモリページ(0x1000バイト単位)を監視登録しておき,その各ページに初めて触った瞬間,コールバックがページ内を初期化している,という感じのコードですね.

さて,続いてはこれをカーネルexploitにどう使うかの話をしましょう.

userfaultfdの悪用

userfaultfdは,カーネルexploitと相性がよく,知っておくと便利なので紹介します.ちなみに元ネタはこちらです.

このテクニックは,一言で言ってしまうと,「Race Conditionの成功率を上げるためのテクニック」です.これ単体で何かできるわけではありません.また,SMAPが有効な環境下では多分使えません.

カーネルのバグ自体は別にあって,任意の構造体を書き換えられるといったケースを考えます.以下は簡単なコードです.

// 関数ポインタと,別のメンバを持つ構造体があるとする struct some_struct { void (func_ptr*)(); int member; }; // バグのあるカーネルの関数 void vulnerable(char __user *arg) { char buf[0x10]; struct some_struct *p; p = kmalloc(sizeof(struct some_struct), GFP_KERNEL); copy_from_user(buf, arg, 0x18); // BOFでpは好きなポインタを指せるとする ... p->func_ptr = &some_func; // 初期化 p->member = 0; // 初期化 p->func_ptr(); // 呼び出し }

vulnerable()が呼ばれると,copy_from_user()によりBOFが発生します.bufの直下にあるpは,BOFにより任意の場所を指せますから,例えばユーザランドを指すようにできます(実際は,スタック上の並びとしては配列のほうが下に来るような気もしますけど,これ解説用のコードなので突っ込まないでください).

しかしpがユーザランドを指してはいるものの,ちゃんと初期化があります.19行目でp->func_ptr()を呼び出していますが,ちゃんと17行目の&some_funcで初期化されています.つまりコード上,13行目のBOF時にpの上書きでp->func_ptrの値をユーザが指定したとしても,17行目で正しい値に書き戻されてしまうため,ユーザが任意に指定できる訳ではないように見えます.

しかし,こういったコードにはごく短い間ですが,Race Conditionが存在します.

// pはユーザランドのメモリを指しているとする p->func_ptr = &some_func; // 初期化 <---+ p->member = 0; // 初期化 | Race Window p->func_ptr(); // 呼び出し <-------------+

例えばユーザランドのスレッドAが,このコードをシステムコール経由などで呼び出した際,運良く2行目~4行目の間でユーザランドへのコンテキストスイッチが発生し,スレッドAと同じメモリ空間を持つスレッドBがp->func_ptrの指すアドレスを書き換えれば,再度コンテキストスイッチによりカーネルが再開したタイミングで,p->func_ptrは書き換わっていることになり,RIPを奪うことができます.

[kernel] | [user thread A] | [user thread B] | addr = mmap(0xabcd0000,...); | | vulnerable_syscall(..., "A"*16 + "\x00\x00\xcd\xab\x00\x00\x00\x00"); | <--- context switch ---- | ... | | p->func_ptr = &some_func; | | ------------------------------------ context switch ------------------------------------> | | *(unsigned long long*)0xabcd0000 = 0xdeadbeef; <----------------------------------- context switch ------------------------------------- p->member = 0; | | p->func_ptr(); // RIP=0xdeadbeef | |

しかし,このようなRace Windowの短いコンテキストスイッチが上手く行くことは珍しく,普通は何度もトライする必要があります.バグのあるカーネルのコードを何度も繰り返し呼び出せるような上記のケースではあまり問題になりませんが,一度失敗したらOSを再起動してやり直し,といった条件のバグの場合は無理ゲーになります.こういう時,Race Conditionを安定させるテクニックがあると便利です.

userfaultfdを使ったテクニックは,まさに上記のようなケースで有効です.userfaultfdは,ページフォールトを起こしたのがカーネルであっても,ユーザランドのコールバック関数を呼びだしてくれる,といった特徴があります.

カーネルで起きたページフォールトをハンドルした際,メモリを修正して再開すれば,Race Conditionの信頼度を限りなく100%に近づけることができるようになります.テクニックのキモは,p->func_ptrp->memberの間にちょうどページ境界が位置するようにしておき,p->memberへのアクセスでページフォールトを強制的に発生させることです.

これにより,p->func_ptrが呼び出される前に,確実にユーザランドでp->func_ptrを修正することができます.

[kernel] | [user thread A] | [user thread B] | addr = mmap(0xabcd0000,...); // addrの末尾を利用(以下は0xabcd0ff8を指定) | | vulnerable_syscall(..., "A"*16 + "\xf8\x0f\xcd\xab\x00\x00\x00\x00"); | <--- context switch ---- | ... | | p->func_ptr = &some_func; | | p->member = 0; // page fault | | ------------------------------------ context switch ------------------------------------> | | *(unsigned long long*)0xabcd0ff8 = 0xdeadbeef; <----------------------------------- context switch ------------------------------------- p->func_ptr(); // RIP=0xdeadbeef | |

実際のexploit例は,元ネタのリンクを参照してみてください.カーネルのheap上で,このテクニックを使っているのがわかると思います.

応用

最近,こんな記事もありました.

こちらで紹介されているテクニックは,setxattruserfaultfdを使ったテクニックです.以下の3拍子が揃った,素晴らしいテクニックだと思います.

  • ユーザが任意サイズ・任意データを指定可能
  • そのデータを,kmallocで確保した領域の先頭から書込可能
  • 半永久的に保持可能

デメリットは,プロセス(正確にはスレッド)毎に1つしかそのようなオブジェクトを作成出来ない点ぐらいですかね.

ざっくり説明しておきましょう.
setxattrシステムコールの第3引数,第4引数を利用してカーネルに任意サイズの任意データを渡すと,カーネル内部ではkmalloc()で割り当てた領域に対してstrncpy_from_user()をします.
ユーザ空間からカーネル空間へコピーをしている途中でユーザ空間のページ境界に差し掛かり,userfaultfdの監視対象領域を初めて触るとページフォルトが発生するため,ユーザランドのコールバックハンドラがページフォルトをハンドリングします.
ユーザランドのページフォルトコールバックハンドラが無限ループに入るなどにより,意図的にカーネルへ復帰させなければ,そのまま永久にカーネル側の処理は再開しない,というテクニックです.

終わりに

今回はuserfaultfd()システムコールの悪用について紹介しました.有用なシステムコールも,使い方によってはexploitの助けとなってしまう,というお話でした.海外ではこういうテクニックの情報がガンガン公開されているようで,閉鎖的な日本とは違うなぁと感じたりします.

明日も私が記事を担当します.カーネル内の使える関数テーブルの話(最近のLinuxカーネルexploit問に対するテクニック集3)です.良ければ見てくださいね.