kernel基础知识


内核基础知识

kernel

用于管理软件发出的数据I/O要求的程序,将要求转义为指令交给CPU和其他组件处理,包括 I/O,权限控制,系统调用,进程管理,内存管理等,kernel 的 crash 通常会引起重启

  • 控制并与硬件进行交互
  • 提供应用能运行的环境

Ring Model

cpu分为ring 0-3四个特权级别,ring0os使用,ring3所以程序可用,大多操作系统仅ring0ring3

Loadable Kernel Modules(LKMs)

可加载核心模块 (或直接称为内核模块) ,是内核空间的可执行程序,包括驱动程序(设备驱动、文件系统驱动)和内核扩展模块,和用户态的可执行程序相同,Linux 下为 ELFWindows 下为 exe/dllmac 下为 MACH-O

模块可以被单独编译,但不能单独运行,运行时被链接到内核作为内核的一部分在内核空间运行,这与运行在用户控件的进程不同

Linux 内核之所以提供模块机制,是因为它本身是一个单内核 (monolithic kernel)。单内核的优点是效率高,因为所有的内容都集合在一起,但缺点是可扩展性和可维护性相对较差,模块机制就是为了弥补这一缺陷。

相关指令

  • insmod: 将指定模块加载到内核
  • rmmod: 从内核中卸载指定模块
  • lsmod: 列出已经加载的模块
  • modprobe: 添加或删除模块,加载模块时会查找依赖关系

大多数 CTF 中的 kernel vulnerability 也出现在 LKM 中。

syscall

系统调用,指的是用户空间的程序向操作系统内核请求需要更高权限的服务,系统调用提供用户程序与操作系统间的接口,部分库函数实际上是对系统调用的封装

ioctl

NAME
       ioctl - control device

SYNOPSIS
       #include <sys/ioctl.h>

       int ioctl(int fd, unsigned long request, ...);

ioctl也是一个系统调用,用于与设备通信

int ioctl(int fd, unsigned long request, ...) 的第一个参数为打开设备 (open) 返回的文件描述符,第二个参数为用户程序对设备的控制命令,再后边的参数则是一些补充参数,与设备有关

内核被设计成可扩展的,可以加入一个称为设备驱动的模块,驱动的代码允许在内核空间运行而且可以对设备直接寻址。对设备驱动的请求是一个以设备和请求号码为参数的 ioctl 调用,如此内核就允许用户空间访问设备驱动进而访问设备而不需要了解具体的设备细节,同时也不需要一大堆针对不同设备的系统调用

状态切换

user space to kernel space

当发生 系统调用产生异常外设产生中断 等事件时,会发生用户态到内核态的切换,具体的过程为:

  1. 通过 swapgs 切换 gs 段寄存器,将 gs 寄存器值和一个特定位置的值进行交换,目的是保存 gs 值,同时将该位置的值作为内核执行时的 gs 值使用

  2. 将当前栈顶(用户空间栈顶记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp

  3. 通过 push 保存各寄存器值,具体的代码 如下:

     ENTRY(entry_SYSCALL_64)
     /* SWAPGS_UNSAFE_STACK是一个宏,x86直接定义为swapgs指令 */
     SWAPGS_UNSAFE_STACK
    
     /* 保存栈值,并设置内核栈 */
     movq %rsp, PER_CPU_VAR(rsp_scratch)
     movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
    
    
    /* 通过push保存寄存器值,形成一个pt_regs结构 */
    /* Construct struct pt_regs on stack */
    pushq  $__USER_DS      /* pt_regs->ss */
    pushq  PER_CPU_VAR(rsp_scratch)  /* pt_regs->sp */
    pushq  %r11             /* pt_regs->flags */
    pushq  $__USER_CS      /* pt_regs->cs */
    pushq  %rcx             /* pt_regs->ip */
    pushq  %rax             /* pt_regs->orig_ax */
    pushq  %rdi             /* pt_regs->di */
    pushq  %rsi             /* pt_regs->si */
    pushq  %rdx             /* pt_regs->dx */
    pushq  %rcx tuichu    /* pt_regs->cx */
    pushq  $-ENOSYS        /* pt_regs->ax */
    pushq  %r8              /* pt_regs->r8 */
    pushq  %r9              /* pt_regs->r9 */
    pushq  %r10             /* pt_regs->r10 */
    pushq  %r11             /* pt_regs->r11 */
    sub $(6*8), %rsp      /* pt_regs->bp, bx, r12-15 not saved */
  4. 通过汇编指令判断是否为 x32_abi

  5. 通过系统调用号,跳到全局变量 sys_call_table 相应位置继续执行系统调用

kernel space to user space

退出时,流程如下:

  1. 通过 swapgs 恢复 gs
  2. 通过 sysretq 或者 iretq 恢复到用户控件继续执行。如果使用 iretq 还需要给出用户空间的一些信息(CS, eflags/rflags, esp/rsp 等)

struct cred

kernelcred 结构体记录了进程的权限等信息(uidgid 等),每个进程中都有一个 cred 结构,如果能修改某个进程的 cred,那么也就修改了这个进程的权限

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 */
    unsigned    securebits; /* SUID-less security management */
    kernel_cap_t    cap_inheritable; /* caps our children can inherit */
    kernel_cap_t    cap_permitted;  /* caps we're permitted */
    kernel_cap_t    cap_effective;  /* caps we can actually use */
    kernel_cap_t    cap_bset;   /* capability bounding set */
    kernel_cap_t    cap_ambient;    /* Ambient capability set */
#ifdef CONFIG_KEYS
    unsigned char   jit_keyring;    /* default keyring to attach requested
                     * keys to */
    struct key __rcu *session_keyring; /* keyring inherited over fork */
    struct key  *process_keyring; /* keyring private to this process */
    struct key  *thread_keyring; /* keyring private to this thread */
    struct key  *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
    void        *security;  /* subjective LSM security */
#endif
    struct user_struct *user;   /* real user ID subscription */
    struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
    struct group_info *group_info;  /* supplementary groups for euid/fsgid */
    struct rcu_head rcu;        /* RCU deletion hook */
} __randomize_layout;

内核态函数

相比用户态库函数,内核态的函数有了一些变化

  • printf() -> printk(),但需要注意的是 printk() 不一定会把内容显示到终端上,但一定在内核缓冲区里,可以通过 dmesg 查看效果
  • memcpy() -> copy_from_user()/copy_to_user()
    • copy_from_user()用户空间的数据传送到内核空间
    • copy_to_user() 内核空间的数据传送到用户空间
  • malloc() -> kmalloc(),内核态的内存分配函数,和malloc()相似,但使用的是 slab/slub 分配器
  • free() -> kfree(),同 kmalloc()

另外要注意的是,kernel 管理进程,也记录了进程的权限,kernel 中改变权限的函数:

  • int commit_creds(struct cred *new)
  • struct cred* prepare_kernel_cred(struct task_struct* daemon)

从函数名也可以看出,执行 commit_creds(prepare_kernel_cred(0)) 即可获得 root 权限,0 表示 以 0 号进程作为参考准备新的 credentials

执行 commit_creds(prepare_kernel_cred(0)) 也是最常用的提权手段,两个函数的地址都可以在 /proc/kallsyms 中查看(较老的内核版本中是 /proc/ksyms)。

$ sudo grep commit_creds /proc/kallsyms 
ffffffffbb6af9e0 T commit_creds
ffffffffbc7cb3d0 r __ksymtab_commit_creds
ffffffffbc7f06fe r __kstrtab_commit_creds
$ sudo grep prepare_kernel_cred /proc/kallsyms
ffffffffbb6afd90 T prepare_kernel_cred
ffffffffbc7d4f20 r __ksymtab_prepare_kernel_cred
ffffffffbc7f06b7 r __kstrtab_prepare_kernel_cred

一般情况下,/proc/kallsyms 的内容需要 root 权限才能查看

Mitigation

canary, dep, PIE, RELRO 等保护与用户态原理和作用相同

  • smep: Supervisor Mode Execution Protection,当处理器处于 ring0 模式,执行用户空间的代码会触发页错误。(在 arm 中该保护称为 PXN
  • smap: Superivisor Mode Access Protection,类似于 smep,通常是在访问数据
  • mmap_min_addr

CTF kernel pwn 相关

题目文件

  1. boot.sh: 一个用于启动 kernelshell 的脚本,多用 qemu,保护措施与 qemu 不同的启动参数有关

  2. bzImage: kernel binary

  3. rootfs.cpio: 文件系统映像

    $x babydriver.tar
    boot.sh
    bzImage
    rootfs.cpio
    $file bzImage
    bzImage: Linux kernel x86 boot executable bzImage, version 4.4.72 (atum@ubuntu) #1 SMP Thu Jun 15 19:52:50 PDT 2017, RO-rootFS, swap_dev 0x6, Normal VGA
    $file rootfs.cpio
    rootfs.cpio: gzip compressed data, last modified: Tue Jul  4 08:39:15 2017, max compression, from Unix, original size 2844672
    $file boot.sh
    boot.sh: Bourne-Again shell script, ASCII text executable
    $bat boot.sh 
    ───────┬────────────────────────────────────────────────────────────
           │ File: boot.sh
    ───────┼───────────────────────────────────────────────────────────
             #!/bin/bash
             qemu-system-x86_64 \
             -initrd rootfs.cpio \
             -kernel bzImage \
             -append 'console=ttyS0 root=/dev/ram oops=panic panic=1' \
             -enable-kvm -monitor /dev/null \
             -m 64M \
             --nographic  \
             -smp cores=1,threads=1 \
             -cpu kvm64,+smep
    • -initrd rootfs.cpio,使用rootfs.cpio作为内核启动的文件系统
    • -kernel bzImage,使用 bzImage 作为 kernel 映像
    • -cpu kvm64,+smep,设置 CPU 的安全选项,这里开启了 smep
    • -m 64M,设置虚拟 RAM64M,默认为 128M

    保护机制

    KASLR:内核地址随机化,相当于ASLR(并非默认启用,需要在内核命令行中加入kaslr开启)

    SMEP/SMAP:[SMEP管理模式执行保护,禁止内核访问用户空间的数据],[SMAP管理模式访问保护,类似于NX,即内核态无法执行shellcode]

    Stack Protector:(canary)在编译内核时设置CONFIG_CC_STACKPROTECTOR,开了这个保护再编译驱动会有canary

    KPTIKPTI即内核页表隔离(Kernel page-table isolation),内核空间与用户空间分别使用两组不同的页表集,这对于内核的内存管理产生了根本性的变化

    攻击流程

    1. 解包rootfs,检查保护
    $ mkdir File_system
    $ mv rootfs.cpio ./File_system/rootfs.cpio.gz
    $ cd File_system
    $ gunzip rootfs.cpio.gz
    $ cpio -idmv < rootfs.cpio
    
    $ file babydriver.ko 
    $ checksec --file=babydriver.ko

    2.拖进IDA在内核代码中找到漏洞

    3.利用Shellcode, ROP, 等攻击方式实现代码执行

    4.提权

    5.本地写好 exploit 后,可以通过 base64 编码等方式把编译好的二进制文件保存到远程目录下,进而拿到 flag,同时可以使用 musl, uclibc 等方法减小 exploit 的体积方便传输

    测试exp

    $ cp ./exp ./fs && cd fs
    $ find . | cpio -o --format=newc > ../rootfs.cpio # 重新打包文件系统
    $ ./boot.sh # 启动&测试exp

目标

提权

即获取到 root 权限

改变自身

通过改变自身进程的权限,使其具有 root 权限,内核会通过进程的 task_struct 结构体中的 cred 指针来索引 cred 结构体,cred结构体成员中的 uid-fsgid 都为 0则具有 root 权限,这种提权方法分为定位和修改两个步骤,cred结构体在include/linux/cred.h

直接改cred结构体的内容
定位cred具体位置
  • 直接定位

    cred 结构体的最前面记录了各种 id 信息,对于一个普通的进程而言,uid-fsgid 都是执行进程的用户的身份,因此可以通过扫描内存来定位 cred

    在实际定位的过程中,我们可能会发现很多满足要求的 cred,这主要是因为 cred 结构体可能会被拷贝、释放。可以利用 usage 不为 0 来筛除掉一些 cred,但仍然会发现一些 usage0cred,这是因为 credusage0, 到释放有一定的时间,此外,cred 是使用 rcu 延迟释放

  • 间接定位

    • task_struct

      进程的 task_struct 结构体中会存放指向 cred 的指针,因此我们可以

      1. 定位当前进程 task_struct 结构体的地址
      2. 根据 cred 指针相对于 task_struct 结构体的偏移计算得出 cred 指针存储的地址
      3. 获取 cred 具体的地址
    • comm

      comm 用来标记可执行文件的名字,位于进程的 task_struct 结构体中, commcred 的正下方,所以可以先定位 comm ,然后定位 cred 的地址

          const struct cred __rcu     *ptracer_cred;
          const struct cred __rcu     *real_cred;
          const struct cred __rcu     *cred;
      #ifdef CONFIG_KEYS
          struct key          *cached_requested_key;
      #endif
          char                comm[TASK_COMM_LEN];

      进程名字并不特殊的情况下可能会有多个同样的字符串,,可以使用 prctl 设置进程的comm为一个特殊的字符串,然后再开始定位 comm

  • 修改

    直接将 cred 中的 uid-fsgid 都修改为 0,当然修改的方式有很多种

    • 在我们具有任意地址读写后,可以直接修改 cred

    • 在我们可以 ROP 执行代码后,可以利用 ROP gadget 修改 cred

间接定位

不一定非得知道 cred 的具体位置,只需要能够修改 cred 即可

UAF 使用同样堆块-过时

如果我们在进程初始化时能控制 cred 结构体的位置,并且我们可以在初始化后修改该部分的内容,那么我们就可以很容易地达到提权的目的,且不需要任何信息泄露,例子:

  1. 申请一块与 cred 结构体大小一样的堆块
  2. 释放该堆块
  3. fork 出新进程,恰好使用刚刚释放的堆块
  4. 修改 cred 结构体特定内存,从而提权

此种方法在较新版本内核中已不再可行,我们已无法直接分配到 cred_jar 中的 object,这是因为 cred_jar 在创建时设置了 SLAB_ACCOUNT 标记,在 CONFIG_MEMCG_KMEM=y 时(默认开启)cred_jar 不会再与相同大小的 kmalloc-192 进行合并

void __init cred_init(void)
{
    /* allocate a slab in which we can store credentials */
    cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred), 0,
            SLAB_HWCACHE_ALIGN|SLAB_PANIC|SLAB_ACCOUNT, NULL);
}
修改 task_struct 结构体中的 cred 指针
定位cred结构体指针具体位置
  • 间接定位

    • task_struct

      task_struct 结构体中会存放指向 cred 的指针,可以定位task_struct 结构体的地址,再根据 cred 指针的偏移计算得出 cred 指针地址

    • comm

      cred指针正下方,可以使用 prctl 设置进程的 comm 为一个特殊的字符串,然后再定位 comm

          const struct cred __rcu     *ptracer_cred;
          const struct cred __rcu     *real_cred;
          const struct cred __rcu     *cred;
      #ifdef CONFIG_KEYS
          struct key          *cached_requested_key;
      #endif
          char                comm[TASK_COMM_LEN];
  • 修改

    • 修改 cred 指针为内核镜像中已有的 init_cred 的地址。这种方法适合于我们能够直接修改 cred 指针以及知道 init_cred 地址的情况
    • 伪造一个 cred,然后修改 cred 指针指向该地址即可。比较麻烦,一般不使用
间接定位
  • commit_creds(&init_cred)

    commit_creds() :将一个新的 cred 设为当前进程 task_structreal_credcred 字段,调用该函数并传入一个具有 root 权限的 cred,则能直接完成对当前进程的提权工作

    int commit_creds(struct cred *new)
    {
        struct task_struct *task = current;//内核宏,用以从 percpu 段获取当前进程的 PCB
        const struct cred *old = task->real_cred;
    
        //...
        rcu_assign_pointer(task->real_cred, new);
        rcu_assign_pointer(task->cred, new);

    在内核初始化过程当中会以 root 权限启动 init 进程,其 cred 结构体为静态定义init_cred,可以通过 commit_creds(&init_cred) 来完成提权的工作

    struct cred init_cred = {
        .usage          = ATOMIC_INIT(4),
    #ifdef CONFIG_DEBUG_CREDENTIALS
        .subscribers        = ATOMIC_INIT(2),
        .magic          = CRED_MAGIC,
    #endif
        .uid            = GLOBAL_ROOT_UID,
        .gid            = GLOBAL_ROOT_GID,
        .suid           = GLOBAL_ROOT_UID,
        .sgid           = GLOBAL_ROOT_GID,
        .euid           = GLOBAL_ROOT_UID,
        .egid           = GLOBAL_ROOT_GID,
        .fsuid          = GLOBAL_ROOT_UID,
        .fsgid          = GLOBAL_ROOT_GID,
        .securebits     = SECUREBITS_DEFAULT,
        .cap_inheritable    = CAP_EMPTY_SET,
        .cap_permitted      = CAP_FULL_SET,
        .cap_effective      = CAP_FULL_SET,
        .cap_bset       = CAP_FULL_SET,
        .user           = INIT_USER,
        .user_ns        = &init_user_ns,
        .group_info     = &init_groups,
        .ucounts        = &init_ucounts,
    };
  • commit_creds(prepare_kernel_cred(0))-6.2之后失效

    prepare_kernel_cred() 函数用以拷贝指定进程的 cred 结构体,参数为 NULL 时,该函数会拷贝 init_cred 并返回一个有着 root 权限的 cred

    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之后失效

    struct cred *prepare_kernel_cred(struct task_struct *daemon)
    {
        const struct cred *old;
        struct cred *new;
    
        if (WARN_ON_ONCE(!daemon))
            return NULL;
    
        new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
        if (!new)
            return NULL;

改变别人

通过影响高权限进程的执行,使其完成我们想要的功能

改数据

符号链接

如果一个 root 权限的进程会执行一个符号链接的程序,并且该符号链接或者符号链接指向的程序可以由攻击者控制,攻击者就可以实现提权

  • call_usermodehelper

    一种内核线程执行用户态应用的方式,并且启动的进程具有root权限,通过改变某个变量指定的具体要执行的应用提权

    • 修改modprobe_path

      • 获取modprobe_path地址
        • 直接定位:取值确定,modprobe_path扫描内存寻找字符串
        • 间接定位:相对内核基址偏移固定,先获取内核基址再通过偏移得到地址
      • 修改modprobe_path为指定的程序
      • 触发 call_modprobe
        • 执行满足相应要求的非法可执行文件
        • 使用未知协议触发

      模板如下:

      // 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);
    • 修改 poweroff_cmd

  • poweroff_cmd

    • 获取poweroff_cmd地址,方法同上
    • 修改 poweroff_cmd 为指定的程序
    • 劫持控制流执行 __orderly_poweroff
改代码

修改root权限执行的代码,内核中 vDSO 的代码会被映射到所有的用户态进程中,高特权的进程周期性地调用 vDSO 中的函数时可以考虑把 vDSO 中相应的函数修改为特定的 shellcode,早期vDSO 是可写的,后来引入 post-init read-only 的数据,即将初始化后不再被写的数据标记为只读,来防御这样的利用

  • 定位 vDSO

    • IDAvmlinux里定位vDSO

      点击init_vdso函数 -> vdso_image_64 / vdso_image_x32 -> raw_data

      或直接使用raw_data来寻找vDSO

    • 内存中定位

      直接定位:vDSO是一个ELF文件,有ELF文件头,vDSO特定位置存储导出函数的字符串,根据这两个特征扫描内存

      vDSO相对内核基址偏移固定,先获取内核基址再根据偏移得到地址

  • 修改 vDSO 的特定函数为指定的 shellcode

  • 等待触发执行 shellcode

总结

改变自身
  • cred结构体:扫描内存定位/task_struct结构体存放cred结构体指针/comm偏移定位cred结构体位置,通过任意地址写/rop来改cred中的uid-fsgid0
  • cred结构体指针:task_struct结构体存放cred结构体指针/comm偏移定位cred结构体指针位置,能够直接修改 cred 指针以及知道有root权限的 init_cred 地址时,修改 cred 指针为内核镜像中已有的 init_cred 的地址
  • UAF 使用同样堆块控制cred结构体-过时
  • commit_creds(&init_cred),传入有root权限的init_cred的地址
  • commit_creds(prepare_kernel_cred(0))-6.2之后失效
改变别人
  • 修改modprobe_path为指定程序之后触发 call_modprobe
  • 修改 poweroff_cmd 为指定的程序之后触发__orderly_poweroff
  • vDSOshellcode

泄露敏感信息

需要我们具有读取内核数据的能力,具体想要泄漏什么数据与利用场景紧密相关

DoS

即使得内核崩溃

  • 触发内核中的某个漏洞让内核崩溃
  • 触发内核中的死锁
  • 触发大量的内核内存泄漏,即存在大量的内存被申请但是没有被释放

内核防御机制

隔离

内核态和用户态的隔离

默认

用户态不可直接访问内核态的数据、执行内核态的代码

SMEP

内核态不可执行用户态的代码/用户代码不可执行,位于cr4的第20位,不开的话会导致攻击者控制了内核中的执行流,就可以执行处于用户态的代码,由于用户态的代码是攻击者可控的,所以更容易实施攻击

开启:默认开启,qemu开启在启动时加-append +smep

关闭/etc/default/grub中如下两行添加nosmep,运行update-grub并重启系统,qemu中加-append nosmep

GRUB_CMDLINE_LINUX_DEFAULT="quiet"  
GRUB_CMDLINE_LINUX="initrd=/install/initrd.gz"

状态查看:存在smep字符即开启了smep保护

grep smep /proc/cpuinfo

攻击:内核中存在固定的修改cr4的代码,比如refresh_pce函数、set_tsc_mode函数,或者控制程序流后执行内核中的gadget修改,将CR4寄存器第20位置0后即关闭smep,一般将CR4设置位0x6f0,这样同时也关闭了smap

SMAP

内核态不可访问用户态的数据/用户数据不可访问,位于cr4的第21位,不开smap可能导致攻击者可以通过栈迁移将栈迁移到用户态,然后进行 ROP

开启/关闭/状态查看:同上

攻击:设置rc4同上,劫持程序流之后可以调用 copy_from_usercopy_to_user 来访问用户态的内存。这两个函数会临时清空禁止访问用户态内存的标志

KPTI-Kernel Page Table Isolation

用户态不可看到内核态的页表内核态不可执行用户态的代码,进一步增强了用户态和内核态内存的隔离,在开启 KPTI 机制后,用户态进入内核态或内核态进入用户态时都会进行页表切换

  • 内核态页表:用户空间内存的页表(标记为不可执行,类似smep,未开启smap则仍然可以访问用户态空间内存,只是不能执行shellcode)+内核空间内存的页表
  • 用户态的页表:用户空间内存的页表+必要的内核空间内存的页表(如用于处理系统调用、中断等信息的内存

Linux 4.15 中引入了 KPTI 机制,并且该机制被反向移植到了 Linux 4.14.114.9.75,4.4.110

开启:-append kpti=1

关闭-append nopti

状态查看dmesg | grep 'page table'

攻击KPTI 机制和 SMAP 、SMEP 不太一样,由于与源码紧密结合,似乎没有办法在运行时刻关闭

  • 修改页表

    用户态空间所有数据被标记了NX,可以修改对应页表权限,未开smep修改后可以返回到用户态执行用户态代码

  • SWITCH_TO_USER_CR3_STACK

    控制内核执行返回用户态时执行的切换页表代码判断也可以正常返回到用户态,页表切换主要靠SWITCH_TO_USER_CR3_STACK汇编宏,只要能调用这部分代码即可进行页表切换

    .macro SWITCH_TO_USER_CR3_STACK scratch_reg:req
        pushq   %rax
        SWITCH_TO_USER_CR3_NOSTACK scratch_reg=\scratch_reg scratch_reg2=%rax
        popq    %rax
    .endm
    .macro SWITCH_TO_USER_CR3_NOSTACK scratch_reg:req scratch_reg2:req
        ALTERNATIVE "jmp .Lend_\@", "", X86_FEATURE_PTI
        mov %cr3, \scratch_reg
    
        ALTERNATIVE "jmp .Lwrcr3_\@", "", X86_FEATURE_PCID
    
        /*
         * Test if the ASID needs a flush.
         */
        movq    \scratch_reg, \scratch_reg2
        andq    $(0x7FF), \scratch_reg      /* mask ASID */
        bt  \scratch_reg, THIS_CPU_user_pcid_flush_mask
        jnc .Lnoflush_\@
    
        /* Flush needed, clear the bit */
        btr \scratch_reg, THIS_CPU_user_pcid_flush_mask
        movq    \scratch_reg2, \scratch_reg
        jmp .Lwrcr3_pcid_\@
    
    .Lnoflush_\@:
        movq    \scratch_reg2, \scratch_reg
        SET_NOFLUSH_BIT \scratch_reg
    
    .Lwrcr3_pcid_\@:
        /* Flip the ASID to the user version */
        orq $(PTI_USER_PCID_MASK), \scratch_reg
    
    .Lwrcr3_\@:
        /* Flip the PGD to the user version */
        orq     $(PTI_USER_PGTABLE_MASK), \scratch_reg
        mov \scratch_reg, %cr3
    .Lend_\@:
    .endm

    页表切换后还要返回到用户态,需要复用内核中返回至用户态的代码,主要有两种方式:iretsysret

    • iret

      SYM_INNER_LABEL(swapgs_restore_regs_and_return_to_usermode, SYM_L_GLOBAL)
      #ifdef CONFIG_DEBUG_ENTRY
          /* Assert that pt_regs indicates user mode. */
          testb   $3, CS(%rsp)
          jnz 1f
          ud2
      1:
      #endif
          POP_REGS pop_rdi=0
      
          /*
           * The stack is now user RDI, orig_ax, RIP, CS, EFLAGS, RSP, SS.
           * Save old stack pointer and switch to trampoline stack.
           */
          movq    %rsp, %rdi
          movq    PER_CPU_VAR(cpu_tss_rw + TSS_sp0), %rsp
          UNWIND_HINT_EMPTY
      
          /* Copy the IRET frame to the trampoline stack. */
          pushq   6*8(%rdi)   /* SS */
          pushq   5*8(%rdi)   /* RSP */
          pushq   4*8(%rdi)   /* EFLAGS */
          pushq   3*8(%rdi)   /* CS */
          pushq   2*8(%rdi)   /* RIP */
      
          /* Push user RDI on the trampoline stack. */
          pushq   (%rdi)
      
          /*
           * We are on the trampoline stack.  All regs except RDI are live.
           * We can do future final exit work right here.
           */
          STACKLEAK_ERASE_NOCLOBBER
      
          SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
      
          /* Restore RDI. */
          popq    %rdi
          SWAPGS
          INTERRUPT_RETURN

      伪造如下栈跳转到movq %rsp, %rdi就可以切换页表并且返回至用户态

      fake rax
      fake rdi
      RIP
      CS
      EFLAGS
      RSP
      SS
    • sysret

      rcxr11取值

      rcx, save the rip of the code to be executed when returning to userspace
      r11, save eflags

      构造栈

      fake rdi
      rsp, the stack of the userspace

      跳转entry_SYSCALL_64的如下部分即可返回到用户态

      SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
      
          popq    %rdi
          popq    %rsp
          swapgs
          sysretq
  • signal handler

    在用户态注册signal handler来执行用户态的代码,无需切换页表

内核自身内部不同对象间的隔离

堆块隔离
GFP_KERNEL & GFP_KERNEL_ACCOUNT 的隔离

GFP_KERNELGFP_KERNEL_ACCOUNT 是内核中最为常见与通用的分配 flag,常规情况下他们的分配都来自同一个 kmem_cache ——即通用的 kmalloc-xx

5.9 版本之前GFP_KERNELGFP_KERNEL_ACCOUNT 存在隔离机制,自内核版本 5.14 起又重新引入:

  • 对于开启了 CONFIG_MEMCG_KMEM 编译选项的 kernel 而言(默认开启),其会为使用 GFP_KERNEL_ACCOUNT 进行分配的通用对象创建一组独立的 kmem_cache ——名为 kmalloc-cg-\* ,从而导致使用这两种 flag 的 object 之间的隔离。
SLAB_ACCOUNT

如果在使用 kmem_cache_create 创建一个 cache 时,传递了 SLAB_ACCOUNT 标记,那么这个 cache 就会单独存在,不会与其它相同大小的 cache 合并。在早期,许多结构体(如 cred 结构体)对应的堆块并不单独存在,会和相同大小的堆块使用相同的 cache。在 Linux 4.5 版本引入了这个 flag 后,许多结构体就单独使用了自己的 cache

访问控制

使得内核中相应的对象具有一定的访问控制要求,比如不可写,或者不可读

信息泄露

dmesg_restrict

内核日志有一些地址信息或者敏感信息,因此对内核日志的访问进行限制

该选项用于控制是否可以使用 dmesg查看内核日志。当 dmesg_restrict0时,没有任何限制;当该选项为 1 时,只有具有 CAP_SYSLOG 权限的用户才可以通过 dmesg 命令来查看内核日志

kptr_restrict

输出内核地址时施加的限制,主要限制通过 /proc 获取的内核地址

选项配置:

  • 0:默认情况下,没有任何限制
  • 1:使用 %pK 输出的内核指针地址将被替换为 0,除非用户具有 CAP_ SYSLOG 特权,并且 group id 和真正的 id 相等
  • 2:使用 %pK 输出的内核指针都将被替换为 0与权限无关

当开启该保护后,攻击者就不能通过 /proc/kallsyms 来获取内核中某些敏感的地址了,如 commit_credsprepare_kernel_cred

Misc

__ro_after_init

使用 __ro_after_init 标记在 __init 阶段被初始化且初始化后就不会改变的内存,在 init 阶段结束后,不能够被再次修改

我们可以使用 set_memory_rw(unsigned long addr, int numpages) 来修改对应页的权限

mmap_min_addr

mmap_min_addr 是用来对抗 NULL Pointer Dereference 的,指定用户进程通过 mmap 可以使用的最低的虚拟内存地址

异常检测

Kernel Stack Canary

开启:开启编译选项CONFIG_CC_STACKPROTECTOR

关闭:重新编译内核,并关闭编译选项

状态检查checksec

特点x86 架构下同一个 task 共享 Canary

攻击:只要泄露一次系统调用中的canary,同一task的其他系统调用中的canary就都被泄露了

随机化

KASLR

内核的代码段基地址等地址会整体偏移

开启-append kaslr

关闭-append nokaslr

攻击:泄露某个段的地址得到该段的基址

FGKASLR

KASLR基础上,在加载时刻以函数粒度重新排布内核代码

特点.text参与随机化,.data不参与随机化

开启:开启CONFIG_FG_KASLR=y选项

关闭nokaslr会同时关闭FGKASLR,也可以单独使用nofgkaslr

缺点

  • 函数粒度随机化,如果函数内的某个地址知道了,函数内部的相对地址也就知道了
  • .text节区不参与函数随机化,一旦知道其中的某个地址,就可以获取该节区所有的地址。系统调用的入口代码都在该节区内,且该节区具有以下 gadget
    • swapgs_restore_regs_and_return_to_usermode,用于绕过 KPTI 防护
    • memcpy 内存拷贝
    • sync_regs,可以把 RAX 放到 RDI
  • __ksymtab相对于内核镜像的偏移是固定的,如果我们可以泄露数据,那就可以泄露出其它的符号地址,如 prepare_kernel_credcommit_creds
    • 基于内核镜像地址获取 __ksymtab 地址
    • 基于 __ksymtab 获取对应符号记录项的地址
    • 根据符号记录项中具体的内容来获取对应符号的地址
  • data 节区相对于内核镜像的偏移是固定的。因此在获取了内核镜像的基地址后,就可以计算出数据区数据的地址,这个节区有一些重要数据比如modprobe_path

__ksymtab 格式

__ksymtab 中每个记录项的名字的格式为 __ksymtab_func_name,以 prepare_kernel_cred 为例,对应的记录项的名字为__ksymtab_prepare_kernel_cred,因此,我们可以直接通过该名字在 IDA 里找到对应的位置,如下

__ksymtab:FFFFFFFF81F8D4FC __ksymtab_prepare_kernel_cred dd 0FF5392F4h
__ksymtab:FFFFFFFF81F8D500                 dd 134B2h
__ksymtab:FFFFFFFF81F8D504                 dd 1783Eh

__ksymtab 每一项的结构为

struct kernel_symbol {
    int value_offset;
    int name_offset;
    int namespace_offset;
};

第一个表项记录了重定位表项相对于当前地址的偏移。那么,prepare_kernel_cred 的地址应该为 0xFFFFFFFF81F8D4FC-(2**32-0xFF5392F4)=0xffffffff814c67f0

__ksymtab_prepare_kernel_cred addr - (2 ** 32 - __ksymtab_prepare_kernel_cred)

.text.prepare_kernel_cred:FFFFFFFF814C67F0                 public prepare_kernel_cred
.text.prepare_kernel_cred:FFFFFFFF814C67F0 prepare_kernel_cred proc near           ; CODE XREF: sub_FFFFFFFF814A5ED5+52↑p

参考文章

https://ctf-wiki.org/pwn/linux/kernel-mode

https://www.z1r0.top/2021/11/21/kernel-pwn%EF%BC%88%E4%B8%89%EF%BC%89%E9%98%B2%E5%BE%A1%E6%9C%BA%E5%88%B6/#%E7%BC%BA%E7%82%B9

https://www.z1r0.top/2021/10/29/kernel-pwn%EF%BC%88%E4%BA%8C%EF%BC%89%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/#/


  目录