Seal /proc/self/exe to protect against CVE-2019-5736 - containers/youki を見て /proc/self/exe まわりで気になったことがあったので個人的に落ち穂拾いをしたときのメモです。

脆弱性自体の内容の解説や PoC については元のプルリクエストでまとめられているのでここでは省略します。 ざっくりいうと OCI ランタイム (e.g. runc, youki) に対する脆弱性であり、悪意のあるコンテナの起動を介してホスト側で任意のコードを実行できるというものです。 詳しい内容は 発見者のブログこちらの PoC と共に見ると理解しやすいかと思います。

この手法の一つの肝はコンテナのエントリポイントに /proc/self/exe が指定されるようになっており、それが本来ホスト側に存在する OCI ランタイムバイナリへのアクセスになるという点です。

これを確認するために例えば youki (上記修正前のバージョンで) でコンテナ作成後、エントリポイント実行直前の /proc/self/exe を確認すると以下のようになっています。ここで /works はホスト側から見えるパスでありコンテナからは解決できないはずのパスですが確かにシンボリックリンク先として youki バイナリが参照されています。

# pid=5686 がコンテナの pid
$ sudo ls -l /proc/5686/exe
lrwxrwxrwx. 1 root root 0 Oct  4 13:11 /proc/5686/exe -> /works/youki

自分が気になった些細な点はここで、ホスト側の youki バイナリのパスが指定されていたとしてもコンテナ側でそのパスを解決できないんだから実行できないのでは、ということでした。

自分が調べた範囲からいうとこれは「/proc/self/exe で指定されるのは実行中のバイナリなのですでにそのプロセスで対応するファイルディスクリプタが存在するはずであり、再度開く必要はない。従ってパスを解決する必要もない」ということのようです。

以下の 2 つのページはそれぞれ /proc/self/exe に関する質問ですが、いずれも回答では /proc/self/exe は一見シンボリックリンクに見えるけどそうではなく open すると単に既に持っているファイルディスクリプタを返す、といった趣旨の内容が含まれます。

  • How to handle readlink() of “/proc/self/exe” when executable is replaced during execution?
    • Instead of using readlink to discover the path to your own executable, you can directly call open on /proc/self/exe. Since the kernel already has an open fd to processes that are currently executing, this will give you an fd regardless of whether the path has been replaced with a new executable or not.

  • How does the /proc/<pid>/exe symlink differ from ordinary symlinks?
    • /proc/<pid>/exe appears to be a symlink when you stat it. This is a convenient way for the kernel to export the pathname it knows for the process’ executable. But when you actually open that “file”, there is none of the normal procedure of reading the following the contents of a symlink. Instead the kernel just gives you access to the open file entry directly

また簡易的な確認として pivot_root を使って同様な環境を再現してみます。 これをコンテナと呼ぶには不足点が多すぎますが、少なくとも mount namespace は整えているので今回の検証には十分だと思います。 また後述の検証で使用するためにこのバイナリを test 引数つきで呼び出すと test を出力して終了するようにしています。

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/syscall.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <limits.h>
#include <sys/mman.h>

#define errExit(msg)    do { perror(msg); exit(EXIT_FAILURE); \
                        } while (0)

static int pivot_root(const char *new_root, const char *put_old) {
    return syscall(SYS_pivot_root, new_root, put_old);
}

#define STACK_SIZE (1024 * 1024)

/* Startup function for cloned child */
static int child(void *arg) {
    char **args = arg;
    char *new_root = args[0];
    const char *put_old = "/oldrootfs";
    char path[PATH_MAX];

    /* Ensure that 'new_root' and its parent mount don't have
       shared propagation (which would cause pivot_root() to
       return an error), and prevent propagation of mount
       events to the initial mount namespace. */

    if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) == -1)
        errExit("mount-MS_PRIVATE");

    /* Ensure that 'new_root' is a mount point. */

    if (mount(new_root, new_root, NULL, MS_BIND, NULL) == -1)
        errExit("mount-MS_BIND");

      /* Mount proc */
    snprintf(path, sizeof(path), "%s/proc", new_root);
    if (mount(path, path, "proc", 0, NULL) == -1) {
        errExit("mount-proc");
    }

    /* Create directory to which old root will be pivoted. */

    snprintf(path, sizeof(path), "%s/%s", new_root, put_old);
    if (mkdir(path, 0777) == -1)
        errExit("mkdir");

    /* And pivot the root filesystem. */

    if (pivot_root(new_root, path) == -1)
        errExit("pivot_root");

    /* Switch the current working directory to "/". */

    if (chdir("/") == -1)
        errExit("chdir");

    /* Unmount old root and remove mount point. */

    if (umount2(put_old, MNT_DETACH) == -1)
        perror("umount2");
    if (rmdir(put_old) == -1)
        perror("rmdir");

    /* Execute the command specified in argv[1]... */

    execv(args[1], &args[1]);
    errExit("execv");
}

int main(int argc, char *argv[]) {
    if (strncmp(argv[1], "test", 4) == 0) {
            printf("test\n");
            exit(EXIT_SUCCESS);
    } else {
            /* Create a child process in a new mount namespace. */

            char *stack = mmap(NULL, STACK_SIZE, PROT_READ | PROT_WRITE,
                               MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
            if (stack == MAP_FAILED)
                errExit("mmap");

            if (clone(child, stack + STACK_SIZE,
                        CLONE_NEWNS | SIGCHLD, &argv[1]) == -1)
                errExit("clone");

            /* Parent falls through to here; wait for child. */

            if (wait(NULL) == -1)
                errExit("wait");

            exit(EXIT_SUCCESS);
    }
}

このなんちゃってコンテナを実行するには以下のようにします。

# ルートファイルシステムの用意
$ mkdir rootfs
$ sudo docker export $(sudo docker create ubuntu:18.04) | tar -C rootfs -xvf -

# pivot_root_sample のコンパイル
$ cc -o pivot_root_sample pivot_root_sample.c

# 実行
# ./pivot_root_sample <path_to_rootfs> <command>
$ sudo ./pivot_root_sample rootfs /bin/ls -l /
total 4
drwxr-xr-x   1 1000 1000  902 Sep  3  2020 bin
drwxr-xr-x   1 1000 1000    0 Apr 24  2018 boot
drwxr-xr-x   1 1000 1000   26 Oct 24 04:11 dev
drwxr-xr-x   1 1000 1000 1084 Oct 24 04:11 etc
drwxr-xr-x   1 1000 1000    0 Apr 24  2018 home
drwxr-xr-x   1 1000 1000   84 May 23  2017 lib
drwxr-xr-x   1 1000 1000   40 Sep  3  2020 lib64
drwxr-xr-x   1 1000 1000    0 Sep  3  2020 media
drwxr-xr-x   1 1000 1000    0 Sep  3  2020 mnt
drwxr-xr-x   1 1000 1000    0 Sep  3  2020 opt
dr-xr-xr-x 319 root root    0 Oct 24 09:48 proc
drwx------   1 1000 1000   30 Sep  3  2020 root
drwxr-xr-x   1 1000 1000   40 Sep 16  2020 run
drwxr-xr-x   1 1000 1000 1144 Sep 16  2020 sbin
drwxr-xr-x   1 1000 1000    0 Sep  3  2020 srv
drwxr-xr-x   1 1000 1000    0 Apr 24  2018 sys
drwxr-xr-x   1 1000 1000    0 Sep  3  2020 tmp
drwxr-xr-x   1 1000 1000   70 Sep  3  2020 usr
drwxr-xr-x   1 1000 1000   90 Sep  3  2020 var

検証では child プロセスで実行されるシステムコールを strace しました。本来は /proc/self/exe でシンボリックリンクの解決が行われるのかを見たかったのですが (そしてそこで最近覚えた eBPF を使えないかなーと) それをピンポイントで見るのは難しそうだったので、open あたりのシステムコールが追えれば十分かなという判断です。

試しに上のように /bin/ls -l / を実行した際には / を open する様子が確認できます。

$ sudo strace -p 92011
strace: Process 92011 attached
...
openat(AT_FDCWD, "/", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
...

これが /proc/self/exe test とすると以下のように対応する openat システムコールは呼ばれず実行が終了します。

$ sudo strace -p 92074
strace: Process 92074 attached
restart_syscall(<... resuming interrupted read ...>) = 0
execve("/proc/self/exe", ["/proc/self/exe", "test"], 0x7ffc85c5cea0 /* 15 vars */) = 0
brk(NULL)                               = 0x5593154e0000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=7274, ...}) = 0
mmap(NULL, 7274, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f0facf39000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260\34\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=2030544, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f0facf37000
mmap(NULL, 4131552, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f0fac923000
mprotect(0x7f0facb0a000, 2097152, PROT_NONE) = 0
mmap(0x7f0facd0a000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7f0facd0a000
mmap(0x7f0facd10000, 15072, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f0facd10000
close(3)                                = 0
arch_prctl(ARCH_SET_FS, 0x7f0facf384c0) = 0
mprotect(0x7f0facd0a000, 16384, PROT_READ) = 0
mprotect(0x5593148ba000, 4096, PROT_READ) = 0
mprotect(0x7f0facf3b000, 4096, PROT_READ) = 0
munmap(0x7f0facf39000, 7274)            = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0xd), ...}) = 0
brk(NULL)                               = 0x5593154e0000
brk(0x559315501000)                     = 0x559315501000
write(1, "test output\n", 12)           = 12
exit_group(0)                           = ?
+++ exited with 0 +++

ということで確かに /proc/self/exe を実行する場合には再度ファイルを開くという処理には至らず、ひいてはパスの解決をしなくても済むと推測されます。なおこの挙動は /proc/self/exe に限らず既にそのプロセスで開いてファイルディスクリプタを持つファイルならば同様にパス解決せずに処理が行えるようです。