Heap Spray
堆喷射(heap spraying)指的是一种辅助攻击手法:「通过大量分配相同的结构体来达成某种特定的内存布局,从而帮助攻击者完成后续的利用过程」,常见于如下场景:
你有一个 UAF,但是你无法通过少量内存分配拿到该结构体(例如该 object 不属于当前 freelist 且释放后会回到 node 上,或是像
add_key()那样会被一直卡在第一个临时结构体上),这时你可以通过堆喷射来确保拿到该 object。你有一个堆溢出读/写,但是堆布局对你而言是不可知的(比如说开启了
SLAB_FREELIST_RANDOM(默认开启)),你可以预先喷射大量特定结构体,从而保证对其中某个结构体的溢出。......
作为一种辅助的攻击手法,堆喷射可以被应用在多种场景下。
例题:RWCTF2023体验赛 - Digging into kernel 3
本篇为了介绍堆喷射这一手法,同时为了使用更多不同的结构体,笔者会用比较复杂的思路去解题。
题目分析
按惯例查看启动脚本,发现开启了 SMEP、SMAP、KASLR、KPTI:
#!/bin/sh
qemu-system-x86_64 \
-m 128M \
-nographic \
-kernel ./bzImage \
-initrd ./rootfs.img \
-enable-kvm \
-cpu kvm64,+smap,+smep \
-monitor /dev/null \
-append 'console=ttyS0 kaslr kpti=1 quiet oops=panic panic=1 init=/init' \
-no-reboot \
-snapshot \
-s文件系统里给了一个 rwctf.ko ,拖入 IDA 进行分析,发现只定义了一个 ioctl,提供了两个功能:
0xDEADBEEF:分配一个任意大小的 object 并能写入数据,分配 flag 为
__GFP_ZERO | GFP_KERNEL,不过我们只能同时有两个 object。0xC0DECAFE:释放一个之前分配的 object ,存在 UAF。

我们需要传入如下结构体:
经过笔者测试,出题人手动关闭了如下默认开启的保护(出题人为了降低题目难度,可能关的更多,笔者只测了这几个):
关闭了
CONFIG_MEMCG_KMEM,这使得GFP_KERNEL与GFP_KERNEL_ACCOUNT会从同样的kmalloc-xx中进行分配关闭了
CONFIG_RANDOMIZE_KSTACK_OFFSET,这使得固定函数调用到内核栈底的偏移值是不变的关闭了
SLAB_FREELIST_HARDENED,这使得 freelist 几乎没有任何保护,我们可以轻易完成任意地址分配 + 任意地址读写
不过在笔者看来 出题人其实没有必要自降难度 ,下面笔者将给出在这三种保护开启时也能完成利用的方法 :)
漏洞利用
既然题目中已经直接白给出了一个无限制的 UAF,那么利用方式就是多种多样的了,这里笔者选择使用 user_key_payload 来完成利用。
Step.I - 堆喷 user_key_payload 越界读泄露内核基地址
在内核当中存在一个用于密钥管理的子系统,内核提供了 add_key() 系统调用进行密钥的创建,并提供了 keyctl() 系统调用进行密钥的读取、更新、销毁等功能:
当我们调用 add_key() 分配一个带有 description 字符串的、类型为 "user" 的、长度为 plen 的内容为 payload 的密钥时,内核会经历如下过程:
首先会在内核空间中分配 obj 1 与 obj2,分配 flag 为
GFP_KERNEL,用以保存description(字符串,最大大小为 4096)、payload(普通数据,大小无限制)分配 obj3 保存
description,分配 obj4 保存payload,分配 flag 皆为GFP_KERNEL释放 obj1 与 obj2,返回密钥 id
其中 obj4 为一个 user_key_payload 结构体,定义如下:
类似于 msg_msg,user_key_payload 结构体有着一个固定大小的头部,其余空间用来存储来自用户空间的数据(密钥内容)。
keyctl() 系统调用为我们提供了读取、更新(分配新对象,释放旧对象)、销毁密钥(释放 payload)的功能,其中读取的最大长度由 user_key_payload->datalen 决定,我们不难想到的是我们可以利用题目提供的 UAF 将user_key_payload->datalen 改大,从而完成越界读。
注意以下两点:
这里我们的 description 字符串需要和 payload 有着不同的长度,从而简化利用模型。
读取 key 时的 len 应当不小于 user_key_payload->datalen,否则会读取失败。
但是这里有一个问题:add_key() 会先分配一个临时的 obj1 拷贝 payload 后再分配一个 obj2 作为 user_key_payload,若我们先分配一个 obj 并释放后再调用 add_key() 则该 obj 不会直接成为 user_key_payload ,而是会在后续的数次分配中都作为拷贝 payload 的临时 obj 存在。
但我们可以通过堆喷将 UAF obj 分配到 user_key_payload,考虑如下流程:
利用题目功能构建 UAF object。
堆喷射
user_key_payload,UAF obj 作为拷贝 payload 的临时 obj 存在。kmem_cache_cpu的 slub page 耗光,向 node 请求新的 slub page 分配user_key_payload,完成后 UAF obj 被释放并回到kmem_cache_node。继续堆喷
user_key_payload,kmem_cache_cpu的 slub page 耗光,向 node 请求新的 slub page 分配user_key_payload。UAF obj 所在页面被取回,UAF obj 被分配为
user_key_payload。利用题目功能再次释放 UAF obj,利用题目功能进行堆喷获取到该 obj,从而覆写
user_key_payload。
注:官方题解中进行地址泄露也是利用类似的做法。
不过笔者觉得其实直接利用题目分配 obj1 和 obj2 后全部释放,之后再在 obj2 上弄 UAF 就行了:) 这里采用这种做法只是为了介绍 heap spraying 这一手法。
笔者将在 Step.II 中使用这种方法。
接下来我们考虑越界读取什么数据,这里我们并不需要分配其他的结构体, rcu_head->func 函数指针在 rcu 对象被释放后才会被写入并调用,但调用完并不会将其置为 NULL,因此我们可以通过释放密钥的方式在内核堆上留下内核函数指针,从而完成内核基址的泄露。
Step.II - UAF 泄露可控堆对象地址,篡改 pipe_buffer 劫持控制流
可以用来控制内核执行流的结构体有很多,但是我们需要考虑如何完整地执行 commit_creds(prepare_kernel_cred(NULL)) 后再成功返回用户态,因此我们需要进行栈迁移以布置较为完整的 ROP gadget chain。
由于题目开启了 SMEP、SMAP 保护,因此我们只能在内核空间伪造函数表,同时内核中的大部分结构体的函数表为静态指定(例如 tty->ops 总是 ptm(或pty)_unix98_ops),因此我们还需要知道一个内容可控的内核对象的地址,从而在内核空间中伪造函数表。
这里笔者选择管道相关的结构体完成利用;在内核中,管道本质上是创建了一个虚拟的 inode 来表示的,对应的就是一个 pipe_inode_info 结构体:
同时内核中会分配一个 pipe_buffer 结构体数组,每个 pipe_buffer 结构体对应一张用以存储数据的内存页:
pipe_buf_operations 为一张函数表,当我们对管道进行特定操作时内核便会调用该表上对应的函数,例如当我们关闭了管道的两端时,会触发 pipe_buffer->pipe_buffer_operations->release 这一指针,由此我们便能控制内核执行流,从而完成提权。
那么这里我们可以利用 UAF 使得 user_key_payload 与 pipe_inode_info 占据同一个 object, pipe_inode_info 刚好会将 user_key_payload->datalen 改为 0xFFFF 使得我们能够继续读取数据,从而读取 pipe_inode_info 以泄露出 pipe_buffer 的地址。
而 pipe_buffer 是动态分配的,因此我们可以利用题目功能预先分配一个对象作为 pipe_buffer 并直接在其上伪造函数表即可。
对于笔者来说比较麻烦的倒是找栈迁移的 gadget...好在最后还是成功找到了一些合适的 gadget。
EXPLOIT
最后的 exp 如下:
Last updated