execveシステムコールの解明&eBPFでロードされたバイナリを読みたかった(失敗)
前置き
こちらの記事は2020年度のセキュリティ・ネクストキャンプの選考課題だった課題解決の姿勢についての問題で自分が実際に取り組んだ成果物を残すためのものです.
私は,eBPFを用いてファイルレスマルウェアや実行ファイルを削除するマルウェアがプログラムをメモリにロードする際の処理をトレースすることで,どのような処理を行なっているのかわかるのではないかという仮説をたて,その試みをやっていく前段階の疑問として,プログラムがどのようにしてメモリにロードされるのか調べ,その後実際にロードを行っている関数をeBPFによってフックしました.
先日GMOペパボ株式会社様のインターンでeBPFのツールの開発を行ったのですが,これはそれより少し前に取り組みました.
本題
ここからは実際に提出したものを少し編集したものです.
まずプログラムを実行するシステムコールであるexecveのソースコードの調査を行う.
~/linux/arch/x86
以下でexecve
をキーワードにして検索してみる
vagrant@vagrant:~/linux/arch/x86$ grep -r execve . ./include/asm/elf.h: /* ax gets execve's return value. */ ./include/asm/mmu_context.h: * and on mm's that are brand-new, like at execve(). ./xen/mmu_pv.c: /* pgd may not be pinned in the error exit path of execve */ ./um/sys_call_table_64.c:#define stub_execve sys_execve ./um/sys_call_table_64.c:#define stub_execveat sys_execveat ・・・
上の中で
./entry/syscalls/syscall_64.tbl:59 64 execve sys_execve
という記述があり,execve
はsys_execve
というものがエントリーポイントとして設定されていることがわかった.
それを踏まえて ~/linux
下で
vagrant@vagrant:~/linux$ grep -r sys_execve .
とすると,
./fs/exec.c: * sys_execve() executes a new program.
という記述が見つかったが,見てみるとこの部分はコメントで実際にはsys_execve()
の定義がなかったため少しググってみると,LinuxのシステムコールはSYSCALL_DEFINEnマクロ
を用いて定義され,実際にfs/exec.c
に
SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user *, envp) { return do_execve(getname(filename), argv, envp); }
という記述があり,do_execve()
という関数を読んでいることが分かる.
static int do_execve(struct filename *filename, const char __user *const __user *__argv, const char __user *const __user *__envp) { struct user_arg_ptr argv = { .ptr.native = __argv }; struct user_arg_ptr envp = { .ptr.native = __envp }; return do_execveat_common(AT_FDCWD, filename, argv, envp, 0); }
do_execve()
では__argv
と__envp
をstruct user_arg_ptr
に格納した後,do_execveat_common()
という関数を呼び出している.
#define AT_FDCWD -100 /* Special value used to indicate openat should use the current working directory. */
AT_FDCWD
はinclude/uapi/linux/fcntl.h
に定義されていて,コメントからopenat
が現在の作業ディレクトリを使用することを示すために使用される特別な値であることが分かる.
static int do_execveat_common(int fd, struct filename *filename, struct user_arg_ptr argv, struct user_arg_ptr envp, int flags) { /* 略 */ retval = bprm_execve(bprm, fd, filename, flags); /* 略 */ }
fd
にはAT_FDCWD
,flags
に0が格納されている.
do_execveat_common()
中ではいくつか引数や,フラグの検査などが行われた後,bprm_execve()
という関数を読んでいることが分かった.
static int bprm_execve(struct linux_binprm *bprm, int fd, struct filename *filename, int flags) { /* 略 */ file = do_open_execat(fd, filename, flags); /* 略 */ bprm->file = file; /* 略 */ retval = exec_binprm(bprm);
bprm_execve()
中でもいくつかの処理が行われていたが,その中でも上の3つがどのようにプログラムがロードされるのかという点で重要だと感じた.
まず,do_open_execat()
でファイルが開かれ,戻り値がbprm->file
に格納され,そのbprm
を引数にexec_binprm()
が呼ばれている.
static int exec_binprm(struct linux_binprm *bprm) { /* 略 */ ret = search_binary_handler(bprm);
exec_binprm()
ではsearch_binary_handler()
という関数が呼ばれていた.
static int search_binary_handler(struct linux_binprm *bprm) { /* 略 */ list_for_each_entry(fmt, &formats, lh) { if (!try_module_get(fmt->module)) continue; read_unlock(&binfmt_lock); retval = fmt->load_binary(bprm); read_lock(&binfmt_lock); put_binfmt(fmt); if (bprm->point_of_no_return || (retval != -ENOEXEC)) { read_unlock(&binfmt_lock); return retval; } }
list_for_each_entry()
の部分の処理が何を行っているかわからなかったため,調べてみるとformats
リストに登録されたハンドラの中から,ファイルにマッチするものを選んで実行しているようで,ファイルのフォーマットがELFならば,
static struct linux_binfmt elf_format = { .module = THIS_MODULE, .load_binary = load_elf_binary, .load_shlib = load_elf_library, .core_dump = elf_core_dump, .min_coredump = ELF_EXEC_PAGESIZE, };
というハンドラが呼び出される.
つまりfmt->load_binary()
はload_elf_binary()
という関数が呼び出される.
このことからsearch_binary_handler()
はファイルのフォーマットに対応したハンドラを見つける関数であると思われる.
/* Now we do a little grungy work by mmapping the ELF image into the correct location in memory. */ for(i = 0, elf_ppnt = elf_phdata; i < elf_ex->e_phnum; i++, elf_ppnt++) { int elf_prot, elf_flags; unsigned long k, vaddr; unsigned long total_size = 0; if (elf_ppnt->p_type != PT_LOAD) continue; /* 略 */ elf_prot = make_prot(elf_ppnt->p_flags, &arch_state, !!interpreter, false); elf_flags = MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE; vaddr = elf_ppnt->p_vaddr; /* * If we are loading ET_EXEC or we have already performed * the ET_DYN load_addr calculations, proceed normally. */ if (elf_ex->e_type == ET_EXEC || load_addr_set) { elf_flags |= MAP_FIXED; } else if (elf_ex->e_type == ET_DYN) { /* 略 */ } error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt, elf_prot, elf_flags, total_size);
上のコードはload_elf_binary()
の定義の中の一節である.この部分では最初のコメントにあるように,
ELFイメージをメモリにマッピングしているようだ.
elf_phdata
というのがELFのプログラムヘッダを指す変数でforループでプログラムヘッダのタイプそれぞれに対して処理を行っている.このforループでは,プログラムヘッダのタイプがPT_LOAD
ではない場合スキップされる.
elf_prot
はmake_prot()
でプログラムヘッダのフラグに応じて,read属性,write属性,exec属性を付与されている.
static inline int make_prot(u32 p_flags, struct arch_elf_state *arch_state, bool has_interp, bool is_interp) { int prot = 0; if (p_flags & PF_R) prot |= PROT_READ; if (p_flags & PF_W) prot |= PROT_WRITE; if (p_flags & PF_X) prot |= PROT_EXEC; return arch_elf_adjust_prot(prot, arch_state, has_interp, is_interp); }
elf_flagsとvaddrは以下のように設定されている.
elf_flags = MAP_PRIVATE | MAP_DENYWRITE | MAP_EXECUTABLE; vaddr = elf_ppnt->p_vaddr;
if (elf_ex->e_type == ET_EXEC || load_addr_set) { elf_flags |= MAP_FIXED; } else if (elf_ex->e_type == ET_DYN) { /* 略 */ }
次の部分ではオブジェクトファイルのタイプによって処理を行っている.
今回は実行ファイルを前提に処理を追っていきたいため,上の部分だけを考慮する.
e_type
がET_EXEC
であればelf_flags
にMAP_FIXED
を立てる.これはマッピング時に指定されるアドレスをそのまま使用して配置することを意味するそうだ.
またET_DYN
は共有オブジェクトを意味し,その場合,バイナリのランダム化のための値をload_bias
に保存し,ELFマッピングの全体のサイズをtotal_size
に保存することがソースのコメントからわかった.
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt, elf_prot, elf_flags, total_size);
最後にelf_map()
という関数が実行されている.
おそらくこれはmmap
というシステムコールを使った関数であると名前から推測される.
mmap
システムコールはファイルやデバイスを仮想アドレス空間にマッピングするシステムコールであり,マッピングとは割り当てるだけで実際にデータのコピーが発生することがない(おそらく)ため,高速にファイルの読み書きが行えるようである.
以上の調査からプログラムが実行された時,プログラムがメモリにロードされる手順は以下のように行われることがわかった.
1.execve
システムコールが呼ばれると,SYSCALL_DEFINEnマクロ
によりdo_execve()
が実行され,以降いくつかの関数の中で各種設定や引数,フラグの検査などが行われた後,実行ファイルのフォーマットに合わせたバイナリハンドラが実行される.
2.ELFの場合,ハンドラはload_elf_binary()
であり,その中でプログラムヘッダが指し示すセグメントごとに仮想メモリ空間にマッピングされる.
以上の調査から,おそらくmmap
システムコールをフックすることでバイナリのメモリロードを覗き見ることができると予測し,
以下のeBPFのためのコードを作成した.
from bcc import BPF bpf_code = """ #include <uapi/linux/ptrace.h> #include <linux/sched.h> #include <linux/fs.h> int kprobe_mmap(struct pt_regs *ctx) { int length = (int)PT_REGS_PARM2(ctx); int prot = (int)PT_REGS_PARM3(ctx); int fd = (int)PT_REGS_PARM5(ctx); u64 id = bpf_get_current_pid_tgid(); bpf_trace_printk("mmap():\\n"); bpf_trace_printk(" + pid: %d\\n", id); bpf_trace_printk(" + length: %d\\n", length); bpf_trace_printk(" + prot: %d\\n", prot); bpf_trace_printk(" + fd: %d:\\n", fd); return 0; } int kretprobe_mmap(struct pt_regs *ctx) { u64 id = bpf_get_current_pid_tgid(); void *addr = (void *)PT_REGS_RC(ctx); bpf_trace_printk("ret mmap():\\n"); bpf_trace_printk(" + pid: %d\\n", id); bpf_trace_printk(" + addr: %x\\n", addr); return 0; } """ bpf = BPF(text = bpf_code) sysmmap_name = bpf.get_syscall_fnname("mmap") print(sysmmap_name, sysopenat_name) bpf.attach_kprobe(event = sysmmap_name, fn_name = "kprobe_mmap") bpf.attach_kretprobe(event = sysmmap_name, fn_name = "kretprobe_mmap") bpf.trace_print()
簡単に説明すると,mmapシステムコールの入り口と出口にフックポイントを設け,入り口ではファイルディスクリプタfd
と,メモリ属性prot
,サイズlength
を,出口では結果のアドレスaddr
を取得して出力するプログラムとなっている.これを実際に動かし,その後以下のような,何も行わない実験プログラムを実行してみた.
int main() { return 0; }
vagrant@vagrant:~/ebpf$ file ./test ./test: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=b9ccc761cfe472e9b2d62c55cf3ec3c66cbbe58f, not stripped
実験プログラムを実行した結果,eBPFプログラムは何も出力を行わなかった.何も出力しないということは,mmapシステムコールが呼ばれていないということになる. そこであることに気づいた.
システムコールとはユーザ空間のアプリケーションがOSの機能を使用するために,カーネルに対して発行するものであり,システムコールが発行されると,処理はカーネルへと移り,カーネル空間で処理が行われる.その中でファイルディスクリプタが示すデータをメモリにマッピングする必要が出たとしても,既にカーネル内で処理を行っているため,システムコールを発行する必要はなく,続けてその処理を行うのではないか.
そこで次は,elf_map()の入り口の出口にフックポイントを設けて,同じように監視するeBPFプログラムを作成した.
from bcc import BPF bpf_code = """ #include <uapi/linux/ptrace.h> #include <linux/sched.h> #include <linux/fs.h> int kprobe__elf_map(struct pt_regs *ctx) { int length = (int)PT_REGS_PARM5(ctx); int prot = (int)PT_REGS_PARM4(ctx); u64 id = bpf_get_current_pid_tgid(); bpf_trace_printk("elf_map():\\n"); bpf_trace_printk(" + pid: %d\\n", id); bpf_trace_printk(" + length: %d\\n", length); bpf_trace_printk(" + prot: %d\\n", prot); return 0; } int kretprobe__elf_map(struct pt_regs *ctx) { u64 id = bpf_get_current_pid_tgid(); void *addr = (void *)PT_REGS_RC(ctx); bpf_trace_printk("ret elf_mmap():\\n"); bpf_trace_printk(" + pid: %d\\n", id); bpf_trace_printk(" + addr: %x\\n", addr); return 0; } """ bpf = BPF(text = bpf_code) bpf.trace_print()
(kprobe__[カーネル関数]
とすることで,bpf.attach_kprobe()
としなくてもアタッチされることを知った.)
このeBPFプログラムを実行し,先ほどの何も行わない実験プログラムを動かすことで今度は以下のような結果が出力された.
b' test-14049 [000] .... 28160.483994: 0x00000001: elf_map():' b' test-14049 [000] .N.. 28160.484006: 0x00000001: + pid: 14049' b' test-14049 [000] .N.. 28160.484006: 0x00000001: + length: 6162' b' test-14049 [000] .N.. 28160.484007: 0x00000001: + prot: 5' b' test-14049 [000] d... 28160.486370: 0x00000001: ret elf_mmap():' b' test-14049 [000] d... 28160.486377: 0x00000001: + pid: 14049' b' test-14049 [000] d... 28160.486378: 0x00000001: + addr: 400000' b' test-14049 [000] .N.. 28160.486390: 0x00000001: elf_map():' b' test-14049 [000] .N.. 28160.486390: 0x00000001: + pid: 14049' b' test-14049 [000] .N.. 28160.486391: 0x00000001: + length: 6162' b' test-14049 [000] .N.. 28160.486391: 0x00000001: + prot: 3' b' test-14049 [000] d... 28160.487459: 0x00000001: ret elf_mmap():' b' test-14049 [000] d... 28160.487464: 0x00000001: + pid: 14049' b' test-14049 [000] d... 28160.487465: 0x00000001: + addr: 6b6000'
prot
フラグのビットの順番の定義がわからなかったため,確かではないが,
恐らく上のelf_map()
がPROT_EXEC
とPROT_READ
のセグメントで,下がPROT_READ
とPROT_WRITE
のセグメントにバイナリをロードしているものと思われる.(実際,上のret elf_mmap()
のアドレスが400000
となっており,これはよく目にするスタートアドレス0x400000
だと思われる.)
次に得られたアドレスの値を出力させようとしたが,0としか表示されず,プログラムのバイナリデータを見ることが出来なかった. 原因としては,何らかのアクセス制限機構などが働いていることも考えられるが,今のところ不明である.
まとめ
以上が取り組んだ内容である.その後できなかったことを含めて色々わかれば良いなと思いながら,インターンに参加した.
インターンの記事はこちら udon-yuya.hatenablog.com
セキュリティ・ネクストキャンプ頑張りたい.