最近发现内核pwn题怎么这么多了。。。。感觉发展太快了,学习总结一波吧。
1 内核简介
1.1 内核的组成
通常一个内核由负责响应中断的中断服务程序
,负责管理多个进程从而分享处理器时间的的调度程序
,负责管理进程地址空间的内存管理程序
和网络、进程间通信等系统服务程序
共同组成。
1.2 内核与应用程序的交互
在系统中运行的应用程序通过系统调用
来于内核通信。应用程序调用库函数(比如C库函数)再由库函数通过系统调用界面,让内核代其完成各种不同的任务。
当硬件设备想和系统通信的时候,它首先要发出一个异步的中断信号
去打断处理器的执行,继而打断内核函数的执行。中断通常对应着一个中断号,内核通过这个中断号
查找对应的中断服务程序
,并调用程序相应和处理中断。许多操作系统的中断服务程序
,包括linux的,都不在进程上下文执行。它们在一个与所有进程都无关的、专门的中断上下文中运行。
实际上我们可以将每个处理器在任何指定时间点上的活动必然概括为下列三者之一
- 运行于用户空间,执行用户进程
- 运行于内核空间,处于进程上下文,代表某个特定的进程执行
- 运行于内核空间,处于中断上下文,与任何进程无关,处理某个特定的中断。
1.3 单内核与微内核
单内核
通常以单个静态二进制文件的形式存放于磁盘中。所有内核服务都在这样一个大内核地址空间上运行,内核可以直接调用函数,具有简单和性能高的特点。
微内核
微内核的功能被划分为多个独立的过程,每个过程叫做一个服务器
Linux是一个单内核,但是它汲取了微内核的精华:其引以为豪的是模块化设计,抢占式内核,支持内核线程以及动态装载内核模块的能力。
2 模块简介
内核模块是Linux内核向外部提供的一个插口,其全称为动态可加载内核模块(Loadable Kernel Module,LKM),我们简称为模块。Linux内核之所以提供模块机制,是因为它本身是一个单内核(monolithic kernel)。单内核的最大优点是效率高,因为所有的内容都集成在一起,但其缺点是可扩展性和可维护性相对较差,模块机制就是为了弥补这一缺陷。
2.1 什么是模块
模块是具有独立功能的程序,它可以被单独编译,但不能独立运行。它在运行时被链接到内核作为内核的一部分在内核空间运行,这与运行在用户空间的进程是不同的。模块通常由一组函数和数据结构组成,用来实现一种文件系统、一个驱动程序或其他内核上层的功能
2.2 编写一个简单的模块
模块和内核都在内核空间运行,模块编程在一定意义上说就是内核编程。因为内核版本的每次变化,其中的某些函数名也会相应地发生变化,因此模块编程与内核版本密切相关。以下例子针对2.6内核
1 | hellomod.c |
说明
- 第4行:所有模块都要使用头文件module.h,此文件必须包含进来。
- 第5行:头文件kernel.h包含了常用的内核函数。第6行:头文件init.h包含了宏_init和_exit,它们允许释放内核占用的内存。建议浏览一下该文件中的代码和注释。
- 第9-12行:这是模块的初始化函数,它必需包含诸如要编译的代码、初始化数据结构等内容。第11行使用了printk()函数,该函数是由内核定义的,功能与C库中的printf()类似,它把要打印的信息输出到终端或系统日志。字符串中的<1>是输出的级别,表示立即在终端输出。
- 第15-18行:这是模块的退出和清理函数。此处可以做所有终止该驱动程序时相关的清理工作。
- 第20行:这是驱动程序初始化的入口点。对于内置模块,内核在引导时调用该入口点;对于可加载模块则在该模块插入内核时才调用。第21行:对于可加载模块,内核在此处调用module_cleanup()函数,而对于内置的模块,它什么都不做。第22行:提示可能没有GNU公共许可证。有几个宏是在2.4版的内核中才开发的(详情参见modules.h)。函数module_init()和cleanup_exit()是模块编程中最基本也是必须的两个函数。module_init()向内核注册模块所提供的新功能,而cleanup_exit()注销由模块提供的所有功能。
2.3 应用程序与内核模块的比较
C语言应用程序 | 内核模块程序 | |
---|---|---|
使用函数 | Libc库 | 内核函数 |
运行空间 | 用户空间 | 内核空间 |
运行权限 | 普通用户 | 超级用户 |
入口函数 | main() | module_init() |
出口函数 | exit() | module_exit() |
编译 | Gcc –c | Makefile |
链接 | Gcc | insmod |
运行 | 直接运行 | insmod |
调试 | Gdb | kdbug, kdb,kgdb等 |
从表一我们可以看出,内核模块程序不能调用libc库中的函数,它运行在内核空间,且只有超级用户可以对其运行。另外,模块程序必须通过module_init()和module-exit()函数来告诉内核“我来了”和“我走了”。
2.4 内核符号表
如 前所述,Linux内核是一个整体结构,像一个圆球,而模块是插入到内核中的插件。尽管内核不是一个可安装模块,但为了方便起见,Linux把内核也看作 一个“母”模块。那么模块与模块之间如何进行交互呢,一种常用的方法就是共享变量和函数。但并不是模块中的每个变量和函数都能被共享,内核只把各个模块中 主要的变量和函数放在一个特定的区段,这些变量和函数就统称为符号。到低哪些符号可以被共享? Linux内核有自己的规定。对于内核这个特殊的母模块,在kernel/ksyms.c中定义了从中可以“移出”的符号,例如进程管理子系统可以“移出”的符号定义如下
1 | /* 进程管理 */ |
其中宏定义EXPORT_SYMBOL()本身的含义是“移出符号”。为什么说是“移出”呢?因为这些符号本来是内核内部的符号,通过这个宏放在一个公开的地方,使得装入到内核中的其他模块可以引用它们。
实际上,仅仅知道这些符号的名字是不够的,还得知道它们在内核地址空间中的地址才有意义。因此,内核中定义了如下结构来描述模块的符号:
1 | struct module_symbol |
我们可以从/proc/ksyms文件中读取所有内核模块“移出”的符号,这所有符号就形成内核符号表,其格式如下:
内存地址 符号名 [所属模块]
在模块编程中,可以根据符号名从这个文件中检索出其对应的地址,然后直接访问该地址从而获得内核数据。第三列“所属模块”指符号所在的模块名,对于从内核这一母模块移出的符号,这一列为空。
模块加载后,2.4内核下可通过 /proc/ksyms、 2.6 内核下可通过/proc/kallsyms查看模块输出的内核符号
2.5 模块依赖
如前所述,内核符号表记录了所有模块可以访问的符号及相应的地址。当一个新的模块被装入内核后,它所申明的某些符号就会被登记到这个表中,而这些符号可能被其他模块所引用,这就引出了模块依赖这个问题。
一个模块A引用另一个模块B所移出的符号,我们就说模块B被模块A引用,或者说模块A依赖模块B。如果要链接模块A,必须先链接模块B。这种模块间相互依赖的关系就叫模块依赖。
2.6 模块引用计数器
为 了确保模块安全地卸载,每个模块都有一个引用计数器。当执行模块所涉及的操作时就递增计数器,在操作结束时就递减这个计数器;另外,当模块B被模块A引用 时,模块B的引用计数就递增,引用结束,计数器递减。什么时候可以卸载这个模块?当然只有这个计数器值为0的时候,例如,当一个文件系统还被安装在系统上 时就不能将其卸载,当这个文件系统不再被使用时,引用计数器就为0,于是可以卸载。
2.7 模块编译
有了Makefile,执行make命令,会自动形成相关的后缀为.o和.ko文件。
到此,模块编译好了,该把它插入到内核了:
如:$insmod hellomod.ko
当然,要以系统员的身份才能把模块插入。
成功插入后,可以通过dmesg命令查看,屏幕最后几行的输出就是你程序中输出的内容:Hello,World! from the kernel space…
当模块不再需要时,可以通过rmmod命令移去,例如
$rmmod hellomod
3 系统调用
系统调用,顾名思义,说的是操作系统提供给用户程序调用的一组“特殊”接口。用户程序可以通过这组“特殊”接口来获得操作系统内核提供的服务,比如用户可以通过文件系统相关的调用请求系统打开文件、关闭文件或读写文件,可以通过时钟相关的系统调用获得系统时间或设置定时器等。
3.1 Linux 的系统调用
下一个需要解释一下的问题是内核函数和系统调用的关系。大家不要把内核函数想像的过于复杂,其实它们和普通函数很像,只不过在内核实现,因此要满足一些内核编程的要求[3]。系统调用是一层用户进入内核的接口,它本身并非内核函数,进入内核后,不同的系统调用会找到对应到各自的内核函数——换个专业说法就叫:系统调用服务例程。实际上针对请求提供服务的是内核函数而非调用接口。
比如系统调用 getpid实际上就是调用内核函数sys_getpid。
1 | asmlinkage long sys_getpid(void) |
总而言之,从用户角度向内核看,依次是系统命令、编程接口、系统调用和内核函数。在讲述了系统调用实现后,我们会回过头来看看整个执行路径。
4 状态切换
4.1 user space to kernel space
当发生 系统调用
,产生异常
,外设产生中断
等事件时,会发生用户态到内核态的切换,具体的过程为:
- 通过
swapgs
切换 GS 段寄存器,将 GS 寄存器值和一个特定位置的值进行交换,目的是保存 GS 值,同时将该位置的值作为内核执行时的 GS 值使用。 - 将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里,将 CPU 独占区域里记录的内核栈顶放入 rsp/esp。
- 通过 push 保存各寄存器值,具体的 代码 如下:
1 | ENTRY(entry_SYSCALL_64) |
通过汇编指令判断是否为 x32_abi。
通过系统调用号,跳到全局变量
sys_call_table
相应位置继续执行系统调用。
4.2 kernel space to user space
退出时,流程如下:
- 通过
swapgs
恢复 GS 值 - 通过
sysretq
或者iretq
恢复到用户控件继续执行。如果使用iretq
还需要给出用户空间的一些信息(CS, eflags/rflags, esp/rsp 等)
4.3 struct cred
之前提到 kernel 记录了进程的权限,更具体的,是用 cred 结构体记录的,每个进程中都有一个 cred 结构,这个结构保存了该进程的权限等信息(uid,gid 等),如果能修改某个进程的 cred,那么也就修改了这个进程的权限。
源码 如下:
1 | struct cred { |
5 内核态函数
相比用户态库函数,内核态的函数有了一些变化
- 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 也记录了进程的权限
。kernel 中有两个可以方便的改变权限的函数:
- int commit_creds(struct cred *new)
- struct cred* prepare_kernel_cred(struct task_struct* daemon)
从函数名也可以看出,执行 commit_creds(prepare_kernel_cred(0))
即可获得 root 权限(root 的 uid,gid 均为 0)
执行 commit_creds(prepare_kernel_cred(0))
也是最常用的提权手段,两个函数的地址都可以在 /proc/kallsyms
中查看(较老的内核版本中是 /proc/ksyms
。
1 | post sudo grep commit_creds /proc/kallsyms |
6 Ctf kernel Pwn 相关
一般会给以下三个文件
- boot.sh: 一个用于启动 kernel 的 shell 的脚本,多用 qemu,保护措施与 qemu 不同的启动参数有关
- bzImage: kernel binary
- rootfs.cpio: 文件系统映像
比如:
1 | CISCN2017_babydriver [master●] ls |
解释一下 qemu 启动的参数:
- -initrd rootfs.cpio,使用 rootfs.cpio 作为内核启动的文件系统
- -kernel bzImage,使用 bzImage 作为 kernel 映像
- -cpu kvm64,+smep,设置 CPU 的安全选项,这里开启了 smep
- -m 64M,设置虚拟 RAM 为 64M,默认为 128M
其他的选项可以通过 –help 查看。
- 本地写好 exploit 后,可以通过 base64 编码等方式把编译好的二进制文件保存到远程目录下,进而拿到 flag
5 常用指令
解包
1 | cpio -idmv < rootfs.cpio |
打包
1 | find . | cpio -o --format=newc > ../core.cpio |
编译
1 | gcc exp.c -static -masm=intel -g -o exp |
查看text段地址
1 | cat /sys/module/core/sections/.text |