LK06(Brahman)では、Linuxカーネルの機能の1つである、eBPFに含まれるJIT(検証器)のバグを攻撃します。この章では、まずBPFという機能と、その使い方について学びます。
BPF
eBPFについて説明する前に、その前身となるBPFについて説明します。
BPFは時代とともに利用用途が広がり、拡張が進みました。大幅な変更が入ってからのBPFをeBPF(extended BPF)、それ以前のBPFをcBPF(classic BPF)と区別して表記することもあります。しかし、現在のLinuxでは、内部的にはeBPFのみが利用されているため、本サイトでは明確に区別が必要ないときはeBPF/cBPFをまとめてBPFと呼びます。
BPFとは
BPF(Berkeley Packet Filter)とは、Linuxカーネルが持つ独自のRISC型仮想マシンです。ユーザー空間から渡されたコードをカーネル空間で実行するために用意されています。当然、任意のコードを実行されては危険なので、BPFに存在する命令セットは、演算や条件分岐といった安全な命令がほとんどです。しかし、メモリ書き込みやジャンプなどの、安全性が保証できない命令も含まれているため、バイトコードを受理する際に検証器を通します。これにより、(例えば無限ループに陥らないような)安全なプログラムのみ実行できます。
では、なぜここまでしてユーザー空間からカーネル空間でコードを実行する必要があるのでしょうか。
BPFは設計当初、パケットフィルタリングを目的に作られました。ユーザーがBPFコードをロードしておくと、通信パケットが発生したタイミングでBPFコードが実行され、フィルタリングに利用できます。現在ではパケットフィルタリング以外にも、実行トレースの取得や、seccompがシステムコールをフィルタする仕組みなどにもBPFが利用されています。
このように、パケットフィルタやseccompなど、さまざまな箇所でBPFが利用されるようになりました。しかし、毎回BPFバイトコードを解釈してエミュレートしていては、実行速度に難があります。そこで、検証器を通過したBPFバイトコードは、JIT(Just-in-Time)コンパイラにより、CPUが解釈できる機械語に変換されます。
JITコンパイラとは、プログラムの実行中など動的に、何かしらのコードをネイティブな機械語に変換してくれる機構を指します。例えばChromeやFirefoxなどのブラウザは、何回も呼び出されるJavaScript関数を見つけたら、それを機械語に変換して、以降は機械語側を実行することで高速化しています。LinuxカーネルのBPFにおいてJITコンパイラが利用されるかはオプション次第ですが、現在のLinuxカーネルでは標準でJITコンパイラが有効化されています。
整理すると、BPFコードが実行されるまでの流れは次のようになります。
- ユーザー空間からbpfシステムコールでBPFバイトコードがカーネル空間に渡される。
- バイトコードを実行しても安全かを、検証器が確かめる。
- 検証に成功したら、JITコンパイラでCPUに対応した機械語に変換する。
- イベントが発生したら、JITコンパイル後の機械語が呼ばれる。
イベントが発生すると、登録したBPF(チェックしたいイベント)の種類によって引数が渡されます。この引数をコンテキストと呼びます。BPFはその引数を処理をして、最終的に1つの返り値を返します。例えばseccompの場合、呼ばれようとしたシステムコールの番号やアーキテクチャの種類などが入った構造体が引数としてBPFプログラムに渡ります。BPFプログラム(seccomp filter)はシステムコール番号などをもとに、システムコールの実行を許可するかなどを判断し、返り値としてカーネルに受け渡します。この返り値を受け取ったカーネルは、システムコールを許可するか、拒否するか、それとも失敗させるかなどを判断できます。
seccompは今でもcBPFを使っているけど、カーネル内部ではeBPFしか使ってないから、最初にeBPFに変換されるよ。それから、seccompにはBPFの検証器に加えて独自の検証機構があるよ。
また、BPFプログラムとユーザー空間がやりとりするためにはBPFマップというものを使います。BPFではカーネル空間にマップという、key-valueペアの連想配列[1]を作れます。これについての詳細は、実際にBPFプログラムを書く際に見ていきます。
BPFのアーキテクチャ
より詳しくBPFの構造を見ていきましょう。cBPFは32ビットのアーキテクチャでしたが、eBPFでは近年のアーキテクチャに合わせて64ビットになり、レジスタの数も増えました。ここではeBPFのアーキテクチャを説明します。
レジスタとスタック
BPFプログラムでは512バイトのスタックを利用できます。eBPFでは、以下のレジスタが用意されています。
BPFレジスタ | 対応するx64のレジスタ |
---|---|
R0 | rax |
R1 | rdi |
R2 | rsi |
R3 | rdx |
R4 | rcx |
R5 | r8 |
R6 | rbx |
R7 | r13 |
R8 | r14 |
R9 | r15 |
R10 | rbp |
R10
以外のレジスタは、BPFプログラム中で汎用レジスタとして扱えますが、いくつか特殊な意味を持つレジスタがあります。
まず、カーネル側から渡されるコンテキスト(ポインタ)がR1
に入ります。BPFプログラムは通常、このコンテキストの内容を処理することになります。例えばソケットフィルタの場合、コンテキストからパケットデータを取り出すなどが可能です。
そして、R0
レジスタはBPFプログラムの戻り値として利用されます。そのため、BPFプログラムを終了(BPF_EXIT_INSN
)する前に必ずR0
に値を設定する必要があります。終了コードには意味があり、例えばseccompの場合はシステムコールを許可・拒否するかなどを表します。
次に、R1
からR5
は、カーネル中の関数(後述するヘルパー関数)をBPFプログラムから呼び出すときの引数レジスタとして利用されます。
最後に、R10
はスタックのフレームポインタで、読み込み専用となっています。
命令セット
一般ユーザーがロードするBPFプログラムは、最大4096命令[2]を使えます。
BPFはRISC型のアーキテクチャなので、すべての命令は同じサイズになっています。各命令は64ビットで、次のように各ビットが意味を持ちます。
ビット | 名前 | 意味 |
---|---|---|
0-7 | op |
オペコード |
8-11 | dst_reg |
宛先レジスタ |
12-15 | src_reg |
ソースレジスタ |
16-31 | off |
オフセット |
32-63 | imm |
即値 |
オペコードop
は、最初の4ビットがコード、次の1ビットがソース、残りの3ビットがクラスを表します。
クラスは命令の種類(メモリ書き込み、算術演算など)を指定します。ソースは、ソースオペランドがレジスタか即値かを決めます。そしてコードが、クラス中の具体的な命令番号を指定します。
BPFの命令セットはLinuxカーネルのドキュメントに記載されています。
プログラムタイプ
先の例で実際にBPFを試したときは、BPF_PROG_TYPE_SOCKET_FILTER
というタイプを指定しました。このように、BPFプログラムを何の用途で使うかを、ロード時に指定する必要があります。
cBPFではソケットフィルタとシステムコールフィルタの2種類しかありませんでしたが、eBPFでは20以上のタイプが用意されています。
タイプ一覧はuapi/linux/bpf.hに定義されています。
例えば、BPF_PROG_TYPE_SOCKET_FILTER
は、cBPFでも使えるソケットフィルタの用途です。BPFプログラムの戻り値によって、パケットをドロップするなどの操作が可能です。このタイプのBPFプログラムは、SO_ATTACH_BPF
オプションでsetsockopt
システムコールを呼ぶことで、ソケットにアタッチできます。
コンテキストとして__sk_buff
構造体が渡されます。
Linuxカーネルのsk_buff構造体をそのまま渡すとカーネルのバージョンに依存しちゃうから、BPF用に構造を揃えているよ。
ヘルパー関数
レジスタの項で少し説明があったように、BPFプログラムから呼び出せる関数があります。例えばソケットフィルタの場合、ベースとなるヘルパー関数に加えて4つの関数が提供されています。
1 | static const struct bpf_func_proto * |
ベースとなるヘルパー関数には、BPFマップを扱うmap_lookup_elem
やmap_update_elem
などがあります。各関数の具体的な使い方は、実際にBPFプログラムを書きながら学びましょう。
BPFの利用
それでは、実際にBPF(eBPF)を利用してみましょう。
LK06のマシン上でテストする場合は問題ありませんが、みなさんの使っているマシンでテストする場合は、まずBPFが一般ユーザーから使えるかを確認してください。この記事を書いた時点では、Spectreなどのサイドチャネル攻撃の防止のため、一般ユーザーからはBPFが利用できなくなっています。有効かは/proc/sys/kernel/unprivileged_bpf_disabled
から確認できます。
1 | $ cat /proc/sys/kernel/unprivileged_bpf_disabled |
この値が0ならCAP_SYS_ADMIN
を持っていないユーザーからもBPFが利用できます。1か2になっている場合は、一時的に0に書き換えましょう。
BPFプログラムの記述
パケットフィルタリングなどの複雑なコードを書く場合は、通常BCCのようなコンパイラを使って、C言語などより高級な言語で記述します。今回はexploit目的に軽く使うだけなので、コンパイラを使わずにBPFバイトコードを直接記述しましょう。直接といってもバイトコードを16進数で書く訳ではありません。アセンブリ言語のように、人間にわかりやすい形で書けるC言語用のマクロが用意されています。
まずは、このマクロが定義されたbpf_insn.hをダウンロードして、テスト用のCコードと同じフォルダに入れておきましょう。
まずは、何もしないBPFプログラムを実行してみます。
1 |
|
このコードでは、ソケットに対してBPFプログラムをロード(BPF_PROG_TYPE_SOCKET_FILTER
)します。そのため、最後のwrite
をトリガーとして、BPFプログラムが実行されます。
以下の部分がBPFプログラムになります。
1 | struct bpf_insn insns[] = { |
この例では、R0に64ビットの即値4を代入し、プログラムを終了します。正常に動作した場合、"Hell"と出力されるはずです。
レジスタについては後で詳しい説明がありますが、R0レジスタはBPFプログラムの戻り値として利用されます。今回write
で5文字送信したにも関わらず4文字しか受信できていないのは、BPFがパケットをドロップしたからです。つまり、戻り値によって送信データをカットできるわけです。実際に、socket
のマニュアルには次のように書かれています。
SO_ATTACH_FILTER (since Linux 2.2), SO_ATTACH_BPF (since Linux 3.19)
Attach a classic BPF (SO_ATTACH_FILTER) or an extended BPF (SO_ATTACH_BPF) program to the socket for use as a filter of incoming packets. A packet will be dropped if the filter program returns zero. If the filter program returns a nonzero value which is less than the packet’s data length, the packet will be truncated to the length returned. If the value returned by the filter is greater than or equal to the packet’s data length, the packet is allowed to proceed unmodified.
BPFマップの利用
ここまでで、BPFを使ってパケットをフィルタリングできることを確かめました。
次に、eBPFのexploitで必ずといって良いほど利用する、BPFマップを使ってみます。ユーザー空間(BPFプログラムをロードした側)と、カーネル空間で動くBPFプログラムがやりとりするために、BPFマップが利用されます。
BPFマップを作るには、BPF_MAP_CREATE
でbpf
システムコールを呼びます。このとき渡すbpf_attr
構造体は、タイプをBPF_MAP_TYPE_ARRAY
にして、配列のサイズやキー・値のサイズを指定します。exploitの文脈ではキーは小さいて良いので、キーはint型として固定します。
1 | int map_create(int val_size, int max_entries) { |
配列中の値の更新はBPF_MAP_UPDATE_ELEM
、取得はBPF_MAP_LOOKUP_ELEM
で実現できます。
1 | int map_update(int mapfd, int key, void *pval) { |
次のようなプログラムで動作を確認してみてください。マップの値を(ユーザー空間で)読み書きできていることが分かるでしょう。
1 | unsigned long val; |
さて、次にBPFマップをBPFプログラム側から操作してみます。
1 | /* BPFマップの用意 */ |
このBPFプログラムは、map_update_elem
ヘルパー関数を使って、BPFマップ中のキー1の値を0x1337に変更します。
まず、map_update_elem
にはキー・値ともにポインタを渡すので、メモリ上にキーと値を用意します。
1 | BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 1), // key=1 |
BPF_REG_FP
はR10
のことで、スタックポインタとなります。BPF_ST_MEM
は、馴染みのあるx86-64アセンブリで書くと、次のようになります。
1 | mov dword [rsp-0x08], 1 |
次に、引数を用意します。引数はBPF_REG_ARG1
から順に入れますが、これはR1
からのレジスタです。
map_update_elem
の第一引数はBPFマップのファイルディスクリプタです。BPF_LD_MAP_FD
を使ってレジスタに代入できます。
1 | // arg1: mapfd |
第二引数と第三引数は、それぞれキー、値へのポインタです。
1 | // arg2: key pointer |
第四引数はフラグですが、0を入れておきます。
1 | // arg4: flags |
最後にBPF_EMIT_CALL
を使ってヘルパー関数を呼び出せます。
1 | BPF_EMIT_CALL(BPF_FUNC_map_update_elem), // map_update_elem(mapfd, &k, &v) |
実行すると、BPFプログラムが発火するwrite
命令前後でBPFマップ中のキー1の値が変化していることが分かります。
1 | $ ./a.out |
ここまででBPFの基礎は終わりです。このように、BPFプログラミングでは、BPFマップやヘルパー関数を駆使してパケットフィルタなどが実装できます。
次の章では、BPF関連の脆弱性でもっとも重要となる検証器のお話をします。
skb_load_bytes
などのヘルパー関数を調べる。)(1) 送信データに"evil"という文字列が含まれていたらドロップする。
(2) 送信データサイズが4バイト以上の場合、先頭4バイトを"evil"に変更する。