前節ではHolsteinモジュールにStack Overflowを見つけ、脆弱性を利用してRIPを取れることを確認しました。この節ではこれをLPEに繋げる方法と、さまざまなセキュリティ機構を回避する方法を学びます。
権限昇格の方法
権限昇格の方法にはいろいろありますが、最も基本的な手法はcommit_creds
を使う方法です。これは、カーネルがroot権限のプロセスを作る時に実行する処理と同じことを実行する方法で、非常に自然な考え方です。
root権限を取った後にもう1つ重要なのが、ユーザー空間に戻ることです。今カーネルモジュールをexploitしているのでコンテキストはカーネルですが、最終的にはユーザー空間に戻ってroot権限のシェルを取る必要があるので、クラッシュせずにユーザー空間に戻る必要があります。
まずはこれらの理論的部分について説明します。
prepare_kernel_credとcommit_creds
すべてのプロセスには権限が割り当てられます。これはcred構造体と呼ばれる構造体でヒープ上で管理されています。各プロセス(タスク)はtask_struct構造体という構造体で管理され、その中にcred構造体へのポインタがあります。
1 | struct task_struct { |
cred構造体はプロセスが生成されるタイミングなどで作られるのですが、それを担う関数としてprepare_kernel_cred
というKernel Exploitで非常に重要な関数があります。この関数を少しだけ読んでみましょう。
1 | /* 引数としてtask_struct構造体へのポインタを取る */ |
第一引数にNULLを与えてprepare_kernel_cred
を呼んだ時の挙動を追います。まず以下のコードでcred構造体が新たに確保されます。
1 | new = kmem_cache_alloc(cred_jar, GFP_KERNEL); |
そして第一引数daemon
がNULLのとき、次のコードでinit_cred
と呼ばれるcred構造体のデータが引き継がれます。
1 | old = get_cred(&init_cred); |
その後old
の正当性を検証し、old
からnew
に適当にメンバを引き継いでいきます。
prepare_kernel_cred(NULL)
により、init_cred
を使ったcred構造体が生成されます。ではinit_cred
の定義も見てみましょう。
1 | /* |
コードを見れば分かるように、init_cred
はまさにroot権限のcred構造体になります。
これでroot権限のcred構造体が作れそうです。次にこの権限を現在のプロセスに設定してやる必要があります。その役割を果たすのがcommit_creds
関数になります。
1 | int commit_creds(struct cred *new) |
したがって、
1 | commit_creds(prepare_kernel_cred(NULL)); |
を呼び出すのがKernel Exploitで権限昇格するための1つの手法となります。
[2023年3月28日追記]
Linuxカーネル6.2からはprepare_kernel_cred
にNULLが渡せなくなりました。
init_cred
はまだ存在するので、commit_creds(&init_cred)
を実行すれば同じことが可能です。
swapgs: ユーザー空間への復帰
prepare_kernel_cred
とcommit_creds
でめでたくroot権限が取れたのですが、それで終わりではありません。
ROP chainが終わった後、何事もなかったかのようにユーザー空間に復帰してシェルを取る必要があります。せっかくroot権限を取ってもクラッシュしたり、プロセスが終了したりしては意味がありません。
ROPというのは本来保存されていたスタックフレームを破壊してchainを書き込むので、元に戻るというのは直感的には非常に困難です。しかし、Kernel Exploitではあくまで脆弱性を発火させるプログラム(プロセス)は我々が作るので、ROP終了後にRSPをユーザー空間に戻し、RIPをシェルを取る関数に設定してやればユーザー空間に戻れます。
そもそもユーザー空間からカーネル空間に移動する方法ですが、これはCPUの命令が特権モードを切り替えることで実現されます。ユーザー空間からカーネル空間に行く方法は通常システムコールsyscall
と割り込みint
だけです。そして、カーネル空間からユーザー空間に戻るためには通常sysretq
, iretq
という命令が使われます。sysretq
よりiretq
の方が単純ですので、Kernel Exploitでは普通iretq
を使います。また、カーネルからユーザー空間に戻る際、カーネルモードのGSセグメントからユーザーモードのGSセグメントに切り替える必要があります。このためにIntelではswapgs
命令が用意されています。
流れとしてはswapgs
とiretq
を順番に呼び出せば良いです。iretq
を呼び出すとき、スタックには戻り先のユーザー空間の情報を次のように積んでおく必要があります。
ユーザー空間のRSP, RIPに加え、CS,SS,RFLAGSもユーザー空間のものに戻す必要があります。RSPはどこでも良いですし、RIPはシェルを起動する関数にでも設定しておけば良いです。残りのレジスタはもともとユーザー空間にいたときの値を使えば良いので、次のようにレジスタの値を保存する補助関数を用意しておきましょう。(RSPも保存しておきました。)
1 | static void save_state() { |
ユーザー空間にいる間にこれを呼んでおき、iretq
を呼ぶタイミングでこの値を使えるようにしておけば良いです。
ret2user (ret2usr)
ここまで説明してきた理論を使って、いよいよ権限昇格を実践してみます。
まずは最も基礎的な手法であるret2userについて説明します。今回SMEPが無効なので、ユーザー空間のメモリに置いてあるコードをカーネル空間から実行できます。つまり、ここまで説明したprepare_kernel_cred
, commit_creds
, swapgs
, iretq
の流れをそのままC言語で書いておけばOKです。
1 | static void win() { |
これらの処理は簡単なKernel Exploitでは頻出なので、各自自分に合ったコードでテンプレートとして持っておきましょう。main
関数の先頭にでもsave_state
を呼ぶ処理を追加しておきましょう。
escalate_privilege
関数内でprepare_kernel_cred
とcommit_creds
という関数ポインタが必要になりますが、今回KASLRが無効なのでこの値は固定のはずです。実際にこれらの関数のアドレスを取得し、コード中に書いておきましょう。
さて、あとは脆弱性を使ってescalate_privilege
関数を呼べば終わりです。適当にescalate_privilege
のポインタを大量に書き込んでも良いですが、後々ROPをすることになるので、リターンアドレスの正確なオフセットを把握しておきましょう。
オフセットはカーネルモジュールをIDAなどで読んで計算しても良いですが、せっかくなのでgdbを使って脆弱性が発火する部分を確認しましょう。
module_write
中で_copy_from_user
を呼んでいる箇所をIDAなどで見ると、アドレスは0x190です。/proc/modules
から得たベースアドレスと足してブレークポイントを付けた状態でwriteを呼んでみます。
書き込み先のRDIから0x400先は次のようになっています。
さらにretまで進めると、
となっており、RSPは0xffffc90000413eb0を指しています。
したがって、0x408バイトだけゴミデータを入れた後からRIPを制御できそうです。
ということで、次のようにexploitを変更してみました。
1 | char buf[0x410]; |
最終的なexploitはここに置いておきます。
module_write
のret命令で止めてみると、escalate_privilege
に到達していることが分かります。
nextiコマンドを実行しても次の命令で止まらないことがあるよ。
そういうときはstepiを試してみるか、少し先にブレークポイントを貼ると良いかも?
正しくexploitが書けていればprepare_kernel_cred
とcommit_creds
を通過します。restore_state
の中もステップインで見てみましょう。iretq
を呼ぶ際のスタックは次のようになります。
stepiでwin
関数に飛んでいれば成功です。
今はもともとroot権限なので成功したか分かりませんが、とりあえずユーザー空間に戻れています。
では変更した設定(S99pawnyable)を元に戻し、一般ユーザーからexploitを実行してみましょう。
権限昇格に成功しました!
初めて知る知識が多くて少し難しかったかもしれませんが、これからさらにヒープや競合などの脆弱性を扱っていくうちに、どんな脆弱性でもほとんどやることは同じなので、実は結構簡単だと気付くことになります。お楽しみに!
kROP
次にSMEPを有効化してみましょう。qemu起動時のcpu引数にsmep
を付けてみましょう。
1 | -cpu kvm64,+smep |
この状態で先程のret2userのexploitを動かしてみましょう。
クラッシュしてしまいました😢
「unable to execute userspace code (SMEP?)」となっており、SMEPによりユーザー空間のコードが実行できなくなっていることが分かります。
これはユーザー空間におけるNX(DEP)に非常に似ています。ユーザー空間のデータを読み書きはできますが、実行はできなくなりました。したがって、NXを回避するのと同様に、SMEPはROPにより回避可能です。カーネル空間でのROPをkROPと呼ぶことが多いです。
Kernel Exploitに挑戦している皆さんならret2userでやった処理をROPにするのも簡単だと思います。実際ROP chainを書くにあたり特に注意する点はありませんが、ROP gadgetを探す部分までは一緒にやっていきましょう。
まずLinuxカーネルのROP gadgetを探すには、bzImageからvmlinuxというカーネルのコアになるELFを取り出す必要があります。これには公式でextract-vmlinuxというシェルスクリプトが提供されているので利用しましょう。
1 | $ extract-vmlinux bzImage > vmlinux |
あとはお好みのツールを使ってROP gadgetを探します。
1 | $ ropr vmlinux --noisy --nosys --nojop -R '^pop rdi.+ret;' |
出力されるアドレスは絶対アドレスになっています。この値はKASLRを無効にした際のベースアドレス(0xffffffff81000000)に相対アドレスを足したものなので、例えば上の例だと0x27bbdcが相対アドレスになります。今回はKASLRが無効なので出力されたアドレスをそのまま使えますが、KASLRが有効な場合は相対アドレスを使うように注意しましょう。
Linuxカーネルはlibcなどよりも膨大な量のコードなので、基本的に任意の操作ができるほどのROP gadgetがあります。今回は以下のgadgetを使いましたが、デバッグの練習も兼ねて自分の好きなROP chainを組んでみてください。
1 | 0xffffffff8127bbdc: pop rdi; ret; |
最後にiretq
が必要ですが、これは通常のツールでは探してくれないのでobjdumpなどで探しましょう。
1 | $ objdump -S -M intel vmlinux | grep iretq |
ROP gadgetを探すためのツールのほとんどはカーネルのような膨大な量のバイナリに対して十分にテストされていないよ。
対応していない命令をスキップしていたり、命令のprefixを省略したりと、間違った出力が多いから気を付けようね。
それから、gadgetがカーネル空間で実際に実行可能領域に含まれるかを正しく判別できないツールがほとんどだから、アドレスが大きいgadget (例:0xffffffff81cXXXYYY) には特に注意が必要だよ。
ROP chainの書き方は自由ですが、筆者は次のように書いています。gadgetを途中で追加したり削除したりしてもオフセットの値を変更しなくて良いため、おすすめです。
1 | unsigned long *chain = (unsigned long*)&buf[0x408]; |
ROP chainに直すだけなので、各自でexploitを書いてみてください。exploitの例はここからダウンロードできます。
ROP chainがなぜか動かないけどデバッグするのが面倒な場合は、ユーザーランドのexploitと同様に適当なアドレスを入れてクラッシュメッセージを見て、そこまで実行できているかデバッグするのが楽です。
1 | *chain++ = rop_pop_rdi; |
この際必ずカーネルやユーザーランドでマップされていないアドレスを使うようにしましょう。ROPが正しく動けばSMEPを回避してroot権限が取れるはずです。
今回のROP chainはカーネル空間のスタックで動いているので、実はSMAPを有効にしてもexploitはそのまま動きます。試してみてください。
mov rdi, rax; rep movsq; ret;
」のようなgadgetが存在し、prepare_kernel_cred(NULL)
の結果をcommit_creds
に渡せます。あるいは「mov rdi, rax; call rcx;
」のようなgadgetでcommit_creds
の先頭のpush rbp
をスキップして実行しても良いでしょう。どうしてもgadgetが見つからない時や、ROP chainを短くしたいときは
init_cred
が使えます。init_cred
というグローバル変数にはroot権限のcred構造体が入っています。つまり、単にcommit_creds(init_cred)
を実行するだけでも権限昇格できます。
KPTIの扱い
次にSMAP, SMEP, KPTIを有効にした状態でexploitしてみましょう。
KPTI自体はこのような一般的な脆弱性に対する緩和策ではなく、Meltdownという特定のサイドチャネル攻撃に対応するための緩和策です。そのため、これまで使ってきたexploit手法に影響はありませんが、KPTIを有効にした状態でexploitを実行すると次のようにユーザー空間でクラッシュしてしまいます。
ユーザー空間で死んでいるので、swapgs
からのiretq
でユーザー空間には戻れているのですが、KPTIの影響でページディレクトリがカーネル空間のままなので、ユーザー空間のページが読めない状態になっています。
セキュリティ機構の節でも書いたように、ユーザーランドに戻る前にCR3レジスタに0x1000をORしておく必要があります。「そんなgadgetあるのか?」と思うかもしれませんが、この処理はカーネルからユーザー空間に戻る正規の処理に必ず存在しているはずなので、100%見つかります。
具体的には、swapgs_restore_regs_and_return_to_usermode
マクロで実装されています。重要なのは以下の部分です。
1 | movq %rsp, %rdi |
最初にpushしているのは後述しますが、iretq
に向けてスタックを整備しているものです。その後SWITCH_TO_USER_CR3_STACK
を使ってCR3を更新しています。このマクロのアドレスを調べましょう。
1 | / # cat /proc/kallsyms | grep swapgs_restore_regs_and_return_to_usermode |
なお、シンボルが消えている場合はobjdumpなどでCR3に対する操作(rdiを使って操作している箇所)を探せば良いです。
さて、ROP chainの中でこのswapgs_restore_regs_and_return_to_usermode
のどこにジャンプするかですが、目的はCR3の更新なのでひと目見ると次の箇所に飛べばページディレクトリをユーザー空間に戻してくれそうです。
しかし、CR3をユーザー空間のものに更新したらカーネル空間のスタックにあるデータはもはや参照できないので、最後のpopやiretqでデータを読み込むことはできません。
実は(当たり前と言えば当たり前ですが)このコンテキストスイッチを実現するためにユーザー空間からもカーネル空間からもアクセスが許可されている場所がいくつかあります。先程の
1 | movq %rsp, %rdi |
の部分は事前にスタックをその場所に調整していたものです。
そして、続くpushは本来iretq
に渡るはずだったカーネルのスタックにあったデータを、CR3更新後にもアクセス可能な領域にコピーしているコードです。したがって、ROP中では次の0xffffffff81800e26の箇所にジャンプする必要があります。
今回の場合はswapgsの前にpop rax
とpop rdi
があります。
1 | 0xffffffff81800e89: pop rax |
先ほどの図でpush [rdi]; push rax
していた値がここでrax, rdiに戻されます。そして、swapgs
時点でのスタックは冒頭の
1 | 0xffffffff81800e32: push QWORD PTR [rdi+0x30] |
で構築されたもの(rdiは元のrsp)ですので、gadget呼び出しの0x10バイト先にswapgsで使うデータを置く必要があります。
1 | *chain++ = rop_bypass_kpti; |
この点に気をつけて、KPTIのもとでも動くkROPを自分の手で完成させてみてください。
KASLRの回避
ここまでKASLRを無効化してきましたが、KASLR有効だとexploit可能でしょうか。
KASLRのエントロピー
本題に入る前に、そもそもKASLRはどのように実装されているのでしょうか。
カーネルのアドレスランダム化はページテーブルレベルで行われ、kaslr.c
のkernel_randomize_memory
関数で実装されています。
カーネルは0xffffffff80000000から0xffffffffc0000000までの1GBのアドレス空間を確保しています。したがって、KASLRが有効でも0x810から0xc00までの、たかだか0x3f0通り程度のベースアドレスしか生成されません。
カーネル空間のASLRはユーザー空間よりも弱いんだね。
意外かもしれないけど、カーネルでは一度攻撃が失敗するとカーネルパニックになって総当りが現実的じゃないから、エントロピーが小さくても十分だよ。
アドレスリーク
ASLRを回避するのと同様に、Kernel ExploitでもKASLRを回避するためにはカーネル空間のアドレスリークが必要です。カーネルは全プログラムで共通なので、例えこのドライバに脆弱性がなくても、別のドライバやカーネル自体にアドレスリークの脆弱性がある場合、それを使えます。
今回はmodule_read
に範囲外読み込みの脆弱性があるため、これを利用しましょう。今まではmodule_write
のStack Overflowを悪用しましたが、module_read
にも同様のスタック上での脆弱性が存在します。
1 | static ssize_t module_read(struct file *file, |
スタック上の変数kbuf
にデータを入れていますが、copy_to_user
でコピーできるサイズは自由です。したがって、0x400バイトより多くのデータをスタックから読むことが可能です。スタック上にはリターンアドレスの他にも様々なデータがあるため、カーネルの関数やデータの一部を指したポインタが必ず存在します。これをリークすることで、カーネルがロードされたベースアドレスが計算でき、さらにcommit_creds
等の関数のアドレスも分かります。
まずはスタック上にKASLRのベースアドレスを特定できるアドレスが存在するかをgdbで確認します。基本的に、デバッグ中はKASLRを無効にしておきましょう。
0xffffffff81000000付近を指しているアドレスを探すと、上の図で0xffffc9000041beb0と0xffffc9000041bef0にそれぞれ0xffffffff8113d33cと0xffffffff8113d6e3が存在します。このアドレスが何かをkallsymsから調べましょう。ちょうどこのアドレスに合うシンボルは見つからないので、リターンアドレスなど関数の途中を指すポインタであると推測できます。下位数ビットを除外してgrepしてみると、次のようにいくつかヒットします。
vfs_read
やksys_read
関数の途中を指しているようです。いずれにせよFGKASLRは無効ですので、カーネルのベースアドレスからこのポインタまでのオフセットは固定です。今回は最初のvfs_read
を指しているポインタを利用します。
1 | /* Leak kernel base */ |
これでSMAP,SMEP,KPTI,KASLRすべて有効でも動作するexploitが書けます。ROP gadgetや各種関数に、リークしたカーネルのベースアドレスを使うように修正してみてください。次のようにKASLR有効でも権限昇格できれば成功です。
Exploitの例はここからダウンロードできます。
(1) SMAP無効 / SMEP無効 / KPTI有効
(2) SMAP有効 / SMEP無効 / KPTI無効
ヒント:ret2usrでシェルコードを実行する瞬間のレジスタの値を確認する。