execveシステムコールの解明&eBPFでロードされたバイナリを読みたかった(失敗)

前置き

こちらの記事は2020年度のセキュリティ・ネクストキャンプの選考課題だった課題解決の姿勢についての問題で自分が実際に取り組んだ成果物を残すためのものです.

私は,eBPFを用いてファイルレスマルウェアや実行ファイルを削除するマルウェアがプログラムをメモリにロードする際の処理をトレースすることで,どのような処理を行なっているのかわかるのではないかという仮説をたて,その試みをやっていく前段階の疑問として,プログラムがどのようにしてメモリにロードされるのか調べ,その後実際にロードを行っている関数をeBPFによってフックしました.

先日GMOペパボ株式会社様のインターンでeBPFのツールの開発を行ったのですが,これはそれより少し前に取り組みました.

本題

ここからは実際に提出したものを少し編集したものです.


基本的にLinuxソースコードを読んで調査を行っていく.

まずプログラムを実行するシステムコールである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

という記述があり,execvesys_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__envpstruct user_arg_ptrに格納した後,do_execveat_common()という関数を呼び出している.

#define AT_FDCWD                -100    
/* Special value used to indicate openat should use the current working directory. */

AT_FDCWDinclude/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_FDCWDflagsに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_protmake_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_typeET_EXECであればelf_flagsMAP_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_EXECPROT_READのセグメントで,下がPROT_READPROT_WRITEのセグメントにバイナリをロードしているものと思われる.(実際,上のret elf_mmap()のアドレスが400000となっており,これはよく目にするスタートアドレス0x400000だと思われる.)

次に得られたアドレスの値を出力させようとしたが,0としか表示されず,プログラムのバイナリデータを見ることが出来なかった. 原因としては,何らかのアクセス制限機構などが働いていることも考えられるが,今のところ不明である.

まとめ

以上が取り組んだ内容である.その後できなかったことを含めて色々わかれば良いなと思いながら,インターンに参加した.

インターンの記事はこちら udon-yuya.hatenablog.com

セキュリティ・ネクストキャンプ頑張りたい.