cache-of-castaways
1 | !/bin/sh |
- smap,smep,pti
1 | !/bin/sh |
- kptr_restrict
- dmesg_restrict
- perf_event_paranoid
漏洞分析
1 | void __fastcall castaway_ioctl(FILE *fd, int cmd, void *args) |
- 这里的 castaway_cachep 是程序自己分配的 kmem_cache,并且创建 flag 为
SLAB_ACCOUNT
,同时开启了CONFIG_MEMCG_KMEM=y
- 这意味着这是一个独立的 kmem_cache(与内核自带的 kmem_cache-512 相独立,二者不会相互干扰)
1 |
内核分配 kmem_cache 的代码如下:
1 | void init_module() |
本题目的漏洞就在 castaway_edit
函数中:
1 | void __fastcall castaway_edit(unsigned __int64 index, size_t size, void *from) |
- 有6字节的堆溢出,但是该 kmem_cache 是独立的,它上边的 object 很难溢出到内核自带的 kmem_cache-512 上
- 于是需要另一个技术:Cross-Cache Overflow
Cross-Cache Overflow
Cross-Cache Overflow 本质上是针对 buddy system 完成对 slub 攻击的利用手法
伙伴系统 buddy system 的机制如下:
- 把系统中要管理的物理内存按照页面个数分为了11个组,分别对应11种大小不同的连续内存块,每组中的内存块大小都相等,且必须是2的n次幂 (Pow(2, n)),即 1, 2, 4, 8, 16, 32, 64, 128 … 1024
- 那么系统中就存在 2^0~2^10 这么11种大小不同的内存块,对应内存块大小为 4KB ~ 4M,内核用11个链表来管理11种大小不同的内存块(这11个双向链表都存储在 free_area 中)
- 在操作内存时,经常将这些内存块分成大小相等的两个块,分成的两个内存块被称为伙伴块,采用 “一位二进制数” 来表示它们的伙伴关系(这个 “一位二进制数” 存储在位图 bitmap 中)
- 系统根据该位为 “0” 或位为 “1” 来决定是否使用或者分配该页面块,系统每次分配和回收伙伴块时都要对它们的伙伴位跟 “1” 进行异或运算
Cross-Cache Overflow 就是为了实现跨 kmem_cache 溢出的利用手法:
- slub 底层逻辑是向 buddy system 请求页面后再划分成特定大小 object 返还给上层调用者
- 但内存中用作不同
kmem_cache
的页面在内存上是有可能相邻的 - 若我们的漏洞对象存在于页面 A,溢出目标对象存在于页面 B,且 A,B 两页面相邻,则我们便有可能实现跨越不同
kmem_cache
之间的堆溢出
Cross-Cache Overflow 需要两个 page 相邻排版,此时又需要使用另一个技术:页级堆风水
页级堆风水
页级堆风水即以内存页为粒度的内存排布方式,而内核内存页的排布对我们来说不仅未知且信息量巨大,因此这种利用手法实际上是让我们手工构造一个新的已知的页级粒度内存页排布
伙伴系统采用一个双向链表数组 free_area 来管理各个空闲块,在分配 page 时有如下的逻辑:
- free_area 的每个条目都是一个用于管理 2^n 大小空闲块的双向链表,每个 free_area[x] 都有一个 map 位图(用于表示各个伙伴块的关系)
- 当一个 m page 大小的空间将要被申请时,伙伴系统会首先在 free_area[n] 中查找(刚好满足条件的最小 n)
- 如果 free_area[n] 中有合适的内存块就直接分配出去,如果没有就继续在 free_area[n+1] 中查找
- 如果 free_area[n+1] 中有合适的内存块,就会将其均分为两份:
- 其中一份分配出去
- 另一个插入 free_area[n] 中
- 如果 free_area[n+1] 中也没有合适的内存块,则重复上面的过程,如果到达 free_area 数组的末端则放弃分配
- 如果在 bitmap 中检测到有两个伙伴块都处于空闲状态,则会进行合并,然后插入上级链表
通过伙伴系统的分配流程我们可以发现:互为伙伴块的两片内存块一定是连续的
从更高阶 order 拆分成的两份低阶 order 的连续内存页是物理连续的,由此我们可以:
- 向 buddy system 请求两份连续的内存页
- 释放其中一份内存页,在
vulnerable kmem_cache
上堆喷,让其取走这份内存页 - 释放另一份内存页,在
victim kmem_cache
上堆喷,让其取走这份内存页
这样就可以保证 vulnerable kmem_cache
和 victim kmem_cache
就一定是连续的
如果想要完成上述操作,就需要使用 setsockopt 与 pgv 完成页级内存占位与堆风水
setsockopt + pgv
函数 setsockopt
用于任意类型,任意状态套接口的设置选项值,其函数原型如下:
1 | int setsockopt( int socket, int level, int option_name,const void *option_value, size_t ption_len); |
- socket:套接字
- level:被设置的选项的级别(如果想要在套接字级别上设置选项,就必须把 level 设置为 SOL_SOCKET)
- option_name:指定准备设置的“选项”
- option_value:指向存放选项值的缓冲区(用于设置所选“选项”的值)
- ption_len:缓冲区的长度
- 返回值:若无错误发生返回 “0”,否则返回 SOCKET_ERROR 错误(应用程序可通过
WSAGetLastError()
获取相应错误代码)
利用步骤如下:
- 创建一个 protocol 为
PF_PACKET
的 socket
1 | socket_fd = socket(AF_PACKET, SOCK_RAW, PF_PACKET); |
- 先调用
setsockopt
将PACKET_VERSION
设为TPACKET_V1
/TPACKET_V2
()
1 | setsockopt(socket_fd, SOL_PACKET, PACKET_VERSION, &version, sizeof(version)); |
- 再调用
setsockopt
提交一个PACKET_TX_RING
1 | req.tp_block_size = size; |
此时便存在如下调用链:
1 | __sys_setsockopt() |
- 在
alloc_pg_vec
中会创建一个pgv
结构体,用以分配tp_block_nr
份 2^order 大小的内存页,其中order
由tp_block_size
决定
1 | static struct pgv *alloc_pg_vec(struct tpacket_req *req, int order) |
- 在
alloc_one_pg_vec_page
中会直接调用__get_free_pages
向 buddy system 请求内存页,因此我们可以利用该函数进行大量的页面请求
当我们耗尽 buddy system 中的 low order page 后,我们再请求的页面便都是物理连续的,因此此时我们再进行 setsockopt
便相当于获取到了一块近乎物理连续的内存:
- 不能分配 low order page 时,程序就会从上一级的 free_area 中分配一个内存块
- 然后等分为两个 low order page,这两个 low order page 就是物理连续的
- 在
setsockopt
的流程中同样会分配大量我们不需要的结构体,从而消耗 buddy system 的部分页面,产生“噪声”
具体的操作就是利用 setsockopt
申请大量的 1 page 内存块,部分 setsockopt
用于耗尽 low order page,而剩下的就有几率成为连续内存
入侵思路
其实入侵的思路很简单:就是通过这6字节的溢出来覆盖 cred 从而实现提权
结构体 cred 所使用的 kmem_cache
是 cred_jar
cred
的大小为 192cred_jar
向 buddy system 单次请求 1 page 大小的内存块,足够分配21个cred
- 利用系统调用 clone 可以申请新的
cred
,从而耗尽cred_jar
具体的利用思路如下:
- 先分配大量的单张内存页,耗尽 buddy 中的 low order page
- 间隔一张内存页释放掉部分单张内存页,之后堆喷
cred
,这样便有几率获取到我们释放的单张内存页 - 释放掉之前的间隔内存页,调用漏洞函数分配堆块,这样便有几率获取到我们释放的间隔内存页
- 而间隔的两张内存页又有几率互为伙伴(物理地址连续)
- 最后利用模块中漏洞进行越界写,篡改
cred->uid
,完成提权
我们先利用 setsockopt
申请大量的 1 page 内存块,参考函数如下:
1 |
|
- 低权限用户无法使用上述函数,但是我们可以通过开辟新的命名空间来绕过该限制
- 这里需要注意的是我们提权的进程不应当和页喷射的进程在同一命名空间内,因为要在原本的命名空间完成提权
- 因此选择 fork 一个子进程,然后在子进程中开辟命名空间并完成页喷射(通过管道完成父子进程通信)
开辟命令空间的函数如下:
1 | void unshare_setup(void) |
使用系统调用 clone 来耗尽 cred_jar
的函数如下:
1 | __attribute__((naked)) long simple_clone(int flags, int (*fn)(void *)) |
- 本进程的 cred 我们是没有机会覆盖的,我们只能覆盖 clone 进程的 cred
- 然后在主进程中触发溢出去覆盖 clone 进程的 cred,在 clone 进程里面尝试 get root
- clone 后的父进程 ret,子进程则跳转到函数指针 fn,并在该函数中等待 root 权限
等待 root 权限的函数如下:
1 | int waiting_for_root_fn(void *args) |
最后组合一下就可以完成 exp 了
完整 exp 如下:
1 |
|
小结:
学到了 Cross-Cache Overflow 和页级堆风水