※こちらの記事は2020年11月9日公開note版「ラック・セキュリティごった煮ブログ」と同じ内容です
デジタルペンテストサービス部のkjです。(自分担当の)ブログネタに困っていたのですが、むかーしXDPを少し触っていたことを思い出し、半ば無理やりeBPFとセキュリティを絡めてみました。
eBPFとは
もともと、「BPF」と呼ばれる仕組みがUnix系OSには存在します。広い意味ではネットワークパケットを高速処理する仕組みを指すことが多いと思います。パケットのフィルタリングなどは裏でBPFを利用しています。狭い意味では、その実装、仮想マシンのことを指します。BPFはカーネル空間で動作する仮想マシンとして実装されており、仮想マシンが動くことでネットワークパケットを処理しています。「カーネル空間で動く仮想マシン」というのは便利で、ネットワークパケット処理以外でも応用されており、プロセスが発行するシステムコールを制限する仕組み(seccomp mode 2)でも使用されています。
2014年ごろにはBPF自体が拡張されました。従来からある仕組みと区別するため、新しいものをeBPF、昔のものはcBPFと区別され呼ばれたりします。
eBPFが登場しさらに利用シーンが拡大しました。たとえば、XDPと呼ばれる、より高速・より柔軟にネットワークパケットを操作するための仕組みが登場しました。そのほか、eBPFがkprobe/uprobeやtracepointなどにアタッチできることから、近年ではパフォーマンス測定に使うシステムトレーシング基盤としての活用が非常に盛り上がっています。
この記事では、eBPFを利用するシステムトレーシングツールであるBCCで遊んでみます。
eBPFを試す
前述のとおり、eBPFはカーネル空間で動作する仮想マシンです。独自の命令セット/バイトコードを持っており、仮想マシン内で動かすプログラム(eBPFプログラム)も、eBPFバイトコードなバイナリである必要があります。
eBPFプログラムを作成するにはclangが利用できますが、今回はclangを直接使用するのではなく、より抽象化された形でeBPFを扱えるBCCのpythonバインディングを使用します。
(eBPFとのやり取りをpythonで記述できるというだけで、eBPFプログラム自体はCで書きます)
簡単なサンプルとして、execveをフックし、新たに生成されるプロセスを表示するプログラムを書いてみます。
# example1.py from bcc import BPF text=""" #include <uapi/linux/ptrace.h> int execve_hook(struct pt_regs *ctx) { char *pathname = (char*)PT_REGS_PARM2(ctx); if (pathname) { bpf_trace_printk("%s\\n", pathname); } return 0; } """ bpf = BPF(text=text) bpf.attach_kprobe(event=bpf.get_syscall_fnname("execve"), fn_name="execve_hook") while True: bpf.trace_print()
実行するとこんな感じになります。
$ sudo python example1.py b'<...>-1970255 [007] ...2 552673.760521: 0: /usr/bin/emacs' b'<...>-1970271 [000] ...2 552675.724799: 0: /bin/gcc' b'<...>-1970272 [011] ...2 552675.725698: 0: /bin/g++' b'<...>-1970273 [000] ...2 552675.753716: 0: /usr/bin/emacs' b'<...>-1970281 [011] ...2 552676.739648: 0: /bin/sh' b'<...>-1970278 [007] ...2 552676.740463: 0: /bin/whoami' b'<...>-1970283 [000] ...2 552676.740485: 0: /bin/sh' b'<...>-1970284 [011] ...2 552676.740945: 0: /bin/ip' b'<...>-1970285 [008] ...2 552676.741014: 0: /bin/grep' b'<...>-1970286 [005] ...2 552676.741215: 0: /bin/cut' b'<...>-1970287 [002] ...2 552676.741372: 0: /bin/cut' b'<...>-1970288 [007] ...2 552676.741432: 0: /bin/head' b'<...>-1970289 [003] ...2 552676.741613: 0: /bin/tail' b'<...>-1970280 [015] ...2 552676.742280: 0: /bin/basename'
example1.pyを実行するシェルとは別のシェルを開き、適当にコマンドを叩くと次々新しいプロセスが生成されていくのがわかります。何もして無くても結構いろんなプロセスが立ち上がってくのが見えて暇つぶしになります。
クセが強い
先の通り、簡単にexecveをフックするプログラムが書けました。カーネル自体に手を入れることや、kprobeを利用するカーネルモジュールを書くことに比べ遥かに簡単です。
が、もちろんカーネル空間で動くという特徴から、eBPFプログラムに暴れられると困ります。試しに適当なアドレスの値を書き換えるプログラムを実行してみます。
# example2.py from bcc import BPF text=""" #include <uapi/linux/ptrace.h> int execve_hook(struct pt_regs *ctx) { int *p = (int*)0x1234; *p = 0xabcd; return 0; } """ bpf = BPF(text=text) bpf.attach_kprobe(event=bpf.get_syscall_fnname("execve"), fn_name="execve_hook") while True: bpf.trace_print()
実行するとこうなります。
$ sudo python example2.py bpf: Failed to load program: Permission denied Unrecognized arg#0 type PTR ; int execve_hook(struct pt_regs *ctx) { 0: (b7) r1 = 4660 1: (b7) r2 = 43981 ; *p = 0xabcd; 2: (63) *(u32 *)(r1 +0) = r2 R1 invalid mem access 'inv' processed 3 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0 HINT: The invalid mem access 'inv' error can happen if you try to dereference memory without first using bpf_probe_read_kernel() to copy it to the BPF stack. Sometimes the bpf_probe_read_kernel() is automatic by the bcc rewriter, other times you'll need to be explicit. Traceback (most recent call last): File "example2.py", line 14, in <module> bpf.attach_kprobe(event=bpf.get_syscall_fnname("execve"), File "/usr/lib/python3.8/site-packages/bcc/__init__.py", line 679, in attach_kprobe fn = self.load_func(fn_name, BPF.KPROBE) File "/usr/lib/python3.8/site-packages/bcc/__init__.py", line 411, in load_func raise Exception("Failed to load BPF program %s: %s" % Exception: Failed to load BPF program b'execve_hook': Permission denied
エラーが出て実行出来ませんでした。これは、eBPFがeBPFプログラムの安全性を検証する仕組み(検証器)を持っているためです。この検証器のチェックをパスしないものは実行拒否されます。たとえば、メモリを直接参照するコード、無限ループ(になりうるコードも含む)、長いコード(=~命令数が多いコード)などは仮想マシンへロードすることができません。
bpf_probe_write_user?
検証器があるので、メモリを読むことはできても書き込むような処理は無理だろうなぁと思ってました。ところがBCCのコードを眺めていたら「bpf_probe_write_user」という関数を見つけました。どうやらユーザー空間で動いているプログラムのメモリに対し書き込みを行う関数のようです。
ためしに、bpf_probe_write_user関数を使い「A」と表示するプログラムを「B」と表示させるようにしてみます。
// example3.c #include <stdio.h> #include <unistd.h> char buf[] = "A\n"; // to store on .data int main() { fwrite(buf, 1, 2, stdout); return 0; }
# example3.py from bcc import BPF text = """ #include <uapi/linux/ptrace.h> int fwrite_hook(struct pt_regs *ctx, char *p) { char *buf = (char*)PT_REGS_PARM1(ctx); if (buf && *(u16*)buf == 0x0a41) { u16 b = 0x0a42; // "B\\n" bpf_probe_write_user(buf, &b, 2); } return 0; } """ bpf = BPF(text=text) bpf.attach_uprobe(name="/tmp/example3", sym="fwrite", fn_name="fwrite_hook") while True: bpf.trace_print()
実行すると以下のようになりました。
かけるんかーい
ファジングしてみる
bpf_probe_write_user関数を使いユーザー空間のプロセスメモリを書き換えることができました。さて、ここで
「メモリ書けるなら、手軽にフォルトインジェクションできるんでは?」
ということで、検証のために適当なプログラム&ファザーを書いてみます。
検証対象となるプログラム
まず、検証対象となるプログラムです。バグ(脆弱性)を持たせています。
// example4.c #include <stdint.h> #include <stdio.h> #include <stdlib.h> int main(int argc, char **argv) { FILE *fp = fopen(argv[1], "rb"); while (1) { uint8_t size = 0; fread(&size, sizeof(size), 1, fp); if (size == 0) { break; } uint8_t alloc_size = size + 1; // +1B for '\0' char *msg = (char *)malloc(alloc_size); if (!msg) { break; } size_t size_read = fread(msg, 1, size, fp); msg[size_read] = '\0'; puts(msg); free(msg); } fclose(fp); return 0; }
このプログラムは、引数で渡されたデータファイルの内容を読み込んで表示します。渡されるファイルは以下のデータ構造を持っています。
// データ構造 struct data { uint8_t size; // size of msg char msg[0]; };
データファイルには1個以上のdata構造体が含まれており、sizeメンバがmsgメンバの長さ(バイト数)表します。sizeメンバが0の場合はデータファイルの終端を表します。
プログラムはデータファイルを開いたのち、
1) まずsizeを読み取る
2) 次にmsgを読み込むメモリを確保する
3) 最後にmsgを読み取る
これをデータファイルの終端が現れるまで繰り返します。
とりあえず、適当なデータファイルを作って動かしてみます。(問題が発生した際に解りやすくするため、というか落ちてほしいのでasanを加えてます)
$ echo -e -n "\x05SYOYU\x04MISO\x08TONKOTSU" > data.bin $ gcc -g -fsanitize=address -o example4 ./example4.c $ ./example4 data.bin SYOYU MISO TONKOTSU $
ファザー
次に、ファザーです。
# example4.py from bcc import BPF text = """ #include <uapi/linux/ptrace.h> #include <linux/sched.h> static int is_example4() { char cur_comm[TASK_COMM_LEN+1] = {0}; bpf_get_current_comm(cur_comm, sizeof(cur_comm) - 1); return (cur_comm[0] == 'e' && cur_comm[1] == 'x' && cur_comm[2] == 'a' && cur_comm[3] == 'm' && cur_comm[4] == 'p' && cur_comm[5] == 'l' && cur_comm[6] == 'e' && cur_comm[7] == '4'); } struct fread_params { void *read_to; u64 bytes_to_read; }; BPF_HASH(FREAD_CTXS, u32, struct fread_params); static void set_fread_params(void *ptr, size_t size, size_t nmemb) { struct fread_params params = {ptr, size * nmemb}; u32 pid = bpf_get_current_pid_tgid(); FREAD_CTXS.update(&pid, ¶ms); } static struct fread_params* get_fread_params() { u32 pid = bpf_get_current_pid_tgid(); struct fread_params *params = FREAD_CTXS.lookup(&pid); FREAD_CTXS.delete(&pid); return params; } BPF_HASH(FUZZ_STATE, u32, u8); static u8* mutate_fuzz() { u32 key = 1; u8 init = 0; u8 *fuzz = FUZZ_STATE.lookup_or_try_init(&key, &init); if (!fuzz) { return NULL; } *fuzz += 1; FUZZ_STATE.update(&key, fuzz); return fuzz; } int fread_enter(struct pt_regs *ctx, void *ptr, size_t size, size_t nmemb, void *_unused_stream) { if (!is_example4()) { return 0; } set_fread_params(ptr, size, nmemb); return 0; } int fread_exit(struct pt_regs *ctx) { struct fread_params *params = get_fread_params(); if (!params || params->bytes_to_read != 1) { return 0; } int retval = PT_REGS_RC(ctx); if (retval != 1) { return 0; } u8 *fuzz = mutate_fuzz(); if (!fuzz) { return 0; } bpf_probe_write_user(params->read_to, fuzz, 1); bpf_trace_printk("fread_exit: pid=%d fuzz=%x\\n", bpf_get_current_pid_tgid(), *fuzz); return 0; } """ b = BPF(text=text) b.attach_uprobe(name="c", sym="fread", fn_name="fread_enter") b.attach_uretprobe(name="c", sym="fread", fn_name="fread_exit") while True: b.trace_print()
freadが実行される前後にフックをいれています。
freadの前に呼ばれるのは「fread_enter」関数です。ここでは、
● freadがexample4から呼ばれたことをチェックし(※)、
● example4から呼ばれた場合はfreadの引数(ptr、size、nmemb)をマップに保存※ ファジング対象以外のプロセスから呼び出されるfreadは影響を与えたくないため
します。
で、freadの後に呼ばれるのは「fread_exit」関数です。こっちでは、
● freadの読み取りバイト数が1バイトであることをチェックし(※)、
● 注入するデータ(ファズ)を「1」から順に生成(インクリメント)、
● freadが読み取ったデータをファズに差し替え※ sizeメンバ(uint8_t)の読み取りのみファズを注入したいため。
(厳密に考えると、size==1の場合はmsgも1バイトの読み取りになるためmsgにもファズが注入されてしまいますが、今回は無視します)
ます。注入したファズが分かるよう標準出力にも出してます。
動かしてみる
検証対象のプログラムとファザーができあがったので、混ぜ合わせます。example4がエラーを返すまで無限に走らせ続けます。
$ while true; do ./example4 data.bin; if [[ $? != 0 ]]; then break; fi done
実行すると、asanがエラーを検出しexample4が終了します。
ファザーの出力を見ると、ファズが0xffの場合にヒープバッファーオーバーフローが発生し落ちているようです。なんで?
デバッグもeBPFで
とりあえずデバッグ用のデータを作成します。「a」が0xff個続くデータを作ります。
$ perl -e 'print "\xff" . ("a" x 0xff) . "\x00"' > test.bin
次に、メモリを確保してるmallocの引数と戻り値を見るためprintfデバッグをします。のではなく、せっかくなのでeBPFを使ってmallocの引数と戻り値を見てみます。
まず、単にexample4の実行時に呼ばれるmallocをフックしてみます。
# example5.py from bcc import BPF text = """ #include <uapi/linux/ptrace.h> #include <linux/sched.h> static int is_example4() { char cur_comm[TASK_COMM_LEN+1] = {0}; bpf_get_current_comm(cur_comm, sizeof(cur_comm) - 1); return (cur_comm[0] == 'e' && cur_comm[1] == 'x' && cur_comm[2] == 'a' && cur_comm[3] == 'm' && cur_comm[4] == 'p' && cur_comm[5] == 'l' && cur_comm[6] == 'e' && cur_comm[7] == '4'); } BPF_HASH(MALLOC_SIZE, int, size_t); int malloc_enter(struct pt_regs *ctx, size_t size) { if (!is_example4()) { return 0; } int key = 1; MALLOC_SIZE.update(&key, &size); return 0; } int malloc_exit(struct pt_regs *ctx) { if (!is_example4()) { return 0; } int key = 1; size_t* alloc_size = MALLOC_SIZE.lookup(&key); if (!alloc_size) { return 0; } void *mem = (void*)PT_REGS_RC(ctx); bpf_trace_printk("malloc(%x) = %x\\n", *alloc_size, mem); return 0; } """ bpf = BPF(text=text) bpf.attach_uprobe(name="c", sym="malloc", fn_name="malloc_enter") bpf.attach_uretprobe(name="c", sym="malloc", fn_name="malloc_exit") while True: bpf.trace_print()
実行するとこうなります。
$ sudo python ./example5.py b'<...>-2935745 [000] ...2 715391.106030: 0: malloc(1d8) = 8bfe42a0' b'<...>-2935745 [000] ...2 715391.106035: 0: malloc(1d8) = 8bfe42a0' b'<...>-2935745 [000] ...2 715391.106043: 0: malloc(1000) = 8bfe4480' b'<...>-2935745 [000] ...2 715391.106047: 0: malloc(0) = 8bfe5490'
検証用のデータには1つのdata構造体しか存在しないはずなのに、mallocが複数回呼ばれています。これはfopenやfreadの中でmallocが呼ばれているためです。「見りゃ解るだろ最後が怪しい」というのはダメです。表示を改善します。
今調べたいのは、「freadがsizeメンバを読み取った後に呼ばれるmallocの引数と戻り値」です。この要求をコードに落とすと↓になります。
# example6.py from bcc import BPF text = """ #include <uapi/linux/ptrace.h> #include <linux/sched.h> static int is_example4() { char cur_comm[TASK_COMM_LEN+1] = {0}; bpf_get_current_comm(cur_comm, sizeof(cur_comm) - 1); return (cur_comm[0] == 'e' && cur_comm[1] == 'x' && cur_comm[2] == 'a' && cur_comm[3] == 'm' && cur_comm[4] == 'p' && cur_comm[5] == 'l' && cur_comm[6] == 'e' && cur_comm[7] == '4'); } BPF_HASH(MALLOC_HOOK, int, int); static void enable_malloc_hook() { if (!is_example4()) { return; } int key = 1, enable = 1; MALLOC_HOOK.update(&key, &enable); } static void disable_malloc_hook() { if (!is_example4()) { return; } int key = 1, disable = 0; MALLOC_HOOK.update(&key, &disable); } static int is_hook_enabled() { if(!is_example4()) { return 0; } u32 key = 1; u32 *enabled = MALLOC_HOOK.lookup(&key); if (!enabled) { return 0; } return *enabled; } struct fread_params { void *read_to; u64 size_to_read; }; BPF_HASH(FREAD_CTXS, int, struct fread_params); static void set_fread_params(void *ptr, size_t size, size_t nmemb){ struct fread_params params = {ptr, size * nmemb}; int key = 1; FREAD_CTXS.update(&key, ¶ms); } static struct fread_params* get_fread_params() { int key = 1; struct fread_params *params = FREAD_CTXS.lookup(&key); FREAD_CTXS.delete(&key); return params; } int fread_enter(struct pt_regs *ctx, void *ptr, size_t size, size_t nmemb) { if (!is_example4()) { return 0; } set_fread_params(ptr, size, nmemb); return 0; } int fread_exit(struct pt_regs *ctx) { if (!is_example4()) { return 0; } struct fread_params *params = get_fread_params(); if (!params) { return 0; } if (params->size_to_read == 1) { enable_malloc_hook(); } int size_read = PT_REGS_RC(ctx); bpf_trace_printk("fread(ptr=%x, size_to_read=%x) = %x\\n", params->read_to, params->size_to_read, size_read); return 0; } BPF_HASH(MALLOC_SIZE, int, size_t); int malloc_enter(struct pt_regs *ctx, size_t size) { if (!is_hook_enabled()) { return 0; } int key = 1; MALLOC_SIZE.update(&key, &size); return 0; } int malloc_exit(struct pt_regs *ctx){ if (!is_hook_enabled()) { return 0; } disable_malloc_hook(); int key = 1; size_t* alloc_size = MALLOC_SIZE.lookup(&key); if (!alloc_size) { return 0; } void *mem = (void*)PT_REGS_RC(ctx); bpf_trace_printk("malloc(%x) = %x\\n", *alloc_size, mem); return 0; } """ bpf = BPF(text=text) bpf.attach_uprobe(name="c", sym="fread", fn_name="fread_enter") bpf.attach_uretprobe(name="c", sym="fread", fn_name="fread_exit") bpf.attach_uprobe(name="c", sym="malloc", fn_name="malloc_enter") bpf.attach_uretprobe(name="c", sym="malloc", fn_name="malloc_exit") while True: bpf.trace_print()
example6.pyでは、
● freadの呼び出し前に引数を保存、
● freadの呼び出し後に、引数で渡された読み取りバイト数が1バイトの場合mallocのフックを有効化、ついでに見やすくするためfreadの引数と呼び出し結果を出力、
● mallocの呼び出し前に引数を保存、
● mallocの呼び出し後に、フックを無効化、mallocの引数と呼び出し結果を出力
しています。
実行結果はこうなります。
$ example6.py b'<...>-2952260 [006] ...2 718123.584444: 0: fread(ptr=882f4896, size_to_read=1) = 1' b'<...>-2952260 [006] ...2 718123.584453: 0: malloc(0) = ef299490' b'<...>-2952260 [006] ...2 718123.584457: 0: fread(ptr=ef299490, size_to_read=ff) = ff'
mallocの呼び出しが一つに絞られました。さらに、freadの呼び出しも表示するようにしたのでコンテキストが分かりやすくなりました。何が起きたかというと、
1) 一番目のfreadでsizeメンバを読み取り、
2) 次にmallocがサイズ0で呼び出される。
戻り値がNULLではないことからメモリの確保が成功している。
3) 最後に2)で確保した領域へfreadで0xffバイト読み込む。
↑ここでヒープバッファーオバーフロー
ヒープバッファーオーバーフローが発生した原因は「mallocが0バイトのメモリを確保し、その領域にコピーが発生したから」だと解りました。
example4.cを見直してほしいのですが、msgの領域を確保する際、sizeに終端文字のため1バイトを足しています。
// example4.c 抜粋 uint8_t alloc_size = size + 1; // +1B for '\0'
sizeはuint8_t型であり、uint8_t型が表現可能なのは0~255(0xff)までです。sizeに0xffが代入された状態で+1されるとインテジャーオーバーフローが発生し、値がラップアラウンドされます。その結果、alloc_sizeには0が代入されてしまいます。
バグが特定できました。よかったよかった。
まとめ
いかがでしたか?
eBPF(BCC)を使ってファジング(?)を行ってみましたが、ファザーを作るにはあまり向いてなさそうですね!
どうしてもeBPFプログラムは検証器に制限されることが多く、普段書くCのプログラムとは勝手が違います。(そりゃそう)。実用的なファザーを書くのは難しいですね。
あまりeBPFの良い紹介記事になりませんでしたが、eBPF自体はとても便利な仕組みです。既存のコードにレイテンシを測る処理を埋め込むだとか、perfの結果と睨めあうとか、そういう場面は未だにあると思いますが、eBPFを使えば測りたいところだけをちょっとしたコードを書くだけで計測できます。とにかく、その「手軽さ」と「プログラマブルさ」は強力で、パフォーマンス解析等で苦しんだ経験からするととても魅力的に感じます。
また、プログラムの動作を調べるようなケースでも、strace・ltraceなどと比べ「プログラマブルさ」が生きてきます(straceも十分強力ですが...)。デバッグの例で示したように、特定のコンテキストにおける関数呼び出しを「狙ってフック」し調査できるというのはとても便利です。
近年のeBPF関連の盛り上がり具合はすさまじく、冒頭で紹介した例以外にもDDoS対策やコンテナのセキュリティーモニタリング等ですでに実用されており今後の動向に注目しています。