この章では、eBPFの中でも検証器のバグを悪用して権限昇格に挑戦します。まずは、LK06 (Brahman)の配布ファイルを用意してください。
パッチの確認
今回は練習用にeBPFを脆弱にするため、検証器に対してバグを埋め込むパッチが適用されています。patch/verifier.diff
にその内容があるので、確認してみましょう。
1 | 7957c7957,7958 |
kernel/bpf/verifier.c
の7957行目[1]に変更が入っています。
scalar32_min_max_or
関数の冒頭で__mark_reg32_known
という関数が呼ばれていますが、パッチ適用後はコメントアウトされています。それ以外の変更はないので、この部分を詳しく見ていきましょう。
scalar32_min_max_or
を読む
変更が入っているscalar32_min_max_or
の呼び出し元はadjust_scalar_min_max_vals
です。この関数では、ADDやXORなどのALU演算後の宛先レジスタの範囲追跡を実装しています。
修正が入っているのはBPF_OR
です。
1 | case BPF_OR: |
まず、tnum_or
で宛先レジスタのvar_off
を更新しています。実装は単純で、ORするビットの両方が不明であれば、宛先も不明とします。片方のビットが不明な時でも、もう片方が1であればORした結果も必ず1になるため、maskの対応するビットは0になります。
1 | struct tnum tnum_or(struct tnum a, struct tnum b) |
例えば(mask=0xffff0000; value=0x1001)
と(mask=0xffffff00; value=0x2)
をORすると、(mask=0xffffef00; value=0x1003)
になります。
var_off
を更新したら、問題のscalar32_min_max_or
が呼ばれます。削除されている箇所はsrc_known
, dst_known
がtrueのときに到達します。
1 |
|
tnum_subreg_is_const
は、レジスタの下位32ビット部分が定数のときにtrueを返します。つまり、ORするレジスタの両方とも下位32ビットが定数のとき、本来は__mark_reg32_known
が呼ばれるはずでした。
__mark_reg32_known
は、定数のvar_off
を使ってs32_min_value
, s32_max_value
, u32_min_value
, u32_max_value
を更新します。
1 | static void __mark_reg32_known(struct bpf_reg_state *reg, u64 imm) |
パッチ中のコメントに「scalar_min_max_or
will handler the case」とあるので、scalar_min_max_or
も追ってみます。
1 |
|
基本的にはscalar32_min_max_or
の64ビット版です。ここで、両方とも64ビットの値が定数のとき、__mark_reg_known
が呼ばれます。__mark_reg_known
は、64ビット部分に加えて32ビットの範囲も定数に変更しています。
1 |
|
つまり、ORの64ビットレジスタが両方とも定数の場合、scalar32_min_max_or
で__mark_reg32_known
を呼ばなくても、後のscalar_min_max_or
で問題なく定数になるという仕組みです。
では、64ビットレジスタの上位32ビットが定数でない場合はどうでしょうか。scalar32_min_max_or
は即座にreturnしますが、scalar_min_max_or
で__mark_reg_known
は呼ばれません。
このとき、scalar_min_max_or
中の次のパスに到達します。
1 | /* We get our maximum from the var_off, and our minimum is the |
umin_value
, umax_value
, smin_value
, smax_value
を更新したあと、__update_reg_bounds
が呼ばれます。
1 | static void __update_reg_bounds(struct bpf_reg_state *reg) |
こちらでも32ビット、64ビットともに範囲を更新しています。ということは、パッチは不要な処理を削除しただけでしょうか?
__update_reg32_bounds
を読む
__update_reg32_bounds
の処理をよく見てみましょう。
1 | static void __update_reg32_bounds(struct bpf_reg_state *reg) |
__mark_reg32_known
が呼ばれていないため、32ビットのmin, maxは古い状態のままです。これを利用して更新されるmin, maxに不整合を起こせないでしょうか。簡単のために符号なしの場合を考えます。
1 | reg->u32_min_value = max_t(u32, reg->u32_min_value, (u32)var32_off.value); |
今、レジスタの下位32ビットはsrc, dstともに定数です。そのため、var32_off.mask
は0で、次のように書き直せます。
1 | reg->u32_min_value = max(reg->u32_min_value, var32_off.value); |
u32_min_value
とu32_max_value
は宛先レジスタの元の状態が引き継がれています。下位32ビットは定数である必要があるので、元のu32_min_value
とu32_max_value
はともにXという値だったとします。これに対して何かしらの定数YをORして、結果がX|Y
になります。すると、X|Y > X
のとき、
1 | reg->u32_min_value = max(X, X|Y); // min=X|Y |
となり、u32_min_value
がu32_max_value
よりも大きくなるという不整合が起きます。
バグの再現
簡単のためにX=0, Y=1として考えましょう。
まず、次のようなレジスタR1, R2を用意します。
1 | R1: var_off=(value=0; mask=0xffffffff00000000) |
これをBPF_OR(R1, R2)
でORした際の変化を見てみましょう。
var_off=(value=0xfffffffe00000001; mask=0x100000000)
u32_min_value = max(0, 1) = 1
u32_max_value = min(0, 1) = 0
これにより、32ビット部分の最小値が1、最大値が0という壊れたレジスタができます。実際にコードで確認しましょう。
1 | // BPFマップを作成 |
このプログラムをロードし、検証器のログverifier_log
を出力してみてください。
1 | / $ ./pwn |
14個目のOR命令で
1 | R1_w=Pscalar(...,s32_min=1,s32_max=0,u32_min=1,u32_max=0) |
と、範囲追跡が壊れていることが分かります。
このバグは実際にOR, AND, XOR命令で過去に存在していたものだよ。
アドレスリーク
今回のように、min_value > max_value
という条件ができた場合、それを悪用する方法はいくつかあります。まずはマップのアドレスリークに使ってみましょう。
eBPFではポインタに対するスカラー値の加減算が許可されています。ポインタとスカラー値の演算におけるオフセットのアップデートは、adjust_ptr_min_max_vals
に実装されています。
1 | static int adjust_ptr_min_max_vals(struct bpf_verifier_env *env, |
上記のコードを読むと、今回のようにスカラー値側の追跡が壊れているケースでは、演算結果を__mark_reg_unknown
で不明な値にしています。
つまり、追跡を壊したレジスタとポインタを加算すると、結果はスカラー値として扱われます。スカラー値はBPFマップに書き込めるので、アドレスリークが可能です。さっそくmap_lookup_elem
で取得したBPFマップのポインタをリークしてみましょう。
先ほどs32_min_value
などの推測を壊しましたが、上記コードではsmin_val
など64ビットレジスタが壊れている必要があります。32ビット値を64ビット値に拡張するには、x86-64と同じように、BPF_MOV32_REG
を使って32ビットのレジスタにコピーすれば良いです。
1 | ... |
次のようにBPFマップのアドレスがリークできていれば成功です。R1には1が入っていたので、加算により実際よりもアドレスが1ずれていることに注意してください。
このアドレスを見ると、正しく配列の先頭要素(リークしたデータ)が入っています。
0x110引いたところは、メタデータを含むBPFマップの先頭になります。今回は配列形式で作っているので、bpf_array
構造体が存在します。例えば先頭にある0xffffffff81c124a0という値は、bpf_map
構造体のops
という関数テーブルになります。今回は使いませんが、eBPFの攻撃ではこのops
を書き換えて権限昇格する手法もあります。
この方法はadjust_ptr_min_max_vals
のコードを知っていないと気づけませんが、実際にはこれを利用しなくてもexploitが書けます。(例題を参照)
マップのアドレスを持っていると以降のkASLRリークが簡単になるため、アドレスは持っておきましょう。マップのfdを渡すと(最後に1を引いた)アドレスを返してくれるように、関数化しておくとコードが綺麗になります。
root権限では標準でポインタのリークが許可されているから、eBPFのexploitをデバッグするときは必ず一般ユーザー権限での動作確認も忘れないでね。
範囲外参照
前章でも少し触れましたが、2022年現在はALU sanitationという緩和機構が入っているため、かつてのように単純には範囲外参照ができません。
しかし、まずは「単純な」範囲外参照を試してみましょう。
実は、ALU sanitationはbpf_bypass_spec_v1
という関数がtrueを返したときはスキップされます。この関数はroot権限ではtrueを返すので、root権限では今でも範囲外参照を試せます。
そこで、まずはroot権限で「単純な」範囲外参照を試してみましょう。
追跡が壊れた定数の作成
検証器の誤りを悪用する上で便利なのは、「検証器がXと思っているが実際にはYである定数(XX!=Y)」を作ることです。とくにX=0,Y!=0のときは、何を掛けても検証器は0と判断するので、範囲外参照のオフセットを作るのに都合が良いです。
まずは「検証器が0と思っているが実際には1である定数」を作ってみましょう。
今、R1はu32_min_value
が1でu32_max_value
が0になっています。反対に、R2にu32_min_value
が0でu32_max_value
が1である(壊れていない)値を入れます。ここでR1とR2の加算を考えると、範囲は[1,0]+[0,1]=[1,1]
になることが分かります。
最小値、最大値が同じレジスタは、MOVなどのタイミングで定数扱いになります。R1の実際値は1でしたが、R2は0か1を取ります。したがって、加算結果は[1,2]
でなければなりません。しかし、検証器は加算後のR1を定数1と判断してしまうため、実際には2が入っている状況が生まれます。
あとはR1から1を引けば、目的の「検証器は0と思っているが実際には1である定数」が作れます。
u32_min_value
が0でu32_max_value
が1であるR2は、論理・算術演算を組み合わせるか、条件分岐で1より大きいケースを捨てることで作成できます。
1 | // BPFマップを作成 |
このプログラムを実行した後のマップ(R1の実際の値)を確認してみると、1になっていることが分かります。一方、22番目の命令終了時点で検証器はR1に0が入っていると推測しています。
範囲外参照の確認
先ほどのコードで、追跡結果が定数0になるにも関わらず、実際には1を持つようなレジスタが作れました。このレジスタに適当な数を掛けて、マップのポインタに足すと、結果として範囲外を指す有効なポインタが作れます。
実際に試してみましょう。
次のBPFプログラムは、壊れたレジスタに0x100を掛けることで、「推測値=0 / 実際値=0x100」の状況を作り、それを使ってマップの範囲外をBPF_LDX_MEM
で読んでいます。
1 | int main() { |
このプログラムをroot権限で実行すると、次のように、設定した覚えのない値が読めていることが分かります。
実際にgdbで確認すると、マップのアドレスから0x100先にリークしたデータが存在します。
なお、0x100という定数をMOVで渡すと、検証器に範囲外参照を検知されることから、脆弱性に起因して範囲外参照を起こせていることが分かります。
しかし、一般ユーザー権限で同じプログラムを実行すると、ALU sanitationが加算による範囲外参照を0の加算に変換してしまうため、次のように何もデータがリークできません。(最初から入れていた値1が取り出されていることからも、ALU sanitationにより加算が意味を成していないことが分かります。)
ALU sanitationがない時代は、この手法でbpf_map構造体のopsなどを読み書きする攻撃が主流だったよ。
ALU sanitationの回避
幸いにも今回の対象であるカーネルv5.18.14では、ALU sanitationを回避する方法が存在します。考え方としては、ポインタへの(範囲外になる)加減算がパッチされてしまうので、既存のヘルパー関数にその処理をやってもらおうという発想になります。
一般ユーザーから使えるヘルパー関数は少ないですが、オフセットやサイズを引数に取る関数を調べてみましょう。すると、ソケットフィルタでは例えばskb_load_bytes
という関数が使えます。
1 |
|
この関数は、パケットの内容をBPF側(マップやスタック)にコピーできます。
第一引数をコンテキスト、第二引数をコピーしたいパケットデータのオフセット、第三引数をコピー先のバッファ、第四引数をコピーするサイズに指定します。コピー元はパケットデータなので、write
でソケットに送ったデータがコピーされます。
この関数を呼び出す際に引数が範囲外でないかを判断しますが、ALU sanitationの影響は受けません。したがって、関数内部で範囲外へのデータコピーが実現できます。
実際に試してみましょう。今、BPFマップのデータサイズは8なので、8バイト以上コピーできれば成功です。
試しに検証器が1と判断し、実際の値が0x10であるようなレジスタを作ります。write
で0x10バイト以上のデータを送り、それがマップにコピーされているかをgdbで確認します。(サイズとして0(と推測される値)を渡すと検証器に警告されるので注意)
1 | ... |
上のプログラムでは、BPFマップの0番目の要素(最初に取得したアドレスR9に保存している)に対してskb_load_bytes
でパケットデータを書き込みます。実際には0x10バイト書き込まれますが、検証器は1バイトと推測しているため許可されます。
プログラムを呼び出す際に、次のように0x10バイトのデータを送ってみましょう。
1 | char payload[0x10]; |
実行後にgdbでマップのアドレスを確認すると、下図のように、今回のデータサイズである8バイトを超えて範囲外書き込みできていることが分かります。
ヒープ上での範囲外書き込みが実現できているため、後はみなさんの好きな方法でexploitできるでしょう。例えばBPFマップを2つ並べて、後ろのマップのops
を書き換えるなどの方法が考えられます。
しかし、せっかくなので今回は、BPFの特徴を生かしてAAR/AAWを実現してみましょう。
AAR/AAWの作成
BPFのスタックにはポインタが書き込めることを思い出しましょう。スタックに保存したデータは型や範囲が追跡されています。
したがって、skb_load_bytes
を使ってスタック上で範囲外[2]書き込みをすれば、スタック上に保存されたポインタをパケットデータで上書きできます。上書きされた後も検証器はポインタとして認識しているため、偽のポインタを読み書きできます。
図で表すと、下のようになります。
最後に書き換えられたFP-0x18にあるデータはポインタとしてマークされているため、BPF_LDX_MEM
で取り出せばポインタとして扱えます。
このように、BPFの特徴を活かせば簡単にAAR/AAWを作ることができるのです。
AAR/AAWのPoC
1 | /** |
kASLRの回避と権限昇格
今、マップのアドレスを持っているため、bpf_map
のops
などを指す偽のポインタを作ることで、カーネルのベースアドレスがリークできます。また、ベースアドレスが取れたらAAWでmodprobe_path
などを書き換えて権限昇格できます。
各自でexploitを完成させましょう。exploitコードの例はここからダウンロードできます。
min_value > max_value
という状況が作れたため、adjust_ptr_min_max_vals
を悪用してBPFマップのアドレスをリークしました。(1) BPFマップやBPFスタック、コンテキストのアドレスをリークせずにexploitを完成させてください。
(2) さらに、
skb_load_bytes
によるヒープオーバーフローを利用して(BPFスタックを使わずに)exploitを完成させてください。