Kprobesで実行可能メモリの読み出しを検知してみる

Dynamic ROP(JIT-ROP)はlibcなどの実行可能メモリを読み出し、その中にあるgadgetを利用することでROPを行う。 これに対し、「x64で実行可能なメモリアドレスに対する入出力システムコールを検知してみる」では、ptraceでreadシステムコールを監視することで実行可能メモリの読み出しの検知を試みた。 ここでは、カーネルモジュールにてKprobesを利用し、システム全体に対しカーネル関数の呼び出しを監視することでこれの検知をやってみる。

環境

Ubuntu 14.04.1 LTS 64bit版

$ uname -a
Linux vm-ubuntu64 3.13.0-48-generic #80-Ubuntu SMP Thu Mar 12 11:16:15 UTC 2015 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 14.04.1 LTS
Release:        14.04
Codename:       trusty

$ gcc --version
gcc (Ubuntu 4.8.2-19ubuntu1) 4.8.2

Kprobesによるカーネル関数probe

KprobesはLinuxカーネルが提供するデバッグ機構であり、カーネル関数の実行を監視することができる。 Kprobesが提供するprobe機構にはkprobes、jprobes、kretprobesの3つがあり、それぞれ次のような違いがある。

  • kprobes: ハンドラ関数の引数としてレジスタの値が渡される
  • jprobes: ハンドラ関数の引数として対象となる関数の引数が渡される
  • kretprobes: 呼び出し時とリターン時のそれぞれにハンドラを登録でき、それぞれの時点でのレジスタの値が渡される

kretprobesでは呼び出し時とリターン時でデータを共有することができ、実行時間の計測などに利用することができる。

Linuxカーネルにおけるメモリの読み出しについて調べてみる

まず最初に、ユーザ空間のプログラムで特定のメモリアドレスに対しwriteシステムコールが呼ばれる状況を想定し、probeの対象となるカーネル関数について調べてみる。

writeシステムコールが呼ばれたとき、カーネル空間では次の関数が実行される。

514 SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
515                 size_t, count)
516 {
517         struct fd f = fdget(fd);
518         ssize_t ret = -EBADF;
519 
520         if (f.file) {
521                 loff_t pos = file_pos_read(f.file);
522                 ret = vfs_write(f.file, buf, count, &pos);
523                 if (ret >= 0)
524                         file_pos_write(f.file, pos);
525                 fdput(f);
526         }
527 
528         return ret;
529 }

スレッドセーフ版であるpwrite64システムコールも合わせて見てみると、vfs_write関数が共通して呼ばれていることがわかる。

551 SYSCALL_DEFINE4(pwrite64, unsigned int, fd, const char __user *, buf,
552                          size_t, count, loff_t, pos)
553 {
554         struct fd f;
555         ssize_t ret = -EBADF;
556 
557         if (pos < 0)
558                 return -EINVAL;
559 
560         f = fdget(fd);
561         if (f.file) {
562                 ret = -ESPIPE;
563                 if (f.file->f_mode & FMODE_PWRITE)  
564                         ret = vfs_write(f.file, buf, count, &pos);
565                 fdput(f);
566         }
567 
568         return ret;
569 }

vfs_write関数の内容は次のようになっている。

457 ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
458 {
459         ssize_t ret;
460 
461         if (!(file->f_mode & FMODE_WRITE))
462                 return -EBADF;
463         if (!file->f_op->write && !file->f_op->aio_write)
464                 return -EINVAL;
465         if (unlikely(!access_ok(VERIFY_READ, buf, count)))
466                 return -EFAULT;
467 
468         ret = rw_verify_area(WRITE, file, pos, count);
469         if (ret >= 0) {
470                 count = ret;
471                 file_start_write(file);
472                 if (file->f_op->write)
473                         ret = file->f_op->write(file, buf, count, pos);
474                 else
475                         ret = do_sync_write(file, buf, count, pos);
476                 if (ret > 0) {
477                         fsnotify_modify(file);
478                         add_wchar(current, ret);
479                 }
480                 inc_syscw(current);
481                 file_end_write(file);
482         }
483 
484         return ret;
485 }

上の内容より、vfs_write関数の中では出力先に応じたwrite関数が呼ばれていることがわかる。 そこで、ここではprobeの対象としてvfs_write関数を選ぶことにする。

カーネルモジュールを書いてみる

jprobeを用い、vfs_write関数の呼び出しを監視するカーネルモジュールを書くと次のようになる。

/* detect-x-read.c */
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/mm.h>

MODULE_LICENSE("GPL");  /* this line is required, otherwise you will get "Unknown symbol in module" error */

static ssize_t jprobe_vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
    unsigned long flags;
    struct vm_area_struct *vma, *vma_end;

    flags = 0;
    vma = find_vma(current->mm, (unsigned long)buf);
    vma_end = find_vma(current->mm, (unsigned long)buf + count);
    while (1) {
        flags |= vma->vm_flags;
        if (vma == vma_end) {
            break;
        }
        vma = vma->vm_next;
    }
    if (flags & VM_EXEC) {
        printk(KERN_INFO "reading executable memory detected: %s (pid %d), %p, %ld bytes\n", current->comm, current->pid, buf, count);
        if (count > 0x40) {
            count = 0x40;
        }
        print_hex_dump(KERN_INFO, "", DUMP_PREFIX_OFFSET, 16, 1, (char *)buf, count, 1);
    }
    jprobe_return();
    return 0;
}

static struct jprobe jp = {
    .entry = jprobe_vfs_write,
    .kp = { .symbol_name = "vfs_write" },
};

int init_module(void)
{
    int ret_val;
    ret_val = register_jprobe(&jp);
    if (ret_val < 0) {
        printk(KERN_ALERT "Registering jprobe failed with %d\n", ret_val);
        return ret_val;
    }
    printk(KERN_INFO "Installation jprobe succeeded\n");
    return 0;
}

void cleanup_module(void)
{
    unregister_jprobe(&jp);
}

このコードは、読み出し元アドレスとなるbufから終端のbuf + countまでのメモリページを調べ、実行可能なページがあれば引数の値と読み出されるデータのhexdumpをカーネルログに出力する。

Makefileを書き、カーネルモジュールをコンパイル、インストールしてみる。

# Makefile
obj-m += detect-x-read.o

all:
[TAB]make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
[TAB]make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
$ make
make -C /lib/modules/3.13.0-48-generic/build M=/home/user/tmp/detect-x-read modules
make[1]: Entering directory `/usr/src/linux-headers-3.13.0-48-generic'
  CC [M]  /home/user/tmp/detect-x-read/detect-x-read.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC      /home/user/tmp/detect-x-read/detect-x-read.mod.o
  LD [M]  /home/user/tmp/detect-x-read/detect-x-read.ko
make[1]: Leaving directory `/usr/src/linux-headers-3.13.0-48-generic'

$ sudo insmod detect-x-read.ko

$ dmesg | tail
[   13.292209] init: plymouth-upstart-bridge main process (167) killed by TERM signal
[   13.398474] init: plymouth-splash main process (1092) terminated with status 1
[   13.587164] Bridge firewalling registered
[   13.710926] nf_conntrack version 0.5.0 (7951 buckets, 31804 max)
[   14.945538] aufs 3.13-20140303
[   15.566302] IPv6: ADDRCONF(NETDEV_UP): docker0: link is not ready
[   16.115379] audit_printk_skb: 90 callbacks suppressed
[   16.115382] type=1400 audit(1428159176.976:42): apparmor="STATUS" operation="profile_replace" profile="unconfined" name="docker-default" pid=1277 comm="apparmor_parser"
[   44.297884] detect_x_read: module verification failed: signature and/or  required key missing - tainting kernel
[   44.301873] Installation jprobe succeeded

カーネルログにあらかじめ出力するようにしておいたメッセージが現れていることより、カーネルモジュールがインストールできていることがわかる。

Dynamic ROPによるlibcメモリの読み出しをやってみる

次に、カーネルモジュールがインストールされた状態でDynamic ROPをやってみる。 ここでは手順の簡略化のため、roputilsのサンプルコードを用いることにする。

$ git clone https://github.com/inaz2/roputils.git
Cloning into 'roputils'...
remote: Counting objects: 684, done.
remote: Total 684 (delta 0), reused 0 (delta 0), pack-reused 684
Receiving objects: 100% (684/684), 185.40 KiB | 230.00 KiB/s, done.
Resolving deltas: 100% (439/439), done.
Checking connectivity... done.

$ cd roputils/examples/

$ gcc -fno-stack-protector bof.c

$ python libc-dynamic-x86-64.py ./a.out 120
[+] read: '1\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x001\x04@\x00\x00\x00\x00\x00&\x06@\x00\x00\x00\x00\x0030FDevgY\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x18\x10`\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00(\x10`\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x10\x06@\x00\x00\x00\x00\x00RupPECbF\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00 \x10`\x00\x00\x00\x00\x00\x90\x01\x00\x00\x00\x00\x00\x00H\x14`\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x06@\x00\x00\x00\x00\x0036cQGDOm\x00\x00\x00\x00\x00\x00\x00\x00@\x14`\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xce\x05@\x00\x00\x00\x00\x00'
[+] len(data) = 199000
got a shell!
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$

Dynamic ROPによりシェルが起動できていることが確認できたので、カーネルログを調べてみる。

$ dmesg | tail
[  440.300030] 00000000: 0a                                               .
[  443.386455] reading executable memory detected: bash (pid 1332), 00000000004bab69, 1 bytes
[  443.386465] 00000000: 0a                                               .
[  444.129943] reading executable memory detected: bash (pid 1332), 00000000004bab69, 1 bytes
[  444.129952] 00000000: 0a                                               .
[  459.528744] reading executable memory detected: a.out (pid 1757), 00007f4bd5336dd0, 2097152 bytes
[  459.528754] 00000000: 41 56 41 55 41 54 55 48 89 cd 53 48 81 ec 90 00  AVAUATUH..SH....
[  459.528760] 00000010: 00 00 48 8b 05 4f d1 39 00 48 89 7c 24 18 89 74  ..H..O.9.H.|$..t
[  459.528764] 00000020: 24 14 48 89 54 24 08 48 85 c0 0f 84 cc 00 00 00  $.H.T$.H........
[  459.528768] 00000030: 8b 00 31 d2 85 c0 0f 94 c2 48 8d 05 70 d2 39 00  ..1......H..p.9.

Dynamic ROPが行われたa.outのプロセスにおいて、2097152(=0x200000)バイトの実行可能メモリの読み出しが検知できていることがわかる。

他のプログラムにおける検知状況を調べてみる

dmesgコマンドで表示されるカーネルログを調べてみると、上の他にも次のような検知ログが出力されていることがわかる。

[  435.721040] reading executable memory detected: git (pid 1724), 0000000000549174, 13 bytes
[  435.721051] 00000000: 63 61 70 61 62 69 6c 69 74 69 65 73 0a           capabilities.
[  436.123260] reading executable memory detected: git (pid 1724), 000000000054928d, 5 bytes
[  436.123270] 00000000: 6c 69 73 74 0a                                   list.
[  438.123169] reading executable memory detected: git (pid 1727), 0000000000542995, 4 bytes
[  438.123180] 00000000: 30 30 30 30                                      0000
[  439.327961] reading executable memory detected: git (pid 1727), 0000000000542995, 4 bytes
[  439.327971] 00000000: 30 30 30 30                                      0000
[  440.287954] reading executable memory detected: git (pid 1730), 0000000000529311, 1 bytes
[  440.287965] 00000000: 0a                                               .
[  440.295741] reading executable memory detected: git (pid 1724), 0000000000544200, 39 bytes
[  440.295752] 00000000: 23 20 70 61 63 6b 2d 72 65 66 73 20 77 69 74 68  # pack-refs with
[  440.295757] 00000010: 3a 20 70 65 65 6c 65 64 20 66 75 6c 6c 79 2d 70  : peeled fully-p
[  440.295760] 00000020: 65 65 6c 65 64 20 0a                             eeled .
[  440.300030] reading executable memory detected: git (pid 1724), 0000000000529311, 1 bytes
[  440.300030] 00000000: 0a                                               .
[  443.386455] reading executable memory detected: bash (pid 1332), 00000000004bab69, 1 bytes
[  443.386465] 00000000: 0a                                               .
[  444.129943] reading executable memory detected: bash (pid 1332), 00000000004bab69, 1 bytes
[  444.129952] 00000000: 0a                                               .

これらは主にメモリ属性がr-xとなっている.rodataセクションに含まれる文字列定数の読み出しにより検知されたものである。 このことから、Dynamic ROPの検知として実際に適用するには.textセクションなどのコード領域とELFヘッダや.rodataなどのデータ領域を別々のページにロードするよう、コンパイル時に使われるリンカスクリプトを変更する必要があることがわかる。

Execute-no-Read(XnR)

次の論文では、ここでのアプローチをさらに進め、アーキテクチャレベルで実行可能メモリの読み出しを禁止するというコンセプトをExecute-no-Read(XnR)として提案している。

XnRのコンセプトの厳密な実装にはCPUによるハードウェアサポートが必要とされるが、論文中ではpage fault handlerを利用したソフトウェアエミュレーションを行うことでその有効性が検証されている。

Uprobes、SystemTap

Kprobesはカーネル空間でのprobeを行うものであるが、関連するデバッグ機構としてユーザプロセスのprobeを行うUprobesがある。 また、SystemTapはこれらのprobe機構をユーザ空間から簡単に利用できるようにするプログラムである。

関連リンク