2021过的很魔幻,所以就没再写过博客,可惜2022更魔幻

逆向

题目给出了Dockerfile,里面用nsjail启动了二进制文件namespaces

1
2
3
4
FROM tsuro/nsjail
COPY ./namespaces /home/user/chal
COPY ./flag /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也是一个基于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
2
3
4
5
6
7
8
9
10
11
new_sandbox = 0LL;
for ( i = 0; i <= 9; ++i )
{
if ( !sandboxes[i] )
{
new_sandbox = &sandboxes[i];
break;
}
}
if ( !new_sandbox )
errx(1, "too many sandboxes");
  • 创建了一个本地socket,供之后处于不同namespaces的父进程和子进程通信使用
1
2
socket = socketpair(1, 1, 0, fds); //AF_UNIX,SOCK_STREAM
check(socket, "socketpair");
  • 使用clone创建一个子进程,子进程将处于新的mnt cgroup uts ipc user pid net namespace中

    • mnt namespacesmount的结果不会影响其他mnt namespaces中的进程

    • cgroup namespacescgroup用于限制进程对cpu等系统资源的使用

    • uts namespaces,隔离hostnameNIS域名

    • ipc namespaces,隔离消息队列、信号量和共享内存3钟进程间通信的方式,并不会限制其他的ipc通信

    • user namespaces,同一个用户在不同的user namespaces中可以对应不同的uid,一个user namespaces中的普通用户甚至可以是另一个user namespaces中的root用户。此外,新建或加入一个user namespaces时,无论新的uid是多少,能够在这个user namespaces中获取到全部的capabilities,不过需要注意如果uid不为0的话执行execve等函数后capabilities会全部丢失掉,后面会详细介绍capabilities

    • pid namespaces,隔离进程的pid,创建新的pid namespaces后,外层的pid namespaces可以看到里面的进程,而里面的进程无法看到外面的进程

    • net namespaces,隔离网络相关的资源,比如ip协议栈、路由表等等,此外它还会隔离unix域的abstract socket,这点在后面也会用到

1
pid = new_proc();
1
2
3
4
5
6
7
8
9
__int64 new_proc()
{
__int64 v1; // [rsp+8h] [rbp-8h]

v1 = syscall(56LL, 0x7E020000LL, 0LL, 0LL, 0LL, 0LL); //clone syscall,0x7E020000=CLONE_NEWNS|CLONE_NEWCGROUP|CLONE_NEWUTS|CLONE_NEWIPC|CLONE_NEWUSER|CLONE_NEWPID|CLONE_NEWNET
if ( v1 == -1 )
errx(1, "clone");
return v1;
}
  • 父进程禁止子进程调用setgroups,并且将nsjail中的1号用户和1号用户组(也就是docket中的10000号用户)映射为子进程的1号用户和1号用户组
1
2
3
4
5
6
7
8
9
10
11
12
close(fds[1]);                                // 父进程
wait_for((unsigned int)fds[0], "1");
puts("[*] setgroups deny");
write_proc(pid, "setgroups", "deny"); // 子进程禁止调用setgroups
puts("[*] writing uid_map");
write_proc(pid, "uid_map", "1 1 1"); // docker:user(1000) -> nsjail:root(0)
// docker:nobody(10000) -> nsjail:nobody(1)
puts("[*] writing gid_map");
write_proc(pid, "gid_map", "1 1 1");
write(fds[0], "2", 2uLL);
wait_for((unsigned int)fds[0], "3");
close(fds[0]);
  • 子进程chroot至沙盒目录并chdir过去,使用setresgidsetresuid降权至1号用户和用户组(nsjail中的1号用户,docker中的10000号用户),最后execveat执行用户提交的elf文件,正如同docker run一样,execveat执行的elf一旦退出子进程就会结束
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
snprintf(s, 0x1000uLL, "/tmp/chroots/%ld", new_sandbox - sandboxes);
printf("[*] Creating chroot dir \"%s\"\n", s);
mk_chroot_dir(s);
printf("[*] Chrooting to \"%s\"\n", s);
v1 = chroot(s); // chroot并切换目录
check(v1, "chroot");
v2 = chdir("/");
check(v2, "chdir");
puts("[*] changing group ids");
v3 = setresgid(1u, 1u, 1u); // 降权到1,也就是nobody
check(v3, "setresgid");
puts("[*] changing user ids");
v4 = setresuid(1u, 1u, 1u);
check(v4, "setresuid");
write(fds[1], "3", 3uLL);
close(fds[1]);
puts("[*] starting init");
v11[0] = (__int64)"init";
v11[1] = 0LL;
execveat(v8, &unk_210F, v11, 0LL, 4096LL);
_exit(1);

run_elf,类似于docker exec

  • 加入到对应沙盒的namespaces中

    • 一个进程的namespaces位于/proc/pid/ns/中,其他进程可以通过setns加入到该进程的namespaces中去

    • pid namespaces需要fork出子进程才能真正加入

    • 可以发现这里没有加入net namespaces

1
2
3
4
5
6
7
8
.data:0000000000203020 NSS             dq offset aUser         ; DATA XREF: change_ns+5E↑o
.data:0000000000203020 ; change_ns+F7↑o
.data:0000000000203020 ; "user"
.data:0000000000203028 dq offset aMnt ; "mnt"
.data:0000000000203030 dq offset s2 ; "pid"
.data:0000000000203038 dq offset aUts ; "uts"
.data:0000000000203040 dq offset aIpc ; "ipc"
.data:0000000000203048 dq offset aCgroup ; "cgroup"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
printf("[*] entering namespaces of pid %d\n", pid);
for ( i = 0; i <= 5; ++i )
{
snprintf(s, 0x1000uLL, "/proc/%d/ns/%s", pid, (&NSS)[i]);
v2 = open(s, 0);
fd = check(v2, "open(ns)");
v3 = setns(fd, 0);
check(v3, "setns");
if ( !strcmp((&NSS)[i], "pid") )
{
v4 = fork();
if ( check(v4, "fork") )
_exit(0);
}
close(fd);
}
  • start_sandbox一样,chroot并且降权
1
2
3
4
5
6
7
8
9
snprintf(s, 0x1000uLL, "/tmp/chroots/%d", a2);
v5 = chroot(s); // chroot并切换目录
check(v5, "chroot");
v6 = chdir("/");
check(v6, "chdir");
v7 = setresgid(1u, 1u, 1u); // 降权
check(v7, "setresgid");
v8 = setresuid(1u, 1u, 1u);
check(v8, "setresuid");
  • 执行用户提交的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,指的是用bindsocket绑定到一个具体的文件名上去,这里因为chroot的限制无法使用

  • unnamed,没有用bind绑定的stream socket都是unnamed的,上面socketpair创建的也是。在这种两个进程分别创建socket的情况下是当作客户端去使用

  • abstract,用bindsocket绑定到一个与文件系统无关的名字上去,由net namespaces进行隔离

而在run_elf中,并没有加入到新的net namespaces中去,也就是所有的run_elf进程使用了同一个net namespaces,因此我们可以在两个run_elf进程a和b中分别开启一个abstract socketunnamed 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中我们可以通过sudosuid机制来让普通用户以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_sandboxrun_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 namespacespid 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.这句话去掉了…