Linux Kernel 中的保护机制和攻击方法

警告
本文最后更新于 2022-08-10,文中内容可能已过时。

Linux Kernel 中的保护机制和攻击方法

KASLR

使用此选项后,每次系统启动时,内核代码在内存中的位置都是随机的,如果想要调用到内核上的代码段,我们可能需要泄露指针来计算当前内核的基地址

当不开启 kaslr 的时候,默认的内核虚拟机基地址是:0xffffffff81000000

开启与关闭

如果是使用 qemu 启动的内核,可以在 qemu 的 -append 选项中添加 kaslr 来开启,关闭则使用 nokaslr

绕过 KASLR

  • 根据泄露内核地址指针,通过偏移计算出内核基址和内核偏移
  • 让 关闭 kaslr 时的目标内核函数地址加上内核偏移,计算得到目标函数地址(其中关闭 kaslr 时的内核函数地址可以通过在 nokaslr 时,在 root 权限下读取 /proc/kallsyms 得到)

FGKASLR - Function Granular KASLR

更细粒度的内核地址空间随机化,在加载时刻,按照函数级别的细粒度来重排内核代码。简单来说就是函数与函数之间也被打乱了,不能通过 leak_addr - offset 的方法得到内核基址和其他函数地址。

实现

利用 GCC 的编译选项 -ffunction-sections 把内核中不同的函数放到不同的 section 中,在编译的过程中,任何使用 C 语言编写的函数以及不在特殊输入节的函数都会作为一个独立的 section,使用汇编编写的代码会唯一一个统一的 section 中。

通过编译后 vmlinux 保留的所有的节区头(Section Headers)就可以知道每个函数的地址范围,同时,FGKASLR 还有一个重定位地址的扩展表。结合这两组信息,就可以乱序重排函数了。

开启与关闭

  • 开启:在编译 FGKASLR 内核时,需要开启 CONFIG_FG_KASLR=y 选项,FGKASLR 也支持模块的随机化,可以使用 CONFIG_MODULE_FG_KASLR=y 开启模块的 FGKASLR
  • 关闭:在 --append 使用 nokaslr 关闭 KASLR 的同时也会关闭 FGKASLR,但是也可以单独使用 nofgkaslr 来关闭 FGKALSR

绕过 FG_KASLR

  • 以函数粒度随机化,如果函数内的某个地址泄露了,那么函数内部的相对地址也可知

  • .text 节区不参与函数随机化。因此,一旦知道其中的某个地址,就可以获取该节区所有的地址。系统调用的入口代码都在该节区内,主要是因为这些代码都是汇编代码,该节区具有以下不错的 gadget

    • swapgs_restore_regs_and_return_to_usermode,可以用于 bypass KPTI
    • memcpy 内存拷贝
    • sync_regs,可以把 rax 放到 rdi 中
  • 内核符号表 __ksymtab 相对于内核镜像的偏移是固定的,因此,如果我们可以泄露数据,那就可以借此泄露出其他的符号地址

    • 根据 kernel_base + offset 计算出 __ksymtab 地址(__ksymtab 中每个记录项的名字的格式为 __ksymtab_func_name,以 prepare_kernel_cred 为例,对应的记录项的名字为 __ksymtab_prepare_kernel_cred
    • 根据 __ksymtab 获取对应符号记录项的地址
    • function_addr = __ksymtab_function_addr + value_offset - 2^32
    1
    2
    3
    4
    5
    
    struct kernel_symbol {
    	  int value_offset;
    	  int name_offset;
    	  int namespace_offset;
    };
  • 如果存在任意地址读写时,可以通过暴力搜索内存比对字节码的方法找到想要的 gadget 和 字符串,也可以暴力搜索堆空间找到 cred 结构体改掉

SMEP - Supervisor Mode Execution Protection

用户代码不可执行,由于内核在 R0 层,比 R3 层的用户态代码权限更高,所以起初在内核态执行代码时,可以直接执行用户态的代码。那如果攻击者控制了内核中的执行流,就可以直接执行用户态的代码,更容易实施攻击。在 Linux 内核中,这个防御的实现是与指令集相关的,在 x86 下对应的保护机制的名字为 SMEP,CR4 寄存器中的第 20 位用来标记是否开启 SMEP 保护

cr4

开启与关闭

默认情况下,SMEP 保护是开启的

如果是使用 qemu 启动的内核,可以在 qemu 的 -cpu 选项中添加 +smep 来开启 SMEP,如果不启用可不加

攻击 SMEP

设置 CR4 寄存器:把 CR4 寄存器中的第 20 位置为 0 后,我们就可以执行用户态的代码。一般而言,会使用 0x6f0 (011011110000b) 来设置 CR4,这样 SMAP 和 SMEP 都会被关闭

SMAP - Supervisor Mode Access Protecton

用户数据不可访问,在劫持内核控制流程后,可以通过栈迁移的手段将栈迁移到用户态来进行 ROP,由于用户态中控制数据容易,更容易实施攻击。在 Linux 内核中,这个防御的实现是与指令集相关的,在 x86 下对应的保护机制的名字为 SMAP,CR4 寄存器中第 21 位 来标记是否开启 SMAP 保护

cr4

开启与关闭

默认情况下,SMAP 保护是开启的

如果是使用 qemu 启动的内核,可以在 qemu 的 -cpu 选项中添加 +smap 来开启 SMEP,如果不启用可不加

攻击 SMAP

设置 CR4 寄存器:像 SMEP 的攻击手法一样,如果我们可以控制程序流程,就可以通过执行内核中对应的 gadget 来修改 CR4 为 0x6f0

copy_from_user / copy_to_user:在劫持控制流后,攻击者可以调用 copy_from_usercopy_to_user 来访问用户态内存,这两个函数会临时清空 SMAP 标志位,同时这两个函数在编写内核程序时也经常用到

1
2
3
4
5
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
 form 用户地址中的数据拷贝到 to 内核地址中去,拷贝长度 n
to: 将数据拷贝到内核地址
from: 需要拷贝数据的用户地址
n: 拷贝数据的长度(字节)
1
2
3
4
5
unsigned long __copy_to_user (void __user * to, const void * from, unsigned long n);
 from 内核地址中的数据拷贝到 to 用户地址中去,拷贝长度 n
to: 将数据拷贝到用户地址
from: 需要拷贝数据的内核地址
n: 拷贝数据的长度(字节)

KPTI - Kernel Page Table Isolation

Linux 采用四级页表结构(PGD -> PUD -> PMD -> PTE),其中 CR3 寄存器用以储存当前 PGD 的地址,在开启了 KPTI(内核页表隔离)后,内核空间和用户空间的 PGD 从原来的同一个被分割成了两个 PGD,但是为了提高切换的速度,这两个 PGD 放在一段连续的内存中,如下图所示

Rm8Ti9MpVUZ7fPK

其中内核空间的 PGD 在低地址,用户空间的 PGD 在高地址,一张表占用 4K 内存,两张表共占用 8K 内存,两者在物理内存相邻并且相差 0x1000 字节,这样只需要将 CR3 的第 13 位取反就能完成页表切换的操作(其中 1 为 用户空间的 PGD,0 为 内核空间的 PGD)

开启 KPTI 后的这两张页表存在一定的区别

476px-Kernel_page-table_isolation.svg

  • 用户页表上除了有对用户内存空间的完整映射,还有少量的内核代码(例如系统调用入口点、中断处理等)
  • 内核页表中有内核内存空间和用户内存空间的完整映射,但其中用户地址空间部分对应的部分不拥有可执行权限(NX),类似于 SMEP 保护,但目前还没有引入模拟出 SMAP 保护,无法使用 ret2usr ,但是可以用用户态的数据来进行栈迁移 ROP

攻击 KPTI

我们不能像之前那样直接 swapgs; iret 直接的返回用户态,而是在返回用户态前还要把用户进程的页表给切换回来,可以借助一个用于完成内核态页表切换回到用户态页表的函数 swapgs_restore_regs_and_return_to_usermode 中的 gadget,该函数可以直接从 mov rdi, rsp 开始执行,大概进行了如下操作

1
2
3
4
5
6
7
mov        rdi, cr3
or         rdi, 0x1000
mov        cr3, rdi
pop        rax
pop        rdi
swapgs
iretq

其中的 or rdi, 0x1000 即设置了 CR3 寄存器第 13 位 为 1 从而指向用户内存空间的 PGD

在布置 ROP 的时候,只需要布置出如下栈布局即可(在 iretq 时,栈顶应为 user_rip ):

1
2
3
4
5
6
7
8
   swapgs_restore_regs_and_return_to_usermode + xxx
    fake rax
    fake rdi
    user_rip
    user_cs
    user_rflags
    user_sp
    user_ss

其中,

  • fake raxfake rdi 的内容不重要,可以用任意数据填充
  • user_rip 可以填写返回用户态后要执行的函数,例如提权后执行的 system('/bin/bash')
  • user_csuser_rflsgsuser_spuser_ss 可以通过在用户态执行以下代码读取到
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
size_t user_cs, user_ss, user_rflags, user_sp;
void saveStatus()
{
    __asm__("mov user_cs, cs;"
            "mov user_ss, ss;"
            "mov user_sp, rsp;"
            "pushf;"
            "pop user_rflags;"
            );
    printf("\033[34m\033[1m[*] Status has been saved.\033[0m\n");
}

堆块隔离

如果在使用 kmem_cache_create 创建一个 cache 时,传递了 SLAB_ACCOUNT 标记,那么这个 cache 就会单独存在,不会与其他相同大小的 cache 合并。

在早期,许多结构体对应的堆块并不单独存在,会和相同大小的堆块使用相同的 cache。在 Linux 4.5 版本引入了这个 flag 后,许多结构体就单独使用了自己的 cache,以下结构体都拥有独立的 cache。

  • threadinfo
  • task_struct
  • task_delay_info
  • pid
  • cred
  • mm_struct
  • vm_area_struct and vm_region (nommu)
  • anon_vma and anon_vma_chain
  • signal_struct
  • sighand_struct
  • fs_struct
  • files_struct
  • fdtable and fdtable->full_fds_bits
  • dentry and external_name
  • inode for all filesystems

参考

  1. 【PWN.0x00】Linux Kernel Pwn I:Basic Exploit to Kernel Pwn in CTF
  2. CTF Wiki Kernel Mode Defense
0%