35c3ctf Pwn namespaces
2021过的很魔幻,所以就没再写过博客,可惜2022更魔幻
逆向
题目给出了Dockerfile,里面用nsjail启动了二进制文件namespaces
1 | FROM tsuro/nsjail |
nsjail也是一个基于linux namespaces的进程隔离工具,其参数值得注意的是:
-R /tmp/flag:/flag -T /tmp
,将/tmp/flag
bind mount
到了/flag
,并重新挂在了一个/tmp
,实际上效果就是在nsjail中只有/flag
,并且权限为/tmp/flag
的400-U 0:1000:1 -U 1:100000:1 -G 0:1000:1 -G 1:100000:1
,将docker的1000号用户(user)映射为了nsjail中的0号用户(root),将docker中的10000号用户(nobody)映射为了nsjail中的1号用户(nobody),用户组也是类似
而二进制文件namespaces实际上也是一个基于namespaces机制的沙盒,也是题目中需要逃逸的沙盒。所以这题实际上用namespaces套了三层…
逆向namespaces可以发现它的功能与docker有些类似,包括两个功能:
start_sandbox
,类似于docker run
- 在
sandboxes
中查找空位,最多能够10个沙盒
1 | new_sandbox = 0LL; |
- 创建了一个本地socket,供之后处于不同namespaces的父进程和子进程通信使用
1 | socket = socketpair(1, 1, 0, fds); //AF_UNIX,SOCK_STREAM |
使用
clone
创建一个子进程,子进程将处于新的mnt
cgroup
uts
ipc
user
pid
net
namespace中mnt namespaces
,mount
的结果不会影响其他mnt namespaces
中的进程cgroup namespaces
,cgroup
用于限制进程对cpu等系统资源的使用uts namespaces
,隔离hostname
和NIS
域名ipc namespaces
,隔离消息队列、信号量和共享内存3钟进程间通信的方式,并不会限制其他的ipc通信user namespaces
,同一个用户在不同的user namespaces
中可以对应不同的uid,一个user namespaces
中的普通用户甚至可以是另一个user namespaces
中的root
用户。此外,新建或加入一个user namespaces
时,无论新的uid
是多少,能够在这个user namespaces
中获取到全部的capabilities,不过需要注意如果uid
不为0的话执行execve
等函数后capabilities会全部丢失掉,后面会详细介绍capabilitiespid namespaces
,隔离进程的pid,创建新的pid namespaces
后,外层的pid namespaces
可以看到里面的进程,而里面的进程无法看到外面的进程net namespaces
,隔离网络相关的资源,比如ip协议栈、路由表等等,此外它还会隔离unix域的abstract socket
,这点在后面也会用到
1 | pid = new_proc(); |
1 | __int64 new_proc() |
- 父进程禁止子进程调用
setgroups
,并且将nsjail中的1号用户和1号用户组(也就是docket中的10000号用户)映射为子进程的1号用户和1号用户组
1 | close(fds[1]); // 父进程 |
- 子进程
chroot
至沙盒目录并chdir
过去,使用setresgid
和setresuid
降权至1号用户和用户组(nsjail中的1号用户,docker中的10000号用户),最后execveat
执行用户提交的elf文件,正如同docker run
一样,execveat
执行的elf一旦退出子进程就会结束
1 | snprintf(s, 0x1000uLL, "/tmp/chroots/%ld", new_sandbox - sandboxes); |
run_elf
,类似于docker exec
加入到对应沙盒的namespaces中
一个进程的namespaces位于
/proc/pid/ns/
中,其他进程可以通过setns
加入到该进程的namespaces中去pid
namespaces需要fork
出子进程才能真正加入可以发现这里没有加入
net
namespaces
1 | .data:0000000000203020 NSS dq offset aUser ; DATA XREF: change_ns+5E↑o |
1 | printf("[*] entering namespaces of pid %d\n", pid); |
- 和
start_sandbox
一样,chroot
并且降权
1 | snprintf(s, 0x1000uLL, "/tmp/chroots/%d", a2); |
- 执行用户提交的elf
1 | execveat(fd, &unk_210F, v5, 0LL, 4096LL); |
chroot逃逸
chroot
本身就不是一个很安全的机制,如同pwn.college中总结的那样,一个进程在chroot
某个目录xx
后会有两个效果:
将所有的
/
重定向为xx
将所有的
xx/../
重定向为xx
并且chroot
只会影响路径,并不会影响已经打开的文件描述符,通过openat
等基于已有文件描述符进行相对路径寻址的系统调用即可从chroot
中逃逸。因此可以想到利用进程间的通信来传递文件描述符,通过别的沙箱的文件描述符来逃逸chroot
Linux中的进程间通信方法有很多,但似乎只有socket
能传递文件描述符,在 https://man7.org/linux/man-pages/man7/unix.7.html 可以看到本地通信的socket
可以分为3种:
pathname
,指的是用bind
将socket
绑定到一个具体的文件名上去,这里因为chroot
的限制无法使用unnamed
,没有用bind
绑定的stream socket
都是unnamed
的,上面socketpair
创建的也是。在这种两个进程分别创建socket
的情况下是当作客户端去使用abstract
,用bind
将socket
绑定到一个与文件系统无关的名字上去,由net namespaces
进行隔离
而在run_elf
中,并没有加入到新的net namespaces
中去,也就是所有的run_elf
进程使用了同一个net namespaces
,因此我们可以在两个run_elf
进程a和b中分别开启一个abstract socket
和unnamed socket
,并将b中打开的文件描述符传递给a,a即可通过这个文件描述符逃逸出chroot
提权
不过逃逸出chroot
仍然读不到flag,/flag
只有docker中的user用户,也就是nsjail中的root用户才能读到,而无论是start_sandbox
还是run_elf
在执行用户提交的elf前都进行了降权操作
这里的攻击思路非常巧妙,问题还是出在run_elf
加入沙盒namespaces
的过程。run_elf
在加入了/proc/xxx/pid
后会立刻fork
出子进程从而真正加入pid namespaces
,在加入了所有的命名空间后才进行了降权操作。而在加入pid namespaces
的时候,start_sandbox
中的进程就已经能够观察到未降权的run_elf
进程,此时如果能在run_elf
进程降权之前用ptrace
控制run_elf
中的进程,就能够以nsjail中root用户的身份去读取/flag
接下来的问题就是如何才能在start_sandbox
进程中拥有ptrace
的权限, 这里先要了解下Linux的capabilities机制。
在过去Linux中我们可以通过sudo
或suid
机制来让普通用户以root权限执行文件,而这两种方式都会使普通用户获取到完整的root权限,很显然这是极不安全的。因此Linux在内核2.2后引入了capabilities机制,对root的权限进行了更加细粒度的划分,例如nginx需要监听端口,那么只需要给nginx可执行文件赋予CAP_NET_BIND_SERVICE
这一项capability,使其能够以普通用户的身份监听端口。
具体capabilities机制比较复杂,可以看看文档或相关的文章,这里只需要知道:
root用户拥有所有的capabilities,普通用户默认状态下没有capabilities
在一个进程创建或加入新的
user namespaces
时,无论新的uid
是多少,都会拥有全部的capabilities,也就是该用户被视为了新的user namespaces
中的root用户。- 新的
user namespaces
中的root用户还是会受到一些限制。比如正常情况下root用户可以无视权限读取任何文件,这是因为其具有CAP_DAC_READ_SEARCH
,可以绕过文件权限的检查。而新建user namespaces
后的root用户只能够随意读取uid和gid都被映射到了该user namespaces
中的文件
- 新的
新的
user namespaces
在执行execve
后如果uid
不为0并且执行的文件不具有可继承的capabilities,那么capabilities会被清空。也正因为如此,题目中的start_sandbox
和run_elf
在使用execve
执行用户输入的elf后将不具有任何的capabilities。新建
namespaces
本身不需要任何的capabilities,因此如果我们可以新建user namespaces
,我们就能再次获取所有的capabilities,也就能够进行ptrace
等操作
然而,chroot
后的进程无法新建user namespaces
。这点我没有在相关文档中找到说明,但比较好理解,因为新建user namespaces
会使chroot
中的进程能够再次chroot
(通过CAP_SYS_CHROOT
),而嵌套chroot
是一项常用的chroot
逃逸手段。
因此,我们不仅需要逃逸出chroot
访问其他文件,还需要整个run_elf
中的进程逃逸出chroot
。这里可以利用沙盒所处文件夹/tmp/chroots
宽松的777权限,通过条件竞争做到,方案如下:
打开两个沙盒a和b,通过上面传递文件描述符的方式逃逸出
chroot
,沙盒a获得访问chroot
外文件的权限沙盒a循环监控
/tmp/chroots/c
是否存在,一旦存在则将其删除,并新建指向/
的链接/tmp/chroots/c
创建c沙盒,当cpu的调度顺序为以下顺序时,就能够使c沙盒chroot到
/
中去,从而使c沙盒进程逃逸出chroot
c沙盒
start_sandbox
创建/tmp/chroots/c
a沙盒中的进程检测到
/tmp/chroots/c
,将其替换为软链接c沙盒
chroot
到/tmp/chroots/c
在条件竞争成功后,理论上我们只需要新建一个user namespaces
,让run_elf
进程加入进来就可以ptrace
了。但这有个问题是run_elf
进程在加入了新的user namespaces
后就无法再加入到原有的pid namespaces
了,原因应该是setns文档中说的(这里的逻辑关系我也没太搞清楚,不过写个demo实验下这种情况确实无法加入进去):
In order to reassociate itself with a new PID namespace,the caller must have the CAP_SYS_ADMIN capability both in its own user namespace and in the user namespace that owns the target PID namespace.
而不加入pid namespaces
的话start_sandbox
进程是无法看到run_elf
进程的,因此还需要start_sandbox
进程新建一个pid namespaces
,可是新建了pid namespaces
后又需要fork
一个子进程才能真正进入,并且pid namespaces
将位于/proc/子进程/ns
下,而run_elf
进程只会去加入父进程(也就是原来的start_sandbox
进程)的namespaces
因此,我们还需要获取mount
的能力从而修改/proc
,在user namespaces文档中关于mount
有这样的表述:
Holding CAP_SYS_ADMIN within the user namespace that owns a process’s mount namespace allows that process to create bind mounts and mount the following types of filesystems:
- /proc (since Linux 3.8)
- /sys (since Linux 3.8)
- devpts (since Linux 3.9)
- tmpfs(5) (since Linux 3.9)
- ramfs (since Linux 3.9)
- mqueue (since Linux 3.9)
- bpf (since Linux 4.4)
- overlayfs (since Linux 5.11)
因此我们需要同时新建mnt namespaces
user namespaces
和pid namespaces
。并在子进程中将原先的/proc
保存到别的地方,重新挂载一个空的/proc
,并在原先父进程pid namespaces
的位置放置一个符号链接指向原先子进程的pid namespaces
。因为子进程和父进程同处一个user namespaces
,子进程的mount
操作将自动传播到父进程中,也就是父进程的/proc
将和子进程一样。
在run_elf
进程加入到start_sandbox
进程的mnt namespaces
后,它将看到我们伪造后的/proc
,从而加入到子进程的pid namespaces
中去,此时子进程就可以用ptrace
注入shellcode读取/flag
为了增加最后一步成功的概率,我们还可以在伪造的/proc
中将uts namespaces
变为一个fifo
管道,从而使run_elf
进程在这里卡住,使得cpu去执行start_sandbox
子进程的ptrace
。但这一步并不是必须的,没有一样有概率可以ptrace
成功
完整的exp见 https://github.com/LevitatingLion/ctf-writeups/tree/master/35c3ctf/pwn_namespaces ,社畜根本没空自己写
还有一个值得一提的是,我之前一直以为ptrace
需要被ptrace
的进程执行ptrace(PTRACE_TRACEME, 0, 0, 0);
才能ptrace
成功,但试验后会发现并非如此(不然strace
怎么追踪进程。。),可man文档里对PTRACE_TRACEME
作用的描述又很模糊不清,最后在 https://sites.uclouvain.be/SystInfo/manpages/man2/ptrace.2.html 才找到更详细的描述:
Indicates that this process is to be traced by its parent. Any signal (except SIGKILL) delivered to this process will cause it to stop and its parent to be notified via wait(2). Also, all subsequent calls to execve(2) by this process will cause a SIGTRAP to be sent to it, giving the parent a chance to gain control before the new program begins execution. A process probably shouldn’t make this request if its parent isn’t expecting to trace it. (pid, addr, and data are ignored.)
不知道为啥现在的文档把Any signal (except SIGKILL) delivered to this process will cause it to stop and its parent to be notified via wait(2). Also, all subsequent calls to execve(2) by this process will cause a SIGTRAP to be sent to it, giving the parent a chance to gain control before the new program begins execution.
这句话去掉了…