linux 内核
kernel 最主要的功能有两点:
- 控制并与硬件进行交互
- 提供
application
能运行的环境
Linux
内核包含了哪些内容:
- 系统调用接口:
SCI
层提供了某些机制执行从用户空间到内核的函数调用。这个接口依赖于体系结构。 - 进程管理:进程的执行。在内核中,这些进程称为线程,代表了单独的处理器虚拟化(线程代码、数据、堆栈、CPU寄存器)。用户空间使用进程这个术语,但是
Linux
实现并没有区分这两个的概念(进程和线程),内核通过SCI
提供了一个应用程序编程接口API
,来创建一个新进程,停止进程,并在他们之间进行通信和同步。进程管理还包括处理获得进程之间共享CPU
的需求。内核实现了一种新型的调度算法,不管有多少个线程在竞争CPU
,这种算法都可以在固定时间内进行操作。调度程序也可以支持处理器(称为对称多处理器或SMP
); - 内存管理:如果由硬件管理虚拟内存,内存是按照所谓的内存页方式进行管理的(4KB),
Linux
包括了管理可用内存的方式,以及物理和虚拟映射所使用的硬件机制。但是内存管理要管理的可不止4KB
缓冲区。Linux
提供了对4KB
缓冲区的抽象,例如slab
分配器。这种内存管理模式使用的是4KB
缓冲区为基数,然后从中分配结构,并跟踪内存页使用情况,比如哪些内存是满的,哪些页面没有完全使用,哪些页面为空。由于这个原因,页面可以移除内存并放入磁盘中。这个过程叫交换,因为页面会被从内存交换到硬盘上。Linux
系统中,被用于交换的分区叫swap
分区,在windows
下叫做虚拟内存。 - 文件系统:虚拟文件系统(VFS)是
Linux
内核中非常有用的一个方面,因为它为文件系统提供了一个通用的接口抽象。VFS
在SCI
和内核所支持的文件系统做了一个交换层。在VFS
上面,是对oepn
,close
之类的函数的一个通用API
抽象。在VFS
下面是文件系统抽象,它定义了上层函数的实现方式。 - 网络管理:网络堆栈在设计上遵循模拟协议本身的分层体系结构
- 设备驱动:
Linux
内核中有大量代码都在设备驱动程序中,它们能够运转特定的硬件设备。Linux
源码提供了一个驱动程序子目录,这个目录又进一步划分为各种支持设备,例如Bluetooth
,I2C
,serial
等。
不同于Windows NT内核和Mach(Mac OS X 的组成部分)的微内核结构,linux内核采用的是单内核结构,效率高,但是体积大。
Ring Model
intel CPU 将 CPU 的特权级别分为 4 个级别:Ring 0
, Ring 1
, Ring 2
, Ring 3
。
大多数的现代操作系统只使用了 Ring 0
和 Ring 3
。
- 内核空间运行在Ring 0特权等级,拥有自己的空间,位于内存的高地址。
- 用户空间则是我们平时应用程序运行的空间,运行在Ring 3特权等级,使用较低地址。
Model Change
中断
中断即硬件/软件向 CPU
发送的特殊信号,CPU
接收到中断后会停下当前工作转而执行中断处理程序,完成后恢复原工作流程
中断向量表(interrupt vector table)类似一个虚表,该表通常位于物理地址 0~1k处,其中存放着不同中断号对应的中断处理程序的地址
自保护模式起引入中断描述符表(Interrupt Descriptor Table)用以存放 「门描述符」(gate descriptor),中断描述符表地址存放在 IDTR
寄存器中,CPU 通过中断描述符表访问对应门
「门」(gate)可以理解为中断的前置检查物件,当中断发生时会先通过这些「门」,主要有如下三种门:
- 中断门(
Interrupt gate
):用以进行硬中断处理,其类型码为110
;中断门的DPL
(Descriptor Priviledge Level)为 0,故只能在内核态下访问,即中断处理程序应当由内核激活;进入中断门会清除IF
标志位以关闭中断,防止中断嵌套的发生 - 陷阱门(
Trap gate
):类型码为111
,类似于中断门,主要用以处理CPU
异常,但不会清除IF
标志位 - 系统门(
System gate
):Linux
特有门,类型码为 3、4、5、128;其 DPL 为 3,用以供用户进程访问,主要用以进行系统调用(int 0x80)
用户态->内核态
当发生 系统调用
,产生异常
,外设产生中断
等事件时,会发生用户态到内核态的切换,具体的过程为:
- 通过
swapgs
切换GS
段寄存器,将GS
寄存器值和一个特定位置的值进行交换,目的是为了保存GS
值,同时将该位置的值作为内核执行时的GS
值使用GS
寄存器的目的是访问CPU
特定的内存。
- 将当前栈顶(用户空间栈顶)记录在
CPU
独占变量区域里,将CPU
独占区域里记录的内核栈顶放入RSP/ESP
. - 通过
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 */
- 通过汇编指令判断是否为
x32_abi
。(linux 32位内核) - 通过系统调用号,跳到全局变量
sys_call_table
相应位置继续执行系统调用。
内核态->用户态
具体流程如下:
- 通过
swapgs
恢复GS
值 - 通过
sysretq
或者iretq
恢复到用户控件继续执行。如果使用iretq
还需要给出用户空间的一些信息(CS, eflags/rflags, esp/rsp
等)
关于syscall
系统调用,指的是用户空间的程序向操作系统内核请求需要更高权限的服务,比如 IO 操作或者进程间通信。系统调用提供用户程序与操作系统间的接口,部分库函数(如 scanf,puts 等 IO 相关的函数实际上是对系统调用的封装 (read 和 write))。
Int $0x80
指令的目的是产生一个编号为128的编程异常,这个编程异常对应的是中断描述符表IDT
中的第128
项——也就是对应的系统门描述符。门描述符中含有一个预设的内核空间地址,它指向系统调用处理程程序:system_call()
.
关于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>.
int ioctl(int fd, unsigned long request, ...)
的第一个参数为打开设备 (open) 返回的 文件描述符,第二个参数为用户程序对设备的控制命令,再后边的参数则是一些补充参数,与设备有关。
进程权限管理
注意到task_struct
的源码中有如下代码:
/* Process credentials: */
/* Tracer's credentials at attach: */
const struct cred __rcu *ptracer_cred;
/* Objective and real subjective task credentials (COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials (COW): */
const struct cred __rcu *cred;
Process credentials 是 kernel 用以判断一个进程权限的凭证,在 kernel 中使用 cred
结构体进行标识,对于一个进程而言应当有三个 cred:
- **ptracer_cred:**使用
ptrace
系统调用跟踪该进程的上级进程的cred(gdb调试便是使用了这个系统调用,常见的反调试机制的原理便是提前占用了这个位置) - real_cred:即客体凭证(objective cred),通常是一个进程最初启动时所具有的权限
- cred:即主体凭证(subjective cred),该进程的有效cred,kernel以此作为进程权限的凭证
进程权限凭证: cred
结构体
对于一个进程,在内核当中使用一个结构体cred
管理其权限,该结构体定义于内核源码include/linux/cred.h
中,如下:
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 *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 */
/* RCU deletion */
union {
int non_rcu; /* Can we skip RCU deletion? */
struct rcu_head rcu; /* RCU deletion hook */
};
} __randomize_layout;
我们主要关注cred
结构体中管理权限的变量
用户ID
和组ID
一个cred
结构体中记载了一个进程四种不同的用户ID:
- 用户真实
ID
(real UID
):标识一个进程启动时的用户ID
- 保存用户
ID
(saved UID
):标识一个进程最初的有效用户ID
- 有效用户
ID
(effective UID
):标识一个进程正在运行时所属的用户ID
- 文件系统用户
ID
(UID for VFS ops
):标识一个进程创建文件时进行标识的用户ID
通常情况下这四个值都是相同的。
用户组ID
同样分为四个:真实组
、保存组
、有效组
、文件系统组
与上面类似。
提权
通过前面我们可以知道,只要我们改变一个进程的cred
结构体,就能改变其执行权限。
内核空间下面有两个函数,都位于kernel/cred.c
中:
struct cred* prepare_kernel_cred(struct task_struct* daemon)
:该函数用以拷贝一个进程的cred
结构体,并返回一个新的cred
结构体,需要注意的是daemon
参数应为有效的进程描述符地址或者NULL
int commit_creds(struct cred *new)
:该函数用以将一个新的cred
结构体应用到进程。
查看prepare_kernel_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);
...
在prepare_kernel_cred()
函数中,若传入的参数为NULL,则会缺省使用init
进程的cred
作为模板进行拷贝,即可以直接获得一个标识着root权限的cred结构体。那么我们不难想到,只要我们能够在内核空间执行commit_creds(prepare_kernel_cred(NULL))
,那么就能够将进程的权限提升到root
。
IO
NIX/Linux
追求高层次抽象上的统一,其设计哲学之一便是万物皆文件
。
万物皆文件
NIX/Linux
设计的哲学之一 —— 万物皆文件
,在Linux
系统的视角下,无论是文件、设备、管道、还是目录,进程,甚至是磁盘,套接字等等,一切都可以被抽象成文件,一切都可以使用访问文件的方式进行操作:
通过这种哲学,Linux
提供了操作的同一性
:
- 所有的读去操作都可以通过
read
进行 - 所有的更改操作都可以通过
write
进行
我们还可以cat /dev/unrandom > /dev/dsp
进程文件系统
用以描述一个进程,其中包括一个该进程所打开的文件描述符、堆栈内存布局、环境变量等
进程文件系统本身是一个伪文件系统,通常被挂载到/proc
目录下,并不真正占用存储内存,而是占用一定的内存
当一个进程被建立起来时,其进程文件系统便会挂载到/proc/[PID]
下,我们可以在该目录下查看其相关信息。
文件描述符
进程通过文件描述符
来完成对文件的访问,其在形式上是一个非负整数,本质上是对文件的索引值,进程所有执行I/O
操作的系统调用都会通过文件描述符。
每个进程都独立有着一个文件描述符表,存放着该进程所打开的文件索引,每当进程成功打开一个现有文件/创建一个新文件时(通过系统调用open
进行操作),内核会向进程返回一个文件描述符
在kernel
中有着一个文件表,由所有的进程共享。
每个*NIX
进程都应当有着三个标准的POSIX
文件描述符,对应着三个标准文件流:
stdin:标准输入 = 0
stdout:标准输出 = 1
stderr:标准错误 = 2
后面打开的文件描述符应当从标号3
起始。
系统调用:ioctl
在*NIX
中一切都可以被视为文件,因为一切都可以访问文件的方式进行操作,Linux
定义了系统调用ioctl
供进程与设备之间进行通信
系统调用ioctl
是一个用于设备输入输出操作的一个系统调用,调用方式如下:
int ioctl(int fd,unsigned long request, ...)
fd
:设备的文件描述符request
:请求码- 其他参数
对于一个提供了ioctl通信方式的设备而言,我们可以通过其文件描述符、使用不同的请求码及其他请求参数通过ioctl系统调用完成不同的对设备的I/O操作
内核态函数调用
printf
变更为printk()
,但需要注意的是printtk()
不一定会把内容显示在终端上,当一定是在内核缓冲区里,可以通过dmesg
查看效果memcpy
变更为copy_from_user()/copy_to_user()
copy_from_user()
将用户空间的数据传送到内核空间copy_to_user()
实现将内核空间的数据传送到用户空间
malloc()
变更为kmalloc()
,内核态的内存分配函数,和malloc
相似,但使层用slab,slub
分配器.这个分配器通过一个多级的结构进行管理。- 首先有
cache
层,cache
是一个结构,也就是用来分配或者已经分配的一部分内核空间。kmalloc
使用多个cache,一个cache对应一个2的幂的大小的一组内存对象。slab
分配器严格按照cache
去区分,不同的cache
无法分配在一页内,slub
分配器则较为宽松,不同的cache
如果分配相同大小,可能会在一页内。
- 首先有
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
权限,0表示以0号进程作为参考标准的credentials
/proc/kallsyms
的内容需要root
权限才能查看,如果以非root
用户权限查看将显示地址为0
内核保护机制
smep
管理模式执行保护(Superivisor Mode Access Protection),禁止内核执行用户空间代码。当处理器处于ring 0
模式,执行用户空间的代码会触发页错误。Linux
下叫做PXN
。
smap
管理模式访问保护(Supervisor Mode Access Prevention),禁止内核访问用户地址空间,类似于smep
。当处理器处于ring 0
模式,访问用户空间的数据会触发页错误。ARM
下叫做PAN
(Privileged Access Never)
- 对于没有
SMAP/SMEP
的情况下把内核指针重定向到用户空间的利用方式称为ret2usr
。 - 上面两种保护的绕过方法:
physmap
是内核管理的一块非常大的连续的虚拟地址空间,为了提高效率,该地址空间和内存地址直接映射。内存地址相对physmap
要小的多,导致了任何一个内存地址可以在physmap
中找到对应的虚拟内存地址。我们知道用户空间的虚拟内存也会映射到内存地址,这就存在了连续虚拟内存地址映射到了同一个内存地址的情况。也就是说,我们在用户空间里创建的数据,代码就很有可能映射到physmap
空间。那么在用户空间用mmap()
将提权代码映射到内存,然后再在内核空间里找到其对应的副本,修改IP
调到副本执行就可以了。因为physmap
本身就在内核空间里,这种漏洞利用方式叫做ret2dir
。Intel
下系统根据CR4
控制寄存器的第20
位标识是否开启SMEP
保护,若能够通过kernel ROP
改变CR4
寄存器的值便能够关闭SMEP
保护,完成SMEP-bypass
,接下来就可以重新ret2usr
。
- 关闭
SMEP
方法:修改/etc/default/grub
文件中的GRUB_CMDLINE_LINUX=
,加上nosmep/nosmap/nokaslr
,然后update-grub
就可以。
MMAP_MIN_ADDR
内核空间和用户空间共享虚拟内存地址,因此需要防止用户空间mmap的内存从0开始,从而缓解空指针引用攻击。windows系统从win8开始禁止在零页分配内存。从linux内核2.6.22开始可以使用sysctl设置mmap_min_addr来实现这一保护。
KASLR
内核地址空间布局随机化(Kernel Address Space Layout Randomization),开启后,允许kernel image
加载到VMALLOC
区域的任何位置。在未开启KASLR保护机制时,内核的基址为0xffffffff80000000
, 内核会占用0xffffffff80000000~0xffffffffC0000000
这1G虚拟地址空间
Dmesg Restrictions
通过设置/proc/sys/kernel/dmesg_restrict
为1,可以将dmesg
输出的信息视为敏感信息(默认为0);
Kernel Address Display Restriction
在/proc/sys/kernel/kptr_restrict
被设置1,导致无法通过/proc/kallsyms
获取内核地址。
Kernel PageTable Isolation
KPTI
,内核页表隔离,进程页表隔离。进程地址空间被分成了内核地址空间和用户地址空间,内核地址都是共享的,用户空间只能单独使用。为了防止用户程序获取内核数据,可以让用户地址空间和内核地址空间使用两组页表集。Windows
称为KVA Shadow
。
由于有KPTI
保护,即使关闭了smep
和smap
,也不能执行用户区间的代码
,只能读,原因如下:
不隔离不意味着完全相同,填充内核态页表项时,KPTI
会给页表项加上_PAGE_NX
标志,以阻止执行内核态页表所映射用户地址空间的代码。在 KAISER patch
里把这一步骤叫 毒化(poison
)。
STACK PROTECTOR
类似于用户态程序的canary,通常又被称作是stack cookie,用以检测是否发生内核堆栈溢出,若是发生内核堆栈溢出则会产生kernel panic
。内核中的canary
的值通常取自gs
段寄存器某个固定偏移处的值,可以直接绕过。