【OS.0x00】Linux Kernel I:Basic Knowledge

本文最后更新于:2023年11月9日 上午

Big Linus is watching you!

0x00.OS Basis

I.操作系统究竟是什么?

在开始之前,笔者想让大家重新来思考这个问题:

  • 操作系统究竟是一个什么东西?

可能有的人会像笔者当年那样照本宣科地说——操作系统(Operation System)本质上也是一种软件,可以看作是普通应用程式与硬件之间的一层中间层,其主要作用便是调度系统资源、控制IO设备、操作网络与文件系统等,并为上层应用提供便捷、抽象的应用接口

那么在抛开掉这些词藻的背后,操作系统又是什么样的一个东西呢?这里笔者给出笔者自己所认为的定义

  • 操作系统实际上是我们抽象出来的一个概念,本质上与用户进程一般无二,都是位于物理内存中的代码+数据,不同之处在于当CPU执行操作系统内核代码时通常运行在高权限环境,拥有着完全的硬件访问能力,而 CPU在执行用户态代码时通常运行在低权限环境,只拥有部分/缺失硬件访问能力

这两种不同权限的运行状态实际上是通过硬件来实现的,这里我们开始引入新的一个概念——

II.分级保护域

分级保护域hierarchical protection domains)又被称作保护环,简称 Rings ,是一种将计算机不同的资源划分至不同权限的模型

在一些硬件或者微代码级别上提供不同特权态模式的 CPU 架构上,保护环通常都是硬件强制的。Rings是从最高特权级(通常被叫作0级)到最低特权级(通常对应最大的数字)排列的

Ring0 拥有最高特权,并且可以和最多的硬件直接交互(比如CPU,内存),因此操作系统内核代码通常运行在 ring0 下,即 CPU 在执行操作系统内核代码时处在 ring0 下

Intel的CPU将权限分为四个等级:Ring0、Ring1、Ring2、Ring3,权限等级依次降低,现代操作系统模型中我们通常只会使用 ring0 和 ring3,对应操作系统内核与用户进程,即 CPU 在执行用户进程代码时处在 ring3 下

image.png

那么我们现在可以给【用户态】与【内核态】这两个概念下定义了:

  • 用户态:CPU 运行在 ring3 + 用户进程运行环境上下文
  • 内核态:CPU 运行在 ring0 + 内核代码运行环境上下文

III.运行状态切换

那么操作系统如何使得 CPU 在不同的特权级间进行切换以进行调度呢?主要有两个途径:

  • 中断(interrupt):当 CPU 收到一个外部中断时,会切换到 ring0,并根据中断描述符表索引对应的中断处理代码以执行

    例如当笔者敲下这一行字时,便发生了许多次的键盘中断(笑)

  • 特权级相关指令:当 CPU 运行这些指令时会发生运行状态的改变,例如 iret 指令(ring0->ring3)或是 sysenter 指令(ring3->ring0)

基于这些特权级切换的方式,现代操作系统的开发者包装出了一种名为系统调用(syscall)的东西,作为由”用户态“切换到”内核态“的入口,从而执行内核代码来完成用户进程所需的一些功能;当用户进程想要请求更高权限的服务时,便需要通过由系统提供的应用接口,使用系统调用以陷入内核态,再由操作系统完成请求

IV.进程 = 进程运行环境上下文 + 内核数据结构

进程本质上是我们抽象出来的一个概念,CPU 是不知道也不可能知道进程是什么东西的,他只知道当前核心的寄存器的数据是这样,运行状态是这样,内存里的数据是那样,但我们可以人为地进行划分——将当前的运行环境上下文称之为一个进程实体

接下来我们引入一个新的概念——页表(page table)。这通常是一个多级结构,用以对应表示一个虚拟地址空间中存在的每张内存页所对应的物理内存,下图是一张经典的二级页表结构:

image.png

当开启分页模式之后,我们对内存的访问都需要通过页表进行,利用多级页表将对应的虚拟地址转化为物理地址后才能进行内存访问,因此当前页表集所表示的物理地址空间为我们当前所能访问的物理地址空间

我们想要让我们的计算机真正变成一个多任务系统,让其运行多个不同的进程,则我们需要实现不同进程间的虚拟地址空间的隔离,因此:

  • 每个进程都有自己独立的一组页表集

那么现在我们可以对抽象出来的进程的概念下一个定义了:进程 = 寄存器上下文 + 虚拟地址空间,这便是进程最核心的本质

但是有了这些还不够,因为需要运行的进程数量往往大于 CPU 核心数量,因此我们不能够让某几个进程占据所有的 CPU 时间,而应当对进程进行调度,让每个进程都有机会在 CPU 上运行——进程调度(schedule)的概念就出来了

那么我们如何知道什么时候该调度一个进程?这里我们就需要借助到硬件的辅助了——时钟中断用来帮助我们实现进程的调度

什么是时钟中断?这里笔者不深入介绍,你可以理解为在 CPU 上有个定时器,每隔一段时间就会向 CPU 发送一个中断,这个中断便是我们所说的时钟中断,而前面我们讲到:当 CPU 收到一个外部中断时,会切换到 ring0,并根据中断描述符表索引对应的中断处理代码以执行,因为时钟中断是会不断定时触发的,我们可以在时钟中断对应的处理代码中加入属于进程调度的部分,由此我们得知:

  • 操作系统利用时钟中断完成进程调度

那么我们怎么知道当前运行的这个进程是否需要被调度呢?我们又如何切换到另一个进程呢?为了解决这个问题,对于每一个进程,我们在内核地址空间中都会对应创建一个新的数据结构实体,在其中保存着该进程上次运行的环境上下文、运行的总时间等信息——我们称之为进程控制块(process control block,PCB)

在需要进行进程调度时,我们便能将当前进程的上下文保存到其在内核空间中对应的 PCB 中,记录其运行时间,并取出要运行的下一个进程对应的 PCB,从其中恢复待运行进程的环境上下文,因此:

  • 每个进程在内核空间当中都有着一个对应的 PCB 保存其相关信息

当然, PCB 还可以记录诸如进程权限、父子关系等信息,不过这里仅介绍对于 PCB 而言最基础也是最必须要的组件:进程上下文以及进程运行时间

进程运行时间其实也可以不用记录,直接默认进程时间片为一个时钟周期即可,不过现代操作系统往往会使用更加复杂的调度系统(例如 Linux 现在使用的 CFS 调度算法)

最后,有了以上这些概念之后,我们来为进程(process)下一个定义:

  • 进程 = 进程运行环境上下文 + 内核数据结构

好了,现在我们可以重新从头开始看操作系统的基本知识了

0x01.内核

一、Introduction

操作系统(Operation System)本质上也是一种软件,可以看作是普通应用程式与硬件之间的一层中间层,其主要作用便是调度系统资源、控制IO设备、操作网络与文件系统等,并为上层应用提供便捷、抽象的应用接口

而运行在内核态的内核kernel)则是一个操作系统最为核心的部分,提供着一个操作系统最为基础的功能

这张十分经典的图片说明了Kernel在计算机体系结构中的位置:

kernel 的主要功能可以归为以下三点:

  • 控制并与硬件进行交互
  • 提供应用程式运行环境
  • 调度系统资源

包括 I/O,权限控制,系统调用,进程管理,内存管理等多项功能都可以归结到以上三点中

但与一般的应用程式不同,系统内核的发生 crash 通常会引起系统重启

二、内核架构:微内核 & 宏内核 & 混合内核

通常来说我们可以把内核架构分为两种:宏内核微内核,现在还有一种内核是混合了宏内核与微内核的特性,称为 混合内核 ,三种内核的大致架构如下图所示:

image.png

I.宏内核(Monolithic Kernel)

宏内核(英语:Monolithic kernel),也译为集成式内核单体式内核,一种操作系统内核架构,此架构的特性是整个内核程序是一个单一二进制可执行文件,在内核态以监管者模式(Supervisor Mode)来运行。相对于其他类型的操作系统架构,如微内核架构或混合内核架构等,这些内核会定义出一个高端的虚拟接口,由该接口来涵盖描述整个电脑硬件,这些描述会集合成一组硬件描述用词,有时还会附加一些系统调用,如此可以用一个或多个模块来实现各种操作系统服务,如进程管理、并发(Concurrency)控制、存储器管理等。

Wikipedia: 整塊性核心

通俗地说,宏内核几乎将一切都集成到了内核当中,并向上层应用程式提供抽象API(通常是以系统调用的形式),例如 Unix 与类 Unix 便通常都是宏内核

image.png

以 Linux 为例,其操作系统内核的二进制文件通常位于 /boot/vmlinuz

II.微内核(Micro Kernel)

对于微内核而言,大部分的系统服务(如文件管理等)都被剥离于内核之外,内核仅仅提供最为基本的一些功能:底层的寻址空间管理、线程管理、进程间通信等

Windows NT 与 Mach 都宣称采用了微内核架构,不过本质上他们更贴近于混合内核(Hybrid Kernel)——在内核中集成了部分需要具备特权的服务组件

image.png

本文中我们主要讨论Linux内核

0x02.分级保护域

分级保护域hierarchical protection domains)又被称作保护环,简称 Rings ,是一种将计算机不同的资源划分至不同权限的模型

在一些硬件或者微代码级别上提供不同特权态模式的 CPU 架构上,保护环通常都是硬件强制的。Rings是从最高特权级(通常被叫作0级)到最低特权级(通常对应最大的数字)排列的

在大多数操作系统中,Ring0 拥有最高特权,并且可以和最多的硬件直接交互(比如CPU,内存)

内层ring可以任意调用外层ring的资源

一、Intel Ring Model

Intel 的 CPU 将权限分为四个等级:Ring0、Ring1、Ring2、Ring3,权限等级依次降低,大部分现代操作系统只用到了ring0 和 ring3,其中 kernel 运行在 ring0,用户态程序运行在 ring3

image.png

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

操作系统本身便是一个运行在内核态的程序,当计算机通电之后首先会载入 ROM(BIOS/UEFI),之后载入第二引导程序(Linux 通常用 GNU Grub),由第二引导程序来将操作内核载入到内存当中并跳转到内核入口点,将控制权移交内核

内核在完成一系列的初始化过程之后,会启动一些低权限(ring3)的进程以向我们提供用户界面

二、虚拟内存空间

在现代操作系统中,计算机的虚拟内存地址空间通常被分为两块——供用户进程使用的用户空间(user space)与供操作系统内核使用的内核空间(kernel space),对于 Linux 而言,通常位于较高虚拟地址的虚拟内存空间被分配给内核使用,而位于较低虚拟地址的虚拟内存空间责备分配给用户进程使用

  • 32 位下的虚拟内存空间布局如下:

image.png

  • 64位下的虚拟内存空间布局如下:

image.png

三、用户态 & 内核态

抛开华丽的辞藻,这两个词最本质的含义是:

  • 用户态:CPU 运行在 ring3 + 用户进程运行环境上下文
  • 内核态:CPU 运行在 ring0 + 内核代码运行环境上下文

通常情况下,不同用户进程间的用户地址空间是隔离的,但都共享着相同的内核地址空间,即不同的用户进程在相同的内核虚拟地址空间上都有着一致的对内核物理地址空间的映射

0x03. 运行状态切换与控制流转移

计算机运行时总会经历无数次的用户态与内核态之间的转换,进行无数次的控制流转移

  • 用户进程往往需要使用内核所提供的各种功能,此时就需要通过系统调用等接口陷入内核,待任务完成之后再“着陆”回用户态
  • 操作系统往往也需要“主动地”暂停当前进程的运行,让 CPU 陷入到内核态并获取控制权,这通常是因为有需要内核主动处理的事件(如外部中断)

通常而言,CPU 由用户态陷入到内核态主要有以下几种途径:

  • 系统调用(通常通过 int 0x80/syscall/sysenter 指令)
  • 异常 (例如进行了除 0 操作)
  • 外设产生中断 (例如时钟中断)

主要的一个过程如下:

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

  • 保存用户态栈帧信息:将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里(由 GS 寄存器所指定的percpu 段),将 CPU 独占区域里记录的内核栈顶放入 rsp/esp

  • 保存用户态寄存器信息: 通过 push 保存各寄存器值到栈上,以便后续“着陆”回用户态

  • 通过汇编指令判断是否为32位

  • 控制权转交内核,执行相应的操作

由内核态重新“着陆”回用户态只需要恢复用户空间信息即可:

  • swapgs 指令恢复用户态GS寄存器
  • sysretq 或者iretq 系列指令让 CPU 运行模式回到 ring 3,恢复用户空间程序的继续运行

一、中断

中断即硬件/软件向 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)

中断描述符表

二、系统调用

系统调用system call)是由操作系统内核向上层应用程式提供的应用接口,操作系统负责调度一切的资源,当用户进程想要请求更高权限的服务时,便需要通过由系统提供的应用接口,使用系统调用以陷入内核态,再由操作系统完成请求

Windows系统下将系统调用封装在win32 API中,不过本篇博文主要讨论Linux

I. 系统调用表

所有的系统调用被声明于内核源码arch/x86/entry/syscalls/syscall_64.tbl 中,在该表中声明了系统调用的标号、类型、名称、内核态函数名称

在内核中使用 系统调用表(System Call Table) 对系统调用进行索引,该表中储存了不同标号的系统调用函数的地址,当进程进行系统调用时会根据该表寻找到不同系统调用对应的处理函数

II. 进行系统调用

Linux下的系统调用以eax/rax寄存器作为系统调用号,参数传递约束如下:

  • 32 位:ebx、ecx、edx、esi、edi、ebp 作为第一个参数、第二个参数…进行参数传递
  • 64 位:rdi、rsi、rdx、rcx、r8、r9 作为第一个参数、第二个参数…进行参数传递

对于 32 位系统调用而言,Linux 实际上通过 0x80 号软中断实现系统调用的基本功能,即用户态程序通过 int 0x80 指令触发一个软中断陷入内核题,对应的中断处理程序会根据系统调用号在系统调用表中调用对应的处理

0x80 号中断:系统调用

对于 64 位系统调用而言,CPU 提供了 syscall/sysenter 指令来替代开销巨大的中断,内核启动时会将系统调用函数入口(entry_SYSCALL_64)写入 MSR 寄存器组中,当执行 syscall/sysenter 指令时 CPU 会进入 ring0 并自动跳转到 MSR 寄存器所指定的系统调用入口中:

  • 通过 swapgs 指令将 GS 寄存器值和一个特定位置的值进行交换,目的是保存 GS 值,同时将该位置的值作为内核执行时的 GS 值使用
  • 将当前栈顶(用户空间栈顶)记录在 CPU 独占变量区域里(由 GS 寄存器所指定的 percpu 段),将 CPU 独占区域里记录的内核栈顶赋给 rsp/esp 寄存器
  • 将用户态各寄存器值到栈上形成一个 pt_regs 结构体,以便后续“着陆”回用户态
  • 根据系统调用号在系统调用表中取出对应的系统调用函数指针执行

64 位系统调用:syscall 指令

III. 系统调用返回

同样地,内核执行完系统调用后退出系统调用也有对应的两种方式:

  • 执行 iret 汇编指令(其实就是中断返回的方式)
  • 执行 sysret 汇编指令 / 执行 sysexit 汇编指令(only Intel)

三、信号机制

Signals 机制(又称之为软中断信号)是UNIX及类UNIX系统中的一种异步的进程间通信方式,用以通知一个进程发生了某个事件,通常情况下常见的流程如下图所示:

image.png

  • 源程序通过 kill() 系统调用向目标程序发送信号,该信号会被存储到进程描述符的信号队列当中
  • ① 当目标进程重新被调度时内核首先会检查该进程的信号队列,若存在未处理信号则会进行信号处理流程:内核会将用户态进程的寄存器逐一压入【用户态进程的栈上】,形成一个 sigcontext 结构体,接下来压入 SIGNALINFO 以及指向系统调用 sigreturn 的代码,用以在后续返回时恢复用户态进程上下文;压入栈上的这一大块内容称之为一个 SigreturnFrame,同时也是一个ucontext_t结构体
  • ② 控制权回到用户态进程,用户态进程跳转到相应的 signal handler 函数以处理不同的信号,完成之后将会执行位于其栈上的第一条指令——sigreturn系统调用
  • ③ 进程通过 sigreturn 系统调用重新陷入内核,恢复原有工作的用户态上下文信息
  • ④ 控制权重新返还给用户态进程,恢复进程原上下文

需要注意的是信号处理机制并不是即时的,即发送信号并不能直接中断一个正在运行中的进程,但 Linux 进程调度的频率是非常频繁的,因此信号通常都能很快被处理

0x04.进程权限管理

前面我们讲到,kernel 调度着一切的系统资源,并为用户应用程式提供运行环境,相应地,应用程式的权限也都是由 kernel 进行管理的

一、进程描述符(process descriptor)

在内核中使用结构体 task_struct 表示一个进程,该结构体定义于内核源码include/linux/sched.h中,代码比较长就不在这里贴出了

一个进程描述符的结构应当如下图所示:

image.png

本篇我们主要关心其对于进程权限的管理

注意到task_struct的源码中有如下代码:

1
2
3
4
5
6
7
8
9
10
/* 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以此作为进程权限的凭证

一般情况下,主体凭证与客体凭证的值是相同的

例:当进程 A 向进程 B 发送消息时,A为主体,B为客体

进程权限凭证:cred结构体

对于一个进程,在内核当中使用一个结构体cred管理其权限,该结构体定义于内核源码include/linux/cred.h中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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进行认证的,内核通过 euid 来进行特权判断;为了防止用户一直使用高权限,当任务完成之后,euid 会与 suid 进行交换,恢复进程的有效权限
  • 文件系统用户ID(UID for VFS ops):标识一个进程创建文件时进行标识的用户ID

在通常情况下这几个ID应当都是相同的

用户组ID同样分为四个:真实组ID保存组ID有效组ID文件系统组ID,与用户ID是类似的,这里便不再赘叙

二、进程权限改变

前面我们讲到,一个进程的权限是由位于内核空间的 cred 结构体进行管理的,那么我们不难想到:只要改变一个进程的 cred 结构体,就能改变其执行权限

在内核空间有如下两个函数,都位于 kernel/cred.c 中:

  • struct cred* prepare_kernel_cred(struct task_struct* daemon):该函数用以拷贝一个进程的cred结构体,并返回一个新的cred结构体,需要注意的是daemon参数应为有效的进程描述符地址
  • int commit_creds(struct cred *new):该函数用以将一个新的cred结构体应用到进程

0x05.I/O

*NIX/Linux追求高层次抽象上的统一,其设计哲学之一便是万物皆文件

一、“万物皆文件”

NIX/Linux设计的哲学之一——万物皆文件,在Linux系统的视角下,无论是文件、设备、管道,还是目录、进程,甚至是磁盘、套接字等等,*一切都可以被抽象为文件,一切都可以使用访问文件的方式进行操作

通过这样一种哲学,Linux予开发者以高层次抽象的统一性,提供了操作的一致性

  • 所有的读取操作都可以通过对文件进行 read 系统调用完成
  • 所有的更改操作都可以通过对文件进行 write 系统调用完成
  • 所有的配置操作都可以通过对文件进行 ioctl 系统调用完成

对于开发者而言,将一切的操作都统一于一个高层次抽象的应用接口,无疑是十分美妙的一件事情——我们不需要去理解实现的细节,只需要对”文件”完成简单的读写操作

例如,在较老版本的Linux中,可以使用cat /dev/urandom > /dev/dsp命令令扬声器产生随机噪声

image.png

二、进程文件系统

进程文件系统(process file system, 简写为procfs)用以描述一个进程,其中包括该进程所打开的文件描述符、堆栈内存布局、环境变量等等

进程文件系统本身是一个伪文件系统,通常被挂载到/proc目录下,并不真正占用储存空间,而是占用一定的内存

当一个进程被建立起来时,其进程文件系统便会被挂载到/proc/[PID]下,我们可以在该目录下查看其相关信息

三、文件描述符

进程通过文件描述符file descriptor)来完成对文件的访问,其在形式上是一个非负整数,本质上是对文件的索引值,进程所有执行 I/O 操作的系统调用都会通过文件描述符

每个进程都独立有着一个文件描述符表,存放着该进程所打开的文件索引,每当进程成功打开一个现有文件/创建一个新文件时(通过系统调用open进行操作),内核会向进程返回一个文件描述符

在kernel中有着一个文件表,由所有的进程共享

stdin、stdout、stderr

每个*NIX进程都应当有着三个标准的POSIX文件描述符,对应着三个标准文件流:

  • stdin:标准输入 - 0
  • stdout:标准输出 - 1
  • stderr:标准错误 - 2

此后打开的文件描述符应当从标号3起始

四、系统调用:ioctl

在*NIX中一切都可以被视为文件,因而一切都可以以访问文件的方式进行操作,为了方便,Linux定义了系统调用ioctl供进程与设备之间进行通信

系统调用ioctl是一个专用于设备输入输出操作的一个系统调用,其调用方式如下:

1
int ioctl(int fd, unsigned long request, ...)
  • fd:设备的文件描述符
  • request:请求码
  • 其他参数

对于一个提供了ioctl通信方式的设备而言,我们可以通过其文件描述符、使用不同的请求码及其他请求参数通过ioctl系统调用完成不同的对设备的I/O操作

例如CD-ROM驱动程序弹出光驱的这一操作就对应着对“光驱设备”这一文件通过ioctl传递特定的请求码与请求参数完成

0x06.Loadable Kernel Modules(LKMs)

前面我们讲到,Linux Kernle采用的是宏内核架构,一切的系统服务都需要由内核来提供,虽然效率较高,但是缺乏可扩展性与可维护性,同时内核需要装载很多可能用到的服务,但这些服务最终可能未必会用到,还会占据大量内存空间,同时新服务的提供往往意味着要重新编译整个内核

综合以上考虑,可装载内核模块Loadable Kernel Modules,简称LKMs)出现了,位于内核空间的LKMs可以提供新的系统调用或其他服务,同时LKMs可以像积木一样被装载入内核/从内核中卸载,大大提高了kernel的可拓展性与可维护性

常见的外设驱动便是LKM的一种

LKMs与用户态可执行文件一样都采用ELF格式,但是LKMs运行在内核空间,且无法脱离内核运行

通常与LKM相关的命令有以下三个:

  • lsmod:列出现有的LKMs
  • insmod:装载新的LKM(需要root)
  • rmmod:从内核中移除LKM(需要root)

CTF 比赛中的 kernel pwn 的漏洞往往出现在第三方 LKM 中,一般来说不会真的让你去直接日内核组件

0x07.内核内存结构 & 管理

Linux kernel 将内存分为 页→区→节点 三级结构,主要有两个内存管理器—— buddy systemslab allocator

一、 页→区→节点三级结构

这是一张十分经典的 _Overview_,自顶向下是

  • 节点(node,对应结构体 pgdata_list)
  • (zone,对应结构体 zone,图上展示了三种类型的 zone)
  • (page,对应结构体 page)

image.png

I.页(page)

Linux kernel 中使用 page 结构体来表示一个物理页框,每个物理页框都有着一个对应的 page 结构体

image.png

II.区(zone)

在 Linux 下将一个节点内不同用途的内存区域划分为不同的 区(zone),对应结构体 struct zone

自己画的图.png

III.节点(node)

zone 再向上一层便是节点——Linux 将_内存控制器(memory controller)_作为节点划分的依据,对于 UMA 架构而言只有一个节点,而对于 NUMA 架构而言通常有多个节点,对于同一个内存控制器下的 CPU 而言其对应的节点称之为_本地内存_,不同处理器之间通过总线进行进一步的连接。如下图所示,一个 MC 对应一个节点:

image.png

二、内存模型

Linux 提供了三种内存模型,定义于 include/asm-generic/memory_model.h 中,如下图所示(偷的图,侵删):

image.png

内存模型在编译期就会被确定下来,目前常用的是 Sparse Memory 模型,即离散内存模型

Flat Memory

平滑内存模型。物理内存地址连续,有一个全局变量 mem_map ——由一个大的 struct page 数组直接对应现有的物理内存

Discontiguous Memory

非连续性内存模型。主要针对内存中存在空洞的情况。

对于每一段连续的物理内存,都有一个 pglist_data 结构体进行对应,其成员 node_mem_map 为一个 struct page 指针,指向一个 page 结构体数组,由该结构体对应到该段连续物理内存

有一个全局变量 node_data 为一个 pglist_data 指针数组,其中存放着指向每一个 pglist_data 的指针,该数组的大小为 MAX_NUMNODES

Sparse Memory

离散内存模型。在一个 mem_section 结构体中存在一个 section_mem_map 成员指向一个 struct page 数组对应一段连续的物理内存,即将内存按照 section 为单位进行分段

存在一个全局指针数组 mem_section (与结构体同名)存放所有的 mem_section 指针,指向理论上支持的内存空间,每个 section 对应的物理内存不一定存在,若不存在则此时该 section 的指针为 NULL

这种模型支持内存的热拔插

图还是偷的,侵删

image.png

三、buddy system

目前的 CTF 题目当中还没有出现针对 buddy system 进行利用的题目,但是有安全研究员在 CVE-2022-27666 中运用了页级堆风水的技巧,笔者认为非常 nb

buddy system 是 Linux kernel 中的一个较为底层的内存管理系统,以内存页为粒度管理者所有的物理内存,其存在于 这一级别,对当前区所对应拥有的所有物理页框进行管理

在每个 zone 结构体中都有一个 free_area 结构体数组,用以存储 buddy system 按照 order 管理的页面

1
2
3
4
struct zone {
//...
struct free_area free_area[MAX_ORDER];
//...

其中的 MAX_ORDER 为一个常量,值为 11

在 buddy system 中按照空闲页面的连续大小进行分阶管理,这里的 order 的实际含义为连续的空闲页面的大小,不过单位不是页面数,而是,即对于每个下标而言,其中所存储的页面大小为:
$$
2^{order}
$$
在 free_area 中存放的页面通过自身的相应字段连接成双向链表结构,由此我们得到这样一张_Overview_:

自己画的图.png

  • 分配:
    • 首先会将请求的内存大小向 2 的幂次方张内存页大小对齐,之后从对应的下标取出连续内存页
    • 若对应下标链表为空,则会从下一个 order 中取出内存页,一分为二,装载到当前下标对应链表中,之后再返还给上层调用,若下一个 order 也为空则会继续向更高的 order 进行该请求过程
  • 释放:
    • 将对应的连续内存页释放到对应的链表上
    • 检索是否有可以合并的内存页,若有,则进行合成,放入更高 order 的链表中

但是我们很容易产生不容易合并的内存碎片,因此 Linux kernel 还会进行 内存迁移 以减少内存碎片,原理如下图,主要由一个持续运行的内核线程完成,由于不是本篇重点故不在此赘叙

从知乎偷的图.png

四、slab allocator

slab allocator 则是更为细粒度的内存管理器,其通过向 buddy system 请求单张或多张连续内存页后再分割成同等大小的对象(object)返还给上层调用者来实现更为细粒度的内存管理

slab allocator 一共有三种版本:

  • slab(最初的版本,机制比较复杂,效率不高)
  • slob(用于嵌入式等场景的极为简化版本)
  • slub(优化后的版本,现在的通用版本)

I.基本结构

slub 版本的 allocator 为现在绝大多数 Linux kernel 所装配的版本,因此本篇文章主要叙述的也是 slub allocator,其基本结构如下图所示:

image.png

  • 我们将 slub allocator 每次向 buddy system 请求得来的单张/多张内存页称之为一个 slub,其被分割为多个同等大小对象(object),每个 object 作为一个被分配实体,在 slub 的第一张内存页对应的 page 结构体上的 freelist 成员指向该张内存页上的第一个空闲对象,一个 slub 上的所有空闲对象组成一个以 NULL 结尾的单向链表

    一个 object 可以理解为用户态 glibc 中的 chunk,不过 object 并不像 chunk 那样需要有一个 header,因为 page 结构体与物理内存间存在线性对应关系,我们可以直接通过 object 地址找到其对应的 page 结构体

  • kmem_cache 为一个基本的 allocator 组件,其用于分配某个特定大小(某种特定用途)的对象,所有的 kmem_cache 构成一个双向链表,并存在两个对应的结构体数组 kmalloc_cacheskmalloc_dma_caches

  • 一个 kmem_cache 主要由两个模块组成:

    • kmem_cache_cpu:这是一个percpu 变量(即每个核心上都独立保留有一个副本,原理是以 gs 寄存器作为 percpu 段的基址进行寻址),用以表示当前核心正在使用的 slub,因此当前 CPU 在从 kmem_cache_cpu 上取 object 时不需要加锁,从而极大地提高了性能
    • kmem_cache_node:可以理解为当前 kmem_cache 的 slub 集散中心,其中存放着两个 slub 链表:
      • partial:该 slub 上存在着一定数量的空闲 object,但并非全部空闲
      • full:该 slub 上的所有 object 都被分配出去了

II.分配/释放过程

那么现在我们可以来说明 slub allocator 的分配/释放行为了

  • 分配:
    • 首先从 kmem_cache_cpu 上取对象,若有则直接返回
    • kmem_cache_cpu 上的 slub 已经无空闲对象了,对应 slub 会被加入到 kmem_cache_nodefull 链表,并尝试从 partial 链表上取一个 slub 挂载到 kmem_cache_cpu 上,然后再取出空闲对象返回
    • kmem_cache_node 的 partial 链表也空了,那就向 buddy system 请求分配新的内存页,划分为多个 object 之后再给到 kmem_cache_cpu,取空闲对象返回上层调用
  • 释放:
    • 若被释放 object 属于 kmem_cache_cpu 的 slub,直接使用头插法插入当前 CPU slub 的 freelist
    • 若被释放 object 属于 kmem_cache_node 的 partial 链表上的 slub,直接使用头插法插入对应 slub 的 freelist
    • 若被释放 object 属于 kmem_cache_node 的 full 链表上的 slub,则其会成为对应 slub 的 freelist 头节点,且该 slub 会从 full 链表迁移到 partial 链表

以上便是 slub allocator 的基本原理

III. slab alias(mergeability)

slab alias 机制是一种对同等/相近大小 object 的 kmem_cache 进行复用的一种机制:

  • 当一个 kmem_cache 在创建时,若已经存在能分配相等/近似大小的 object 的 kmem_cache ,则不会创建新的 kmem_cache,而是为原有的 kmem_cache 起一个 alias,作为“新的” kmem_cache 返回

举个🌰,cred_jar 是专门用以分配 cred 结构体的 kmem_cache,在 Linux 4.4 之前的版本中,其为 kmalloc-192 的 alias,即 cred 结构体与其他的 192 大小的 object 都会从同一个 kmem_cache——kmalloc-192 中分配

对于初始化时设置了 SLAB_ACCOUNT 这一 flag 的 kmem_cache 而言,则会新建一个新的 kmem_cache 而非为原有的建立 alias,🌰如在新版的内核当中 cred_jarkmalloc-192 便是两个独立的 kmem_cache彼此之间互不干扰

0x08.保护机制

与一般的程序相同,Linux Kernel同样有着各种各样的保护机制:

一、通用保护机制

KASLR

KASLR即内核空间地址随机化(kernel address space layout randomize),与用户态程序的ASLR相类似——在内核镜像映射到实际的地址空间时加上一个偏移值,但是内核内部的相对偏移其实还是不变的

在未开启KASLR保护机制时,内核代码段的基址为 0xffffffff81000000 ,direct mapping area 的基址为 0xffff888000000000

内核内存布局可以参考这↑里↓

*FGKASLR

KASLR 虽然在一定程度上能够缓解攻击,但是若是攻击者通过一些信息泄露漏洞获取到内核中的某个地址,仍能够直接得知内核加载地址偏移从而得知整个内核地址布局,因此有研究者基于 KASLR 实现了 FGKASLR,以函数粒度重新排布内核代码

STACK PROTECTOR

类似于用户态程序的 canary,通常又被称作是 stack cookie,用以检测是否发生内核堆栈溢出,若是发生内核堆栈溢出则会产生 kernel panic

内核中的 canary 的值通常取自 gs 段寄存器某个固定偏移处的值

SMAP/SMEP

SMAP即管理模式访问保护(Supervisor Mode Access Prevention),SMEP即管理模式执行保护(Supervisor Mode Execution Prevention),这两种保护通常是同时开启的,用以阻止内核空间直接访问/执行用户空间的数据,完全地将内核空间与用户空间相分隔开,用以防范ret2usr(return-to-user,将内核空间的指令指针重定向至用户空间上构造好的提权代码)攻击

SMEP保护的绕过有以下两种方式:

  • 利用内核线性映射区对物理地址空间的完整映射,找到用户空间对应页框的内核空间地址,利用该内核地址完成对用户空间的访问(即一个内核空间地址与一个用户空间地址映射到了同一个页框上),这种攻击手法称为 ret2dir
  • Intel下系统根据CR4控制寄存器的第20位标识是否开启SMEP保护(1为开启,0为关闭),若是能够通过kernel ROP改变CR4寄存器的值便能够关闭SMEP保护,完成SMEP-bypass,接下来就能够重新进行 ret2usr,但对于开启了 KPTI 的内核而言,内核页表的用户地址空间无执行权限,这使得 ret2usr 彻底成为过去式

image.png

在 ARM 下有一种类似的保护叫 PXN

KPTI

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

需要进行说明的是,在这两张页表上都有着对用户内存空间的完整映射,但在用户页表中只映射了少量的内核代码(例如系统调用入口点、中断处理等),而只有在内核页表中才有着对内核内存空间的完整映射,但两张页表都有着对用户内存空间的完整映射,如下图所示,左侧是未开启 KPTI 后的页表布局,右侧是开启了 KPTI 后的页表布局

在 64 位下用户空间与内核空间都占 128 TB,所以他们占用的页全局表项(PGD)的大小应当是相同的,图上没有体现出来,因此这里由笔者代为补充(笑)

image.png

KPTI 的发明主要是用来修复一个史诗级别的 CPU 硬件漏洞:Meltdown。简单理解就是利用 CPU 流水线设计中(乱序执行与预测执行)的漏洞来获取到用户态无法访问的内核空间的数据,属于侧信道攻击的一种

简单理解:你的第一条指令是合法内存访问,第二条指令是越权内存访问,CPU会在执行第一条正常指令的过程中也会执行第二条非法指令,之后再做权限判定,若是非法访问则消除该影响,这样的流水线设计虽然有着较高的性能,但是越权访问仍会留下痕迹,这给了攻击者非法获取内核空间数据的可能

若是整个流水线设计直接扔掉那自然是捡了芝麻丢了西瓜,因此 KPTI 被快速应用到主流操作系统上(这个设计在漏洞出来之前就有,但未得到广泛应用),尽管仍旧造成了一定的性能损耗,但却有效地从软件层面修复了 Meltdown 漏洞

KPTI 同时还令内核页表中属于用户地址空间的部分不再拥有执行权限,这使得 ret2usr 彻底成为过去式

二、内核“堆”上保护机制

more info 可以参考这里

Hardened Usercopy

hardened usercopy 是用以在用户空间与内核空间之间拷贝数据时进行越界检查的一种防护机制,主要检查拷贝过程中对内核空间中数据的读写是否会越界

  • 读取的数据长度是否超出源 object 范围
  • 写入的数据长度是否超出目的 object 范围

不过这种保护 不适用于内核空间内的数据拷贝 ,这也是目前主流的绕过手段

这一保护被用于 copy_to_user()copy_from_user() 等数据交换 API 中

Hardened freelist

类似于 glibc 2.32 版本引入的保护,在开启这种保护之前,slub 中的 free object 的 next 指针直接存放着 next free object 的地址,攻击者可以通过读取 freelist 泄露出内核线性映射区的地址,在开启了该保护之后 free object 的 next 指针存放的是由以下三个值进行异或操作后的值:

  • 当前 free object 的地址
  • 下一个 free object 的地址
  • 由 kmem_cache 指定的一个 random 值

攻击者至少需要获取到第一与第三个值才能篡改 freelist,这无疑为对 freelist 的直接利用增添不少难度

在更新版本的 Linux kernel 中似乎还引入了一个偏移值,笔者尚未进行考证

Random freelist

这种保护主要发生在 slub allocator 向 buddy system 申请到页框之后的处理过程中,对于未开启这种保护的一张完整的 slub,其上的 object 的连接顺序是线性连续的,但在开启了这种保护之后其上的 object 之间的连接顺序是随机的,这让攻击者无法直接预测下一个分配的 object 的地址

需要注意的是这种保护发生在slub allocator 刚从 buddy system 拿到新 slub 的时候,运行时 freelist 的构成仍遵循 LIFO

image.png

CONFIG_INIT_ON_ALLOC_DEFAULT_ON

当编译内核时开启了这个选项时,在内核进行“堆内存”分配时(包括 buddy system 和 slab allocator),会将被分配的内存上的内容进行清零,从而防止了利用未初始化内存进行数据泄露的情况

据悉性能损耗在 1%~7% 之间

0xFF. Reference

《Linux Kernel Development》Robert Love 著

The Linux Kernel Documentation

《linux技术内幕》罗秋明 著

《Operating System Concepts》 Abraham Silberschatz, Greg Gagne, and Peter Baer Galvin 著

Kernel Exploring

Linux Kernel Teaching

Linux 内存模型

LoyenWang - 博客园

Intel® 64 and IA-32 Architectures Software Developer Manuals


【OS.0x00】Linux Kernel I:Basic Knowledge
https://arttnba3.github.io/2021/02/21/OS-0X00-LINUX-KERNEL-PART-I/
作者
arttnba3
发布于
2021年2月21日
许可协议