Pwn.Linux-Kernel-Pwn-All-in-One
2024-03-05 09:58:38 # Pwn

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. 查看内核版本
1
cat /proc/version
  1. 检查各种基础保护
  • 启动脚本
    • pti=on
    • smep,smap
    • kaslr
  • .config
  • 检查分配方式

Target

以下部分来自 ctf-wiki, 笔者会添加一些自己的理解。

modify cred

内核pwn的大部分目标都是实现提权,而一个进程的权限是由其对应的cred结构体决定的,因此。

kernel通过task_struct 中的cred的指针来索引cred结构体,
更进一步地,通过cred的结构体来识别当前user,因此可以通过修改当前cred结构体或者task_struct的指针来达成提权的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
...
}

直接定位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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct cred *prepare_kernel_cred(struct task_struct *daemon)
{
const struct cred *old;
struct cred *new;

new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
if (!new)
return NULL;

kdebug("prepare_kernel_cred() alloc %p", new);

if (daemon)
old = get_task_cred(daemon);
else
old = get_cred(&init_cred);

我们不难想到的是若是我们可以在内核空间中调用 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权限执行一个程序,从而提权

  1. 获取 modprobe_path 的地址。
  2. 修改 modprobe_path 为指定的程序。
  3. 触发执行 call_modprobe,从而实现提权 。这里我们可以利用以下几种方式来触发
    1. 执行一个非法的可执行文件。非法的可执行文件需要满足相应的要求(参考 call_usermodehelper 部分的介绍)。
    2. 使用未知协议来触发。
1
2
3
4
5
6
7
8
9
10
11
12
13
// step 1. modify modprobe_path to the target value

// step 2. create related file
system("echo -ne '#!/bin/sh\n/bin/cp /flag /home/pwn/flag\n/bin/chmod 777 /home/pwn/flag\ncat flag' > /home/pwn/catflag.sh");
system("chmod +x /home/pwn/catflag.sh");

// step 3. trigger it using unknown executable
system("echo -ne '\\xff\\xff\\xff\\xff' > /home/pwn/dummy");
system("chmod +x /home/pwn/dummy");
system("/home/pwn/dummy");

// step 3. trigger it using unknown protocol
socket(AF_INET,SOCK_STREAM,132);

在这个过程中,我们着重关注下如何定位 modprobe_path。

直接定位

由于 modprobe_path 的取值是确定的,所以我们可以直接扫描内存,寻找对应的字符串。这需要我们具有扫描内存的能力。

间接定位

考虑到 modprobe_path 相对于内核基地址的偏移是固定的,我们可以先获取到内核的基地址,然后根据相对偏移来得到 modprobe_path 的地址。

poweroff_cmd

类似于modprobe_path

  1. 修改 poweroff_cmd 为指定的程序。
  2. 劫持控制流执行 __orderly_poweroff

关于如何定位 poweroff_cmd,我们可以采用类似于定位 modprobe_path 的方法。

一些宏

以下列出了常用的一些宏

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
27
28
29
30
31
32
33
34
35
36
#define ___GFP_DMA		0x01u
#define ___GFP_HIGHMEM 0x02u
#define ___GFP_DMA32 0x04u
#define ___GFP_MOVABLE 0x08u
#define ___GFP_RECLAIMABLE 0x10u
#define ___GFP_HIGH 0x20u
#define ___GFP_IO 0x40u
#define ___GFP_FS 0x80u
#define ___GFP_ZERO 0x100u
/* 0x200u unused */
#define ___GFP_DIRECT_RECLAIM 0x400u
#define ___GFP_KSWAPD_RECLAIM 0x800u
#define ___GFP_WRITE 0x1000u
#define ___GFP_NOWARN 0x2000u
#define ___GFP_RETRY_MAYFAIL 0x4000u
#define ___GFP_NOFAIL 0x8000u
#define ___GFP_NORETRY 0x10000u
#define ___GFP_MEMALLOC 0x20000u
#define ___GFP_COMP 0x40000u
#define ___GFP_NOMEMALLOC 0x80000u
#define ___GFP_HARDWALL 0x100000u
#define ___GFP_THISNODE 0x200000u
#define ___GFP_ACCOUNT 0x400000u
#define ___GFP_ZEROTAGS 0x800000u
#ifdef CONFIG_KASAN_HW_TAGS
#define ___GFP_SKIP_ZERO 0x1000000u
#define ___GFP_SKIP_KASAN 0x2000000u
#else
#define ___GFP_SKIP_ZERO 0
#define ___GFP_SKIP_KASAN 0
#endif
#ifdef CONFIG_LOCKDEP
#define ___GFP_NOLOCKDEP 0x4000000u
#else
#define ___GFP_NOLOCKDEP 0
#endif
1
2
3
4
5
6
#define __GFP_DMA	((__force gfp_t)___GFP_DMA)
#define __GFP_HIGHMEM ((__force gfp_t)___GFP_HIGHMEM)
#define __GFP_DMA32 ((__force gfp_t)___GFP_DMA32)
#define __GFP_MOVABLE ((__force gfp_t)___GFP_MOVABLE) /* ZONE_MOVABLE allowed */
#define GFP_ZONEMASK (__GFP_DMA|__GFP_HIGHMEM|__GFP_DMA32|__GFP_MOVABLE)

1
2
3
4
5
#define __GFP_RECLAIMABLE ((__force gfp_t)___GFP_RECLAIMABLE)
#define __GFP_WRITE ((__force gfp_t)___GFP_WRITE)
#define __GFP_HARDWALL ((__force gfp_t)___GFP_HARDWALL)
#define __GFP_THISNODE ((__force gfp_t)___GFP_THISNODE)
#define __GFP_ACCOUNT ((__force gfp_t)___GFP_ACCOUNT)
1
2
3
#define __GFP_HIGH	((__force gfp_t)___GFP_HIGH)
#define __GFP_MEMALLOC ((__force gfp_t)___GFP_MEMALLOC)
#define __GFP_NOMEMALLOC ((__force gfp_t)___GFP_NOMEMALLOC)
1
2
3
4
5
6
7
8
#define __GFP_IO	((__force gfp_t)___GFP_IO)
#define __GFP_FS ((__force gfp_t)___GFP_FS)
#define __GFP_DIRECT_RECLAIM ((__force gfp_t)___GFP_DIRECT_RECLAIM) /* Caller can reclaim */
#define __GFP_KSWAPD_RECLAIM ((__force gfp_t)___GFP_KSWAPD_RECLAIM) /* kswapd can wake */
#define __GFP_RECLAIM ((__force gfp_t)(___GFP_DIRECT_RECLAIM|___GFP_KSWAPD_RECLAIM))
#define __GFP_RETRY_MAYFAIL ((__force gfp_t)___GFP_RETRY_MAYFAIL)
#define __GFP_NOFAIL ((__force gfp_t)___GFP_NOFAIL)
#define __GFP_NORETRY ((__force gfp_t)___GFP_NORETRY)
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
27
28
29
#define __GFP_NOWARN ((__force gfp_t)___GFP_NOWARN)
#define __GFP_COMP ((__force gfp_t)___GFP_COMP)
#define __GFP_ZERO ((__force gfp_t)___GFP_ZERO)
#define __GFP_ZEROTAGS ((__force gfp_t)___GFP_ZEROTAGS)
#define __GFP_SKIP_ZERO ((__force gfp_t)___GFP_SKIP_ZERO)
#define __GFP_SKIP_KASAN ((__force gfp_t)___GFP_SKIP_KASAN)

/* Disable lockdep for GFP context tracking */
#define __GFP_NOLOCKDEP ((__force gfp_t)___GFP_NOLOCKDEP)

/* Room for N __GFP_FOO bits */
#define __GFP_BITS_SHIFT (26 + IS_ENABLED(CONFIG_LOCKDEP))
#define __GFP_BITS_MASK ((__force gfp_t)((1 << __GFP_BITS_SHIFT) - 1))

#define GFP_ATOMIC (__GFP_HIGH | __GFP_KSWAPD_RECLAIM)
#define GFP_KERNEL (__GFP_RECLAIM | __GFP_IO | __GFP_FS)
#define GFP_KERNEL_ACCOUNT (GFP_KERNEL | __GFP_ACCOUNT)
#define GFP_NOWAIT (__GFP_KSWAPD_RECLAIM)
#define GFP_NOIO (__GFP_RECLAIM)
#define GFP_NOFS (__GFP_RECLAIM | __GFP_IO)
#define GFP_USER (__GFP_RECLAIM | __GFP_IO | __GFP_FS | __GFP_HARDWALL)
#define GFP_DMA __GFP_DMA
#define GFP_DMA32 __GFP_DMA32
#define GFP_HIGHUSER (GFP_USER | __GFP_HIGHMEM)
#define GFP_HIGHUSER_MOVABLE (GFP_HIGHUSER | __GFP_MOVABLE | __GFP_SKIP_KASAN)
#define GFP_TRANSHUGE_LIGHT ((GFP_HIGHUSER_MOVABLE | __GFP_COMP | \
__GFP_NOMEMALLOC | __GFP_NOWARN) & \
~__GFP_RECLAIM)
#define GFP_TRANSHUGE (GFP_TRANSHUGE_LIGHT | __GFP_DIRECT_RECLAIM)

此部分来自 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
2
3
4
5
6
7
8
9
rop_chain[i++] = (size_t)getRootPrivilige;  
rop_chain[i++] = SWAPGS_POPFQ_RET + offset;
rop_chain[i++] = 0;
rop_chain[i++] = IRETQ + offset;
rop_chain[i++] = (size_t)getRootShell;
rop_chain[i++] = user_cs;
rop_chain[i++] = user_rflags;
rop_chain[i++] = user_sp;
rop_chain[i++] = user_ss;

这里的getRootPrivilige就是用户态的 提权代码。

绕过SMAP与SMEP

SMAP和SMEP是 x64 限制内核和用户空间的数据访问的一个架构功能,通过CR4寄存器的低位来判断是否开启。 开启后 从内核态访问用户态的数据会直接panic,因此通过在ROP链中插入 修改 cr4 寄存器的gadget即可绕过

Pasted-image-20240305085117.png

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
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
27
28
29
30
31
32
33
34
struct pt_regs {  
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax;
/* Return frame for iretq */
unsigned long rip;
unsigned long cs;
unsigned long eflags;
unsigned long rsp;
unsigned long ss;
/* top of stack page */
};

我们注意到,这些内容,由用户态的寄存器决定,可以由我们控制。

因此这些部分可以用于布置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
2
3
4
5
6
7
8
static void work_for_cpu_fn(struct work_struct *work)

{

struct work_for_cpu *wfc = container_of(work, struct work_for_cpu, work);
wfc->ret = wfc->fn(wfc->arg);

}

在劫持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_operationsseq_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/net/packet/af_packet.c

static int packet_mmap(file, sock, vma)
{
for (rb = &po->rx_ring; rb <= &po->tx_ring; rb++) {
for (i = 0; i < rb->pg_vec_len; i++) {
struct page *page;
void *kaddr = rb->pg_vec[i].buffer;
for (pg_num = 0; pg_num < rb->pg_vec_pages; pg_num++) { page = pgv_to_page(kaddr);
err = vm_insert_page(vma, start, page);
if (unlikely(err))
goto out;
start += PAGE_SIZE;
kaddr += PAGE_SIZE;
}
}
}
return err;
}

通过vm_insert_page 将这些页,插入了用户态地址空间。

这些页需要满足如下要求

  • page不为匿名页
  • 不为Slab子系统分配的页
  • page不含有type

这就限制了使用内核堆的页面。

1
2
3
4
5
6
7
8
9
/mm/memory.c

static int validate_page_before_insert(struct page *page)
{
if (PageAnon(page) || PageSlab(page) || page_has_type(page))
return -EINVAL;
flush_dcache_page(page);
return 0;
}

值得一提的是,这里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