N1CTF 2022 Praymoon Write Up

0x00 漏洞

 跟所有内核题目一样,题目给出了内核 bzImage (5.18.10),文件系统 rootfsqemu 启动脚本 run.sh。解析 rootfs,提取出待分析的内核模块 praymoon.ko,以及系统启动脚本 init

praymoon.ko

image-20221109172258520

  漏洞模块功能很简单,可以通过 add 功能分配一个 0x200 大小的堆块;通过 del 功能删除这个堆块。但是 add 功能只能用一次,del 功能只能用两次。也就是说给了我们一次 0x200 的堆块的 UAF 机会。

  但是在内核利用中,0x200 的堆块没有能够方便分配而且能够泄露/劫持控制流的。这就是这个题的主要难点所在。此外,漏洞模块提供的功能只有分配和释放,没有读写,这是这个题的第二个难点。

init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/bin/sh

mkdir /tmp
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev
mount -t tmpfs none /tmp
mdev -s
echo -e "Boot took $(cut -d' ' -f1 /proc/uptime) seconds"
echo 1 > /proc/sys/vm/unprivileged_userfaultfd

insmod /praymoon.ko
chmod 666 /dev/seven
chmod 740 /flag
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
chmod 400 /proc/kallsyms

# poweroff -d 120 -f &
setsid /bin/cttyhack setuidgid 1000 /bin/sh

umount /proc
umount /tmp


poweroff -d 0 -f

init 脚本也是在内核题目中比较常见的脚本。由于内核在 5.11 以后,默认不允许非特权用户使用 userfaultfd,本题特意将其打开了。所以我们可以用一些平时没办法使用的功能,例如 sendmsg (ctl_buf) 堆喷和 msg_msg 任意地址写之类的。

 此外,还需要注意的是,init 脚本中没有挂载 pts,也就是我们没办法喷射 tty 结构体;同时文件系统中没有文件支持 xattr,所以 setxattr 堆喷也不能用。(这一点我不确定,但是看起来都是内存文件系统)

config

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y
CONFIG_SHUFFLE_PAGE_ALLOCATOR=y

CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH=""

CONFIG_MEMCG=y
CONFIG_MEMCG_SWAP=y
CONFIG_MEMCG_KMEM=y

CONFIG_DEBUG_LIST=y

CONFIG_HARDENED_USERCOPY=y

 根据题目给出的 readme,内核开启了上述几项保护。简单说就是堆的初始页面和 free_list 都被随机化过了;禁用了 modprobe_path 提权;开启了 MEMCG,所以 GFP_KERNELGFP_KERNEL_ACCOUNT 的堆块就在两个不同的 kmem_cache 中了。

0x01 思路

  因为没有办法使用 0x200 的 slab 构造内核利用原语(任意地址写,劫持控制流),所以我们的主体思路非常明确:转移 UAF 到其他我们能够利用的大小的 slab 中。所以在老传统的内核利用中不常出现但是威力巨大的一种原语就要派上用场:任意地址释放(arbitrary free)。

 如果我们能够任意释放一个堆块,并且知道目标堆块的地址,我们就可以使用可变长的 UAF 写入原语(例如 msg_msgsendmsg (ctl_buf)setxattr 等)占用这个堆块并进行写入,从而覆盖这个堆块的内容。如果这个堆块里包含虚表指针或函数指针(例如 tty_structpipe_bufferseq_operations 等),我们就可以劫持控制流。

  但是,即使我们知道了需要利用任意地址释放原语,还有一个关键性的问题没办法解决:泄露。

 在传统的老内核漏洞利用中,泄露往往是通过 UAF 读原语读到堆块内的脏数据或者通过溢出读原语读到下一个堆块内的数据。然而,在这个题目中会遇到两个问题:

  1. 0x200 堆块中本来有的数据过于干净,没有好用的泄露。即使我们喷射一些可变长的结构体上去,也没有泄露的可能。
  2. 任意地址释放所需要的泄露是目标堆块的地址(例如某个 kmalloc-1024 的 slab),而不是 0x200 堆块(kmalloc-512)的堆地址。在内核中,0x200 堆块和 0x400 的堆块的地址可能天差地别,即使泄露出 0x200 堆块的地址,也无法稳定推断出目标堆块的地址。

 所以我们需要更强的泄露原语:页间溢出读。在今年(2022年),已经有很多广为人知的利用手段用到了页间攻击,即攻击目标不再限于同一个 slab 内或同一个页面内,而是攻击伙伴系统的分配机制(例如 dirtycred)。通常来讲,我们可以调节页风水,使得目标堆块在溢出堆块的下一个页,然后溢出读到目标堆块的内容。

  好了,现在这个题目的所有难点我们都找到了解决办法,剩下的就是把这些原语拼接组装起来了。

0x02 利用

风水 && 泄露

  首先我们需要构造页风水并泄露。根据 FizzBuzz101 的 writeup,我们构造了如下的页风水。

page1 中是 0x200 的堆块。通过 malloc moon, free moon, spray user_key_payload 三步即可将 user_key_payload 喷射到 page1 中,之后再次 free moon 就可以产生悬浮指针。然后喷射 sendmsg(ctl_buf) + userfaultfd 即可修改 user_key_payloaddatalen 字段为 0xfff0,从而读到离 page1 并不算太远的 page2 和 page3。

page2 中是由 shm 喷射产生的 vm_area_struct 结构体,其中有 struct list_head 结构并指向自己(最好用的内核泄露堆地址结构)。因此我们读到 page2 就能泄露出堆上的地址和内核基址,通过堆上的地址结合偏移可以算出 page1, page2, page 3 中所有的地址。

page3 之后是由 pipe 喷射产生的 pipe_buffer 结构体,16 个 pipe_buffer 连在一起,大小为 0x280,占据 0x400 的堆块。为了利用,我们需要让一半的 pipe_buffer 初始化,一半不初始化,原因在稍后解释。初始化的 pipe_buffer 会带有 anon_pipe_buf_ops 的地址,未初始化的则全为 0。

构造过程不详述,方法在 FizzBzz101 的博客中已经说得很详细。但是有一点需要特别指出:page1 和 page2 的构造用的是 FizzBuzz101 的构造方式,然而 page3 并不能使用该方法构造。原因是 page3 中的 pipe_buffer 分配时带有标志 GFP_KERNEL_ACCOUNT,被分配在 kmalloc-cg-1024 中。kmalloc-cg-1024 无法占据由 close(sockfd) 释放的页。但是经过风水,发现 pipe_buffer 分配的位置离 page1 也不会特别远,所以经过微调之后可以保证 page1 和 page3 的间距小于 0xfff0

任意地址释放

  现在到了重头戏:构造任意地址释放。这里我们使用的结构是 pollfd。同样地,这个知识点在 D3V17 的博客中说得也很详细,这里不过多解释。

  泄露后,目前堆的状态是 sendmsg(ctl_buf)user_key_payload 同时占据 page1 中的某个堆块。此后,在 userfaultfd_handler 结束后,ctl_buf 会被释放掉。此时我们呢控制 pollfd 的大小占据刚刚被释放的 ctl_buf。同时我们释放掉 user_key_payload,并再次用新的 sendmsg(ctl_buf) 占据并修改堆块内容,此时修改的既是 pollfdnext 指针。

  好了,现在可以解释为什么 pipe_buffer 要一半初始化一半放生了。我们的目标堆块便是的 pipe_buffer,如果其被初始化,那么它的第一个字段肯定不是 0。这就会导致 pollfdnext 指针链不会在目标堆块终止,结果自然是 kernel panic。所以初始化一半的目的只是为了定位到没有初始化的那一半(因为 pipe_bufferkmalloc-cg-1024 的分配是基本连续的),我们选取某一个没有初始化的 pipe_buffer 作为目标堆块。

  在目标堆块被释放后,我们可以初始化目标堆块(向 pipe 里面写数据)。然后通过喷射同样是 GFP_KERNEL_ACCOUNTmsg_msg 结构覆盖目标堆块的虚表指针劫持控制流。(PS: msg_msg 结构在内核版本 5.15 之后就变为了 GFP_KERNEL_ACCOUNT 分配,不能再用 msg_msg 占页 GFP_KERNEL_ACCOUNT 分配的堆块)

ROP

  到了这一步就十分地简单与显然了。值得一提地是在开启 SMAP 和 SMEP 的情况下并没有很多好用的栈迁移 gadget。最后还是按照 D3V17 的思路用了一些很奇怪的 gadget。

1
2
3
4
5
6
7
// push rsi; jge 0x3247e8; jmp qword ptr [rsi + 0x41];
uint64_t stack_pivot_gadget_0 = kernel_base - 0xffffffff81000000 + 0xffffffff811247e6;
// pop rsp; add rsp, 0x68; pop rbx; ret;
uint64_t stack_pivot_gadget_1 = kernel_base - 0xffffffff81000000 + 0xffffffff8134862c;
// add rsp, 0x78; ret;
uint64_t stack_pivot_gadget_2 = kernel_base - 0xffffffff81000000 + 0xffffffff813f643e;

0x03 吐槽

  这个题最后还是零解,我在洗澡的时候立马想到了现在这个思路,当时离比赛结束只剩三个小时了,想到要改近 500 行代码还是放弃了 QWQ。我们在第一天就成功泄露出地址,但是当时想用的目标堆块是 shm 产生的 file 结构体。我们在第二天也成功地释放掉了 file 结构体,可惜我忘了 file 结构体在 filp 这个 kmem_cache 中,没有任何可以控制的堆块能够覆盖到它。给大晚上还陪我一起头秃调试的 will 和 thinerdas 道歉 hhhhh。

0x04 FINAL EXP

exp:
https://gist.github.com/Roarcannotprogramming/aca9acf10aeba78f797e6e4b93fe4b4a