overview
笔者厌倦了用户态堆的种种tricks,于是决定进入kernel pwn的大坑😊
安全机制
CONFIG_CFI_CLANG
控制流完整性校验,限制ROP
CONFIG_SLAB_FREELIST_HARDENED
类似于用户态下 glibc 中的 safe-linking 机制,在内核中的 slab/slub 分配器当中也存在着类似的机制保护着 freelist—— SLAB_FREELIST_HARDENED
:
类似于 glibc 2.32 版本引入的保护,在开启这种保护之前,slub 中的 free object 的 next 指针直接存放着 next free object 的地址,攻击者可以通过读取 freelist 泄露出内核线性映射区的地址,在开启了该保护之后 free object 的 next 指针存放的是由以下三个值进行异或操作后的值:
- 当前 free object 的地址
- 下一个 free object 的地址
- 由 kmem_cache 指定的一个 random 值
CONFIG_HARDENED_USERCOPY
hardened usercopy 是用以在用户空间与内核空间之间拷贝数据时进行越界检查的一种防护机制,主要检查拷贝过程中对内核空间中数据的读写是否会越界:
- 读取的数据长度是否超出源 object 范围
- 写入的数据长度是否超出目的 object 范围
不过这种保护 不适用于内核空间内的数据拷贝 ,这也是目前主流的绕过手段
这一保护被用于 copy_to_user()
与 copy_from_user()
等数据交换 API 中
CONFIG_SLAB_FREELIST_RANDOM
这种保护主要发生在 slub allocator 向 buddy system 申请到页框之后的处理过程中,对于未开启这种保护的一张完整的 slub,其上的 object 的连接顺序是线性连续的,但在开启了这种保护之后其上的 object 之间的连接顺序是随机的,这让攻击者无法直接预测下一个分配的 object 的地址
需要注意的是这种保护发生在slub allocator 刚从 buddy system 拿到新 slub 的时候,运行时 freelist 的构成仍遵循 LIFO
CONFIG_INIT_ON_ALLOC_DEFAULT_ON
当编译内核时开启了这个选项时,在内核进行“堆内存”分配时(包括 buddy system 和 slab allocator),会将被分配的内存上的内容进行清零,从而防止了利用未初始化内存进行数据泄露的情况
CONFIG_RANDOMIZE_KSTACK_OFFSET
决定内核栈是否存在随机偏移
CONFIG_MEMCG_KMEM
决定GFP_KERNEL
与 GFP_KERNEL_ACCOUNT
是否会从同样的 kmalloc-xx
中进行分配
CONFIG_CFI_CLANG
决定是否开启CFI(控制流完整性), 限制了ROP
CONFIG_STATIC_USERMODEHELPER
决定modprobe_path 是否可写
信息搜集
- 查看内核版本
1 | cat /proc/version |
- 检查各种基础保护
- 启动脚本
- pti=on
- smep,smap
- kaslr
- .config
- 检查分配方式
Target
以下部分来自 ctf-wiki, 笔者会添加一些自己的理解。
modify cred
内核pwn的大部分目标都是实现提权,而一个进程的权限是由其对应的cred结构体决定的,因此。
kernel通过task_struct 中的cred的指针来索引cred结构体,
更进一步地,通过cred的结构体来识别当前user,因此可以通过修改当前cred结构体或者task_struct的指针来达成提权的效果。
1 | struct cred { |
直接定位cred
当拥有内存读写的能力后,可以通过在内存中搜索magic 来查找cred结构体。
// 笔者尝试搜索后,发现不知道为什么,有些cred结构体,magic字段为空 #TODO
笔者给出另一个cred定位方法,在内核态下, GS 段 存储着进程相关控制信息,在其固定偏移,可以找到当前cred结构体的指针。
当然,显然大部分情况,是基本不可能找到恰好访问gs目标偏移地址的gadget的,因此这个方法并不是非常实用。
commit_creds
commit_creds()
函数被用以将一个新的 cred 设为当前进程 task_struct 的 real_cred 与 cred 字段,因此若是我们能够劫持内核执行流调用该函数并传入一个具有 root 权限的 cred,则能直接完成对当前进程的提权工作
// 笔者目前还没有看过commit_creds()
的源代码,并不清楚对cred有哪些检查
// 在笔者看来,如果没有限制 传入的creds必须是相应 slab_account
的话,其实可以自己找一块内存区域来写
prepare_kernel_cred()
在内核当中提供了 prepare_kernel_cred()
函数用以拷贝指定进程的 cred 结构体,当我们传入的参数为 NULL 时,该函数会拷贝 init_cred
并返回一个有着 root 权限的 cred:
1 | struct cred *prepare_kernel_cred(struct task_struct *daemon) |
我们不难想到的是若是我们可以在内核空间中调用 commit_creds(prepare_kernel_cred(NULL))
,则也能直接完成提权的工作
不过自从内核版本 6.2 起,prepare_kernel_cred(NULL)
将不再拷贝 init_cred,而是将其视为一个运行时错误并返回 NULL,这使得这种提权方法无法再应用于 6.2 及更高版本的内核
init_cred
在内核初始化过程当中会以 root 权限启动 init
进程,其 cred 结构体为静态定义的 init_cred
,由此不难想到的是我们可以通过 commit_creds(&init_cred)
来完成提权的工作
// 一个问题是,在高版本,init_cred本身不再作为一个符号导出,因此你直接
// kallsyms-finder
是找不到相应地址的
// 一个直接的方法是,在相应版本linux源代码里面直接搜索符号引用
// 可以在内核代码段里面找到相应地址
// 这个方法不仅仅可以用于init_cred
,一切内核data段的匿名结构体都可以通过这个方法查找, 除非内核在写的时候本身就没有直接访问
modprobe_path
modprobe 是linux的一个用于执行不确定格式文件的一个机制,其会以root权限使用modprobe_path指向的解释器来实现相对应的程序,如果我们能够劫持相关的程序,就能以root权限执行一个程序,从而提权
- 获取 modprobe_path 的地址。
- 修改 modprobe_path 为指定的程序。
- 触发执行
call_modprobe
,从而实现提权 。这里我们可以利用以下几种方式来触发- 执行一个非法的可执行文件。非法的可执行文件需要满足相应的要求(参考 call_usermodehelper 部分的介绍)。
- 使用未知协议来触发。
1 | // step 1. modify modprobe_path to the target value |
在这个过程中,我们着重关注下如何定位 modprobe_path。
直接定位
由于 modprobe_path 的取值是确定的,所以我们可以直接扫描内存,寻找对应的字符串。这需要我们具有扫描内存的能力。
间接定位
考虑到 modprobe_path 相对于内核基地址的偏移是固定的,我们可以先获取到内核的基地址,然后根据相对偏移来得到 modprobe_path 的地址。
poweroff_cmd
类似于modprobe_path
- 修改 poweroff_cmd 为指定的程序。
- 劫持控制流执行
__orderly_poweroff
。
关于如何定位 poweroff_cmd,我们可以采用类似于定位 modprobe_path
的方法。
一些宏
以下列出了常用的一些宏
1 |
|
1 |
1 |
1 |
1 |
1 |
|
此部分来自 https://elixir.bootlin.com/linux/v6.7.8/source/include/linux/gfp_types.h
如果需要快速知道对应的宏的值,可以直接用C 来 printf
如GFP_KERNEL: 0x6000C0
攻击方法
- ROP
- ret2usr
- pt_regs
- sycrop
- ret2dir
- heap
- heap spray
- heap overflow
- double free
- Cross cache overflow
- page level heap fenshui
- Race Condition
- USMA
- 基于idt的内存搜索
ROP
ret2usr
由于KPTI的出现,ret2usr实际上已经不可用了,这里介绍一下ret2usr仅仅是为了拓展了解。
简单来说,ret2usr的核心就是利用内核的ring 0权限,执行用户空间的代码来实现提权。
一个典型的ret2usr rop链:
1 | rop_chain[i++] = (size_t)getRootPrivilige; |
这里的getRootPrivilige就是用户态的 提权代码。
绕过SMAP与SMEP
SMAP和SMEP是 x64
限制内核和用户空间的数据访问的一个架构功能,通过CR4寄存器的低位来判断是否开启。 开启后 从内核态访问用户态的数据会直接panic,因此通过在ROP链中插入 修改 cr4
寄存器的gadget即可绕过
gdb 无法查看 cr4 寄存器的值,可以通过 kernel crash 时的信息查看。为了关闭 smep 保护,常用一个固定值 0x6f0
,即 mov cr4, 0x6f0
。
KPTI如何限制ret2usr
最后讨论一下KPTI的实现
When PTI is enabled, the kernel manages two sets of page tables. The first set is very similar to the single set which is present in kernels without PTI. This includes a complete mapping of userspace that the kernel can use for things like copy_to_user().
Although complete, the user portion of the kernel page tables is crippled by setting the NX bit in the top level. This ensures that any missed kernel->user CR3 switch will immediately crash userspace upon executing its first instruction.
The userspace page tables map only the kernel data needed to enter and exit the kernel. This data is entirely contained in the ‘struct cpu_entry_area’ structure which is placed in the fixmap which gives each CPU’s copy of the area a compile-time-fixed virtual address.
For new userspace mappings, the kernel makes the entries in its page tables like normal. The only difference is when the kernel makes entries in the top (PGD) level. In addition to setting the entry in the main kernel PGD, a copy of the entry is made in the userspace page tables’ PGD.
This sharing at the PGD level also inherently shares all the lower layers of the page tables. This leaves a single, shared set of userspace page tables to manage. One PTE to lock, one set of accessed bits, dirty bits, etc…
KPTI维护两套页表,一套和没有开启KPTI 时的页表类似,拥有用户态和内核态的完整映射,这是给内核态使用的,不同的是, 此页表对于用户态内存空间的映射,是没有可执行权限的,这里权限的限制是通过页表的权限位来实现的,因此ret2usr如果关闭了smap和smep,尽管可以访问到用户态数据,但是无法执行用户态代码;
此外,供给用户态的页表,拥有用户态的完整映射和内核的部分映射,这部分映射仅包含进入和离开内核态的代码。
pt_regs 与 KROP
在5.xx版本(笔者还没有检查具体是哪些版本),或者高版本没有开启如下选项时:
1 | CONFIG_RANDOMIZE_KSTACK_OFFSET |
pt_regs是进入内核态时,压入栈中的结构
1 | struct pt_regs { |
我们注意到,这些内容,由用户态的寄存器决定,可以由我们控制。
因此这些部分可以用于布置ROP链, 当劫持到内核某个结构体的函数指针时,只需要寻找到一条形如 “add rsp, val ; ret” 的 gadget 便能够完成 ROP
具体而言,当通过syscall触发进入内核态前,我们通过在用户态控制所有寄存器,之后,触发syscall时,在syscall_entry 会将用户态的所有寄存器压入栈中来保存运行状态,这时,如果我们能劫持控制流,并通过类似 add rsp, val ; ret
的gadget来迁移栈,在我们可以控制的pt_regs
上进行ROP
然而,在之后的内核版本中,加入了
CONFIG_RANDOMIZE_KSTACK_OFFSET
, 使得在进入内核时,会产生一个随机栈偏移,使得此利用的稳定性下降。
ret2dir
内核堆区 direct_mapping_arean 存在对于整个物理内存的映射,因此,通过mmap在用户态喷射的匿名页面,实际上也从此分配。
通过mmap大量分配,可以获取到 kernel 上一块近乎连续的物理内存,因此,通过不断堆喷布置gadget滑块,然后随机选择一个内核基地址进行栈迁移,最终就有很大概率命中我们写入的页面。
sycrop
通过下硬件断点在用户态触发的方式,可以将寄存器内容推送到与 per_cpu_entry_area
固定偏移的DB stack上,而在linux 6.2之前, per_cpu_entry_area
没有加入随机化,地址固定,所以可以达到在内核固定地址造ROP链的手段
work_for_cpu_fn
这实际上是一个tricks,在内核很难ROP时,可以利用
1 | static void work_for_cpu_fn(struct work_struct *work) |
在劫持rsi的情况。
这个函数可以实现执行一次函数调用,并将返回值保存
overview
注意到,上述列出的几个攻击方法,实际上核心问题就是ROP链写在哪些地方。
- pt_regs: 写在内核栈上
- ret2dir: 写在direct mapping arena
- sycrop: 写在加入随机化的区域
由于ROP可以很方便劫持控制流,所以使用ROP攻击内核时,一般使用 commit_cred
进行提权
遗憾的是,在高版本内核,由于CFI的引入,很多时候难以找到完善的gadget进行利用,限制了ROP的使用
heap
UAF
有效大小Obj的UAF和良好的kmalloc flag
这里主要指和内核关键结构体存在同样的分配size和flag的UAF, 如 tty_operations
或 seq_operations
等等。
利用这些结构的UAF可以直接leak 内核数据或者劫持控制流,这个攻击流程就不赘述了。
任意大小的UAF
接下来讲述一下任意大小UAF(也没有那么任意)的利用
CVE-2021-22555: 基于msg_msg的堆喷 | GFP_KERNEL_ACCOUNT
基于add_key的堆喷
UASM
见后文UASM的利用
cross cache UAF
#TODO
heap overflow
基础overflow
同上文,存在特定结构体的Overflow, 因此可以非常方便地控制一个有效结构,此时的利用非常简单。
cross_cache overflow | 打破slab隔离
众所周知,slab之间存在隔离,因此,如果溢出点在一个特定size的slab,此时,就无法通过直接的溢出劫持控制流。
但是,还是存在在buddy system溢出的办法。
考虑到堆喷耗尽buddy system的低位单页内存,那么之后从slab分配就会从高位连续的页面中切分,此时,就可以使得分配的页面来自一块近乎物理连续的内存,此时,如果在某个页面末尾的slab溢出,那么就可以溢出到下一个页面。
如果下一个页面,被另一个cache申请用来分配另外一种slab,此时就可以实现跨cache的溢出,从而控制有意义的cache.
基于pipe_buffer的溢出通解
#TODO
Race condition
double feach
由于内核模块是全局的,如果对于内核模块的数据访问没有加锁,就很有可能出现竞态漏洞。
userfault
在linux 5.11以下可用。
主要是用来辅助条件竞争漏洞。
userfault是一个在用户态进行缺页处理接口。
在正常情况下, race condition的时间窗口是很短暂的,如果能够通过userfault 将操作停住,就能够将竞争的时间窗口扩大,实现竞争。
fuse
// 通过CVE分析fuse的利用
#TODO
UASM
来自 https://vul.360.net/archives/391 的利用
这个利用笔者最初有点犹豫放在哪个部分。
最后笔者还是决定将内容单独列一个二级目录,因为笔者认为这代表了一种新的利用方法, 不仅仅是 pg_vec, 在io_uring中,同样存在着内核和用户地址的共同映射,有没有可能也利用此来利用呢。
甚至直接对内核页表进行修改,实际上也可以归结为这种利用的一部分。
更进一步的, 笔者认为,UASM 也许可以用在page level uaf中(由于笔者太菜了,暂时先码着)。
简单而言,在创建socket并设置packet后,此时,内核维护一个 pg_vec
数组,每一个数组地址对应着一个虚拟地址。
此时,如果能够通过UAF或者溢出修改pg_vec, 然后再在用户态调用mmap,内核实际上:
1 | /net/packet/af_packet.c |
通过vm_insert_page
将这些页,插入了用户态地址空间。
这些页需要满足如下要求
- page不为匿名页
- 不为Slab子系统分配的页
- page不含有type
这就限制了使用内核堆的页面。
1 | /mm/memory.c |
值得一提的是,这里pg_vec原来的虚拟地址原来的权限是无所谓的,因为并没有对原来虚拟地址的内存权限(也即这个页表项的内存权限)进行检查。
因此我们可以直接修改内核代码段或者内核模块代码段、数据段。
而且线性映射区域存在内核的全部映射,可以在这个地址范围找到上述页面。
更妙的是,pg_vec可以由用户态决定,不过其分配flag是GFP_KERNEL
相比于ROP,利用更加简单,并且不受CFI的影响。
dirtypagetable
#TODO
POP | page level ROP
来自blachhat2021的一种思路,主要是用来拓展脑洞,实际利用起来不如UASM直接改内核代码方便。但是很有传统利用的美感。
#TODO
tricks
- 基于inter硬件漏洞的leak tricks
- 在内核“堆基址”(
page_offset_base
) +0x9d000
处存放着secondary_startup_64
函数的地址
从CTF到实战利用的哲思
#TODO