LK01(Holstein)の章ではKernel Exploitの基礎的な攻撃手法について学びます。導入の章でLK01をダウンロードしていない方は、まず練習問題LK01のファイルをダウンロードしてください。
qemu/rootfs.cpio
がファイルシステムになります。ここではmount
ディレクトリを作って、そこにcpioを展開しておきます。(root権限で作成してください。)
初期化処理の確認
まず/init
というファイルがありますが、これはカーネルロード後、最初にユーザー空間で実行される処理になります。CTFなどではここにカーネルモジュールのロード等の処理が書かれている場合もあるので、必ずチェックしましょう。
今回は/init
はbuildroot標準のもので、モジュールのロード等の処理は/etc/init.d/S99pawnyable
に記載しています。
1 |
|
ここで重要になる行がいくつかあります。まず
1 | echo 2 > /proc/sys/kernel/kptr_restrict |
ですが、これは既に学んだ通りKADRを制御するコマンドで、KADRが有効になっていることが分かります。これはデバッグでは邪魔なので無効化しておきましょう。
次にコメントアウトされている
1 | #echo 1 > /proc/sys/kernel/dmesg_restrict |
ですが、これはCTFの問題では多くの場合有効になっています。意味は一般ユーザーにdmesgを許可するかです。今回は練習なのでdmesgは許可しています。
次に
1 | insmod /root/vuln.ko |
でカーネルモジュールをロードしています。
insmod
コマンドで/root/vuln.ko
というモジュールをロードし、その後mknod
で/dev/holstein
というキャラクタデバイスファイルにholstein
という名前のモジュールを紐づけています。
最後に
1 | setsid cttyhack setuidgid 1337 sh |
ですが、これはユーザーIDを1337にしてsh
を実行しています。ログインプロンプトなしでシェルが起動するのは、このコマンドのおかげです。
デバッグの際は、このユーザーIDを0にしておけばrootのシェルが取れるので、まだ例題を済ませていない方は変更しておいてください。
また、/etc/init.d
には他にもS01syslogd
やS41dhcpcd
などの初期化スクリプトがあります。これらはネットワークの設定などをしますが、今回のexploitではデバッグの際は必要無いので別のディレクトリに移動するなどして、呼び出されないようにすることをおすすめします。これにより起動時間が数秒速くなります。
ディレクトリにはrcK
, rcS
, S99pawnyable
が残る状態になっていればOKです。
Holsteinモジュールの解析
この章ではHolsteinと名付けられた脆弱なカーネルモジュールを題材にKernel Exploitを学びます。src/vuln.c
にカーネルモジュールのソースコードがあるので、まずはこれを読んでいきましょう。
初期化と終了
カーネルモジュールを書く際は、必ず初期化と終了処理を書きます。
108行目で
1 | module_init(module_initialize); |
と記述されていますが、ここでそれぞれ初期化、終了処理の関数を指定しています。まずは初期化のmodule_initialize
を読んでみましょう。
1 | static int __init module_initialize(void) |
ユーザー空間からカーネルモジュールを操作できるようにするためには、インタフェースを作成する必要があります。インタフェースは/dev
や/proc
に作られることが多く、今回はcdev_add
を使っているのでキャラクタデバイス/dev
を介して操作するタイプのモジュールになります。といってもこの時点で/dev
以下にファイルが作られる訳ではありません。先程S99pawnyable
で見たように、/dev/holstein
はmknod
コマンドで作られていました。
さて、cdev_init
という関数の第二引数にmodule_fops
という変数のポインタを渡しています。この変数は関数テーブルで、/dev/holstein
に対してopen
やwrite
等の操作があった際に、対応する関数が呼び出されるようになっています。
1 | static struct file_operations module_fops = |
このモジュールではopen
, read
, write
, close
の4つに対する処理のみを定義しており、その他は未実装(呼んでも何も起きない)となっています。
最後に、モジュールの解放処理は単にキャラクタデバイスを削除しているだけです。
1 | static void __exit module_cleanup(void) |
open
module_open
を見てみましょう。
1 | static int module_open(struct inode *inode, struct file *file) |
printk
という見慣れない関数がありますが、これは文字列をカーネルのログバッファに出力します。KERN_INFO
というのはログレベルで、他にもKERN_WARN
等があります。出力はdmesg
コマンドで確認できます。
次にkmalloc
という関数を呼んでいます。
これはカーネル空間におけるmalloc
で、ヒープから指定したサイズの領域を確保できます。今回はchar*
型のグローバル変数g_buf
にBUFFER_SIZE
(=0x400)バイトの領域を確保しています。
このモジュールをopen
すると0x400バイトの領域をg_buf
に確保することが分かりました。
close
次にmodule_close
を見ます。
1 | static int module_close(struct inode *inode, struct file *file) |
kfree
はkmalloc
と対応し、kmalloc
で確保したヒープ領域を解放します。
一度ユーザーにopen
されたモジュールは最終的には必ずclose
されるので、最初に確保したg_buf
を解放するというのは自然な処理です。(ユーザー空間のプログラムが明示的にclose
を呼ばなくても、そのプログラムが終了する際にカーネルが自動的にclose
を呼び出します。)
実はこの段階で既にLPEに繋がる脆弱性があるのですが、それは後の章で扱います。
read
module_read
はユーザーがread
システムコール等を呼び出した際に呼ばれる処理です。
1 | static ssize_t module_read(struct file *file, |
g_buf
からBUFFER_SIZE
だけkbuf
というスタックの変数にmemcpy
でコピーしています。
次に、_copy_to_user
という関数を呼んでいます。SMAPの節で既に説明しましたが、これはユーザー空間に安全にデータをコピーする関数です。copy_to_user
ではなく_copy_to_user
になっていますが、これはスタックオーバーフローを検知しないバージョンのcopy_to_user
になります。通常は使われませんが、今回は脆弱性を入れるために使っています。
copy_to_user
やcopy_from_user
はインライン関数として定義されていて、可能な場合はサイズチェックをするようになっているよ。
まとめると、read
関数はg_buf
から一度スタックにデータをコピーし、そのデータを要求したサイズだけ読み込む処理になります。
write
最後にmodule_write
を読みましょう。
1 | static ssize_t module_write(struct file *file, |
まず_copy_from_user
でユーザー空間からデータをkbuf
というスタック変数にコピーしています。(これもスタックオーバーフローを検知しないバージョンのcopy_from_user
です。)最後にmemcpy
でg_buf
に最大BUFFER_SIZE
だけkbuf
からデータをコピーしています。
スタックオーバーフロー脆弱性
さて、カーネルモジュールを一通り読み終えましたが、いくつの脆弱性を見つけられたでしょうか。
Kernel Exploitに挑戦するような方なら少なくとも1つは脆弱性を見つけたかと思います。この節では次の箇所にあるスタックオーバーフローの脆弱性を扱います。
1 | static ssize_t module_write(struct file *file, |
9行目でコピーするサイズcount
はユーザーから渡ってくるのに対し、kbuf
は0x400バイトなので自明なスタックバッファオーバーフローがあります。カーネル空間でも関数呼び出しの仕組みはユーザー空間と同じなので、リターンアドレスを書き換えたりROP chainを実行したりできます。
脆弱性の発火
脆弱性を悪用する前に、このカーネルモジュールを普通に使うプログラムを書いて、動作することを確認しましょう。今回は次のようなプログラムを書いてみました。
1 |
|
write
で"Hello, World!"と書き込んで、それをread
で読むだけのプログラムです。
これをカーネル上で実行してみましょう。
期待通りに動いていることが分かります。また、カーネルモジュールが出したログを確認しても特にエラーは発生していません。
次にスタックオーバーフローを発生させてみます。こんな感じで良いでしょう。
1 |
|
実行します。
何やら禍々しいメッセージが出力されました。
このようにカーネルモジュールが異常な処理を起こすと通常カーネルごと落ちてしまいます。その際クラッシュした原因と、クラッシュ時のレジスタの様子やスタックトレースが出力されます。この情報はKernel Exploitのデバッグで非常に重要です。
今回クラッシュの原因は
1 | BUG: stack guard page was hit at (____ptrval____) (stack is (____ptrval____)..(____ptrval____)) |
となっています。ptrval
というのはポインタですが、KADRにより隠されています。
レジスタの様子で気になるのはRIPですが、残念ながら0x414141414141414141にはなっていません。
1 | RIP: 0010:memset_orig+0x33/0xb0 |
クラッシュの原因にも書かれているように、copy_from_user
での書き込みの際にスタックの終端(guard page)に到達してしまったようです。書き込みすぎが原因なので、書き込む量を減らしてみましょう。
1 | write(fd, buf, 0x420); |
するとクラッシュメッセージが変わります。
今度はgeneral protection faultになり、RIPが取れています!
1 | RIP: 0010:0x4141414141414141 |
このように、カーネル空間でもユーザー空間と同様にスタックオーバーフローでRIPを取れます。次の節ではここから権限昇格する方法について学びます。