kernel-pwn基础知识学习

原文:https://www.anquanke.com/post/id/201043

照着原文手打了一遍,印象加深get

02. kernel简介

什么是kernel

kernel也是一个程序,用来管理软件发出的数据的IO要求,将这些要求转义为指令,交给CPU和计算机的其他组件处理,kernel是现代操作系统最基本的部分。

kernel最主要的功能有两点:

  1. 控制并与硬件进行交互
  2. 提供application能运行的环境

包括IO,权限控制,系统调用,进程管理,内存管理等多项功能都可以归结到上边两点中。

需要注意的是,kernel的crash通常会引起重启。

Ring Model

intel CPU将CPU的特权级别分为4个级别:Ring 0,Ring 1,Ring 2,Ring 3.

Ring 0只给OS使用,Ring 3所有程序都可以使用,内层Ring 可以随便使用外层Ring的资源。

使用Ring Model是为了提升系统安全性,例如某个间谍软件作为一个在Ring 3运行的用户程序,在不通知用户的时候打开摄像头会被组成,因为访问硬件需要使用being驱动程序保留的Ring 1的方法。

大多数的现代操作系统只使用了Ring 0和Ring 3.

model change

user space to kernel space

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

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

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

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

    /* 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等)

关于syscall

系统调用,指的是用户控件的程序向操作系统内核请求需要更高权限的服务,比如IO操作或者进程间通信。系统调用提供用户程序与操作系统之间的接口,部分库函数(如scanf,puts等IO相关的函数实际上是对系统调用的封装(read和write))

/usr/include/x86_64-linux-gnu/asm/unistd_64.h/usr/include/x86_64-linux-gnu/asm/unistd_32.h 分别可以查看 64 位和 32 位的系统调用号。

ioctl

在man手册中,关于这个函数的说明如下:

NAME
ioctl - control device
SYNOPSIS
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);

DESCRIPTION
The ioctl() system call manipulates the underlying device parameters of special
files. In particular, many operating characteristics of character special
files (e.g., terminals) may be controlled with ioctl() requests. The argument
fd must be an open file descriptor.

The second argument is a device-dependent request code. The third argument is
an untyped pointer to memory. It's traditionally char *argp (from the days
before void * was valid C), and will be so named for this discussion.

An ioctl() request has encoded in it whether the argument is an in parameter or
out parameter, and the size of the argument argp in bytes. Macros and defines
used in specifying an ioctl() request are located in the file <sys/ioctl.h>.

可以看出ioctl也是一个系统调用,用于与设备通信。

第一个参数为打开设备返回的文件描述符,第二个参数为用户程序对设备的控制命令,再后边的参数则是一些补充参数,与设备有关。

使用 ioctl 进行通信的原因:

操作系统提供了内核访问标准外部设备的系统调用,因为大多数硬件设备只能够在内核空间内直接寻址, 但是当访问非标准硬件设备这些系统调用显得不合适, 有时候用户模式可能需要直接访问设备。

比如,一个系统管理员可能要修改网卡的配置。现代操作系统提供了各种各样设备的支持,有一些设备可能没有被内核设计者考虑到,如此一来提供一个这样的系统调用来使用设备就变得不可能了。

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

(妙啊)

内核态函数调用

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

  1. printf变更为printk,值得注意的是printk不一定会把内容显示到终端上,但是一定在内核缓冲区里,可以通过dmesg查看效果
  2. memcpy变更为copy_from_user/copy_to_user
    1. Copy_from_user实现了将用户空间的数据传送到内核空间
    2. Copy_to_user实现了将内核空间的数据传送到用户空间
  3. malloc变更为kmalloc,内核态的内存分配函数,和malloc相似,但使用的是slab/slub分配器
  4. free变更为kfree,同kmalloc

同时,kernel负责管理进程,因此kernel也记录了进程的权限。kernel中有两个可以方便的改变权限的函数:

  1. int commit_creds(struct cred *new)
  2. 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)

image-20210811161724893

一般情况下,/proc/kallsyms的内容需要root权限才能查看,若以非root权限查看将显示为0地址(emm)

struct cred - 进程权限结构体

内核使用cred结构体记录进程的权限,每个进程中都有一个cred结构,这个结构保存了该进程的权限等信息(uid,gid等),如果能修改某个进程的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;

内核保护机制

  1. smep: Supervisor Mode Execute Protection,当处理器处于ring 0模式,执行用户空间的代码会触发页错误(在arm中该保护称为PXN)。
  2. smap: Supervisor Mode Access Protection,类似于smep,当处理器处于ring 0模式,访问用户空间的数据会发生页错误。
  3. MMAP_MIN_ADDR: 控制着mmap能够映射的最低内存地址,防止用户非法分配并访问低地址数据。
  4. KASLR: Kernel Address Space Layout Randomization,内核地址空间布局随机化,开启后,允许kernel image加载到VMALLOC区域的任何位置。

Canary,DEP,PIE,RELRO等保护与用户态原理和作用相同。

03. LKMs介绍

什么是LKMs

LKMs (Loadable Kernel Modules)称为可加载核心模块(内核模块), 其可以看作是运行在内核空间的可执行程序,包括:

  • 驱动程序 Device drivers
    • 设备驱动
    • 文件系统驱动
    • ……
  • 内核扩展模块 modules

LKMs的文件格式和用户态的可执行程序相同,Linux下为ELF,Windows下为exe/dll,mac下为MACH-O,因此可以使用IDA等工具来分析内核模块。

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

模块通常用来实现一种文件系统、一个驱动程序或者其他内核上层的功能。

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

通常情况下,Kernel漏洞的发生也常见于加载的LKMs出现问题。

内核中的模块相关命令

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

file_operations 结构体

用户进程在对设备文件进行诸如read/write操作的时候,系统调用通过设备文件的主设备号找到相应的设备驱动程序,然后读取这个数据结构相应的函数指针,接着把控制权交给该函数,这是linux的设备驱动程序工作的基本原理。

内核模块程序的结构中包括一些call back回调表,对应的函数存储在一个file_operations(fop)结构体中,这也是相当重要的结构体,结构体中实现了的回调函数就会静态初始化函数地址,而未实现的函数,值为NULL。

例如:

Events User functions Kernel functions
Load insmod module_init()
Open fopen file_operations: open
Close fread file_operations: read
Write fwrite file_operations: write
Close fclose file_operations: release
Remove rmmod module_exit()
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("Dual BSD/GPL");
static int hello_init(void)
{
printk("<1> Hello world!n");
return 0;
}
static void hello_exit(void)
{
printk("<1> Bye, cruel worldn");
}
module_init(hello_init);
module_exit(hello_exit);
struct file_operations module_fops =
{
read: module_read,
write: module_write,
open: module_open,
release: module_release
};

其中,module_init/module_exit是在载入/卸载这个驱动时自动运行;而fop结构体实现了如上四个callback,冒号右侧的函数名是由开发者自己起的,在驱动程序载入内核后,其他用户程序就可以借助文件方式像进行系统调用一样调用这些函数实现所需功能。

04. 环境配置

对于kernel pwn来说,题目通常会给定以下文件:

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

bzImage:kernel binary

rootfs.cpio:文件系统映像

qemu的启动参数:

  • -initrd rootfs.cpio,使用rootfs.cpio作为内核启动的文件系统
  • -kernel bzImage,使用bzImage作为kernel映像
  • -cpu kvm64,+smep,设置CPU的安全选项,这里开启了smep
  • -m 64M,设置虚拟RAM为64M,默认为128M

05. Kernel Stack Overflow

内核栈帧(Kernel Stack Layout)

x86上的Linux内核堆栈的大小为4096或8192字节,取决于发行版。

内核堆栈与thread_info结构共享4k/8k的总大小,该结构包含有关当前线程的一些元数据,如include/linux/sched.h中所示

union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
}

thread_info结构体在x86下有如下定义(arch/x86/include/asm/thread_info.h)

struct thread_info {
struct task_struct *task;
struct exec_domain *exec_domain;
__u32 flags;
__u32 status;
__u32 cpu;
int preempt_count;
mm_segment_t addr_limit;
struct restart_block restart_block;
void __user *sysenter_return;
#ifdef CONFIG_X86_32
unsigned long previous_esp;
__u8 supervisor_stack[0];
#endif
int uaccess_err;
};

内核堆栈在内存中呈现下所示的结构

当内核函数需要超过4k/8k的堆栈空间或者使用了长调用链以至于超出可用堆栈空间的时候,会导致堆栈溢出的发生,并且如果thread_info结构或超出更低地址处的关键内存损坏,则会导致内核崩溃。但是,如果内存对齐并且存在实际可以控制写入堆栈及其以外的数据的情况,则可能存在可利用的条件。

kernel栈溢出攻击

我们看一看栈溢出和thread_info结构的破坏是如何导致提权的发生的。

static int blah(int __user *vals, int __user count)
{
int i;
int big_array[count];
for(i = 0; i < count; ++count) {
big_array[i] = vals[i];
}
}

如上代码使用了可变长度的数组,其大小基于攻击者的count。c99允许使用可变长的数组,并且GCC支持可变长的数组。GCC将在运行的时候简单地计算必要的大小,并适当减少堆栈指针,以在堆栈上为数组分配空间。

我们如果提供一个极大的count,则堆栈可能向下扩展到thread_info的边界之外,从而允许攻击者随后将任意值写入该结构。

我们可以利用的target为thread_info结构中的一个成员:restart_block。该成员是每个线程的结构,用于跟踪信息和参数以供重新启动系统调用。如果在sigaction(2)中指定了SA_RESTART,则被信号中断的系统调用可以中止并返回EINTR,也可以自动自行重启。在include/linux/thread_info.h中,restart_block的定义如下:

struct restart_block {
long (*fn)(struct restart_block *);
union {
struct {
...
};
/* For futex_wait and futex_wait_requeue_pi */
struct {
...
} futex;
/* For nanosleep */
struct {
...
} nanosleep;
/* For poll */
struct {
...
} poll;
};
};

这里有一个fn的函数指针,在kernel/signal.c中有如下代码:

SYSCALL_DEFINE0(restart_syscall)
{
struct restart_block *restart = &current_thread_info()->restart_block;
return restart->fn(restart);
}

而restart_syscall在arch/x86/kernel/syscall_table_32.S中被定义:

.long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */

实际上它的系统调用号为0,我们可以通过以下方式从用户态中调用其功能:

syscall(SYS_restart_syscall);

这将使内核调用在restart_block结构中的函数指针。

如果我们可以破坏thread_info中的restart_block成员中的函数指针,则可以将其指向我们控制下的用户空间中的函数,通过调用sys_restart_syscall触发其执行,并提权。

Linux提权思路

之前说过,执行commit_creds(prepare_kernel_cred(0)),可以使进程的权限提升为root,然后我们返回到用户模式,执行iret指令。

关于iret指令:

  • 当使用iret指令返回到相同保护级别的任务时,iret会从堆栈弹出代码段选择子及指令指针分别到CS与IP寄存器,并弹出标志寄存器内容到EFLAGS寄存器。

  • 当使用iret指令返回到一个不同保护级别时,iret不仅会从堆栈弹出以上内容,还会弹出堆栈段选择子及堆栈指针分别到SS与SP寄存器。

  • 栈上保存了trap frame,返回到用户模式的时候,恢复信息从以下的结构读取:

    struct trap_frame 
    {
    void* eip; // instruction pointer +0
    uint32_t cs; // code segment +4
    uint32_t eflags; // CPU flags +8
    void* esp; // stack pointer +12
    uint32_t ss; // stack segment +16
    } __attribute__((packed));
文章作者: Alex
文章链接: http://example.com/2021/08/10/kernal-pwn学习之路/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Alex's blog~