Featured image of post kernel_pwn 0x00版学习总结

kernel_pwn 0x00版学习总结

如有错误,请直接指出,谢谢。

前言

linux kernel pwn 在 32 位和 64 位的环境下有一定区别,本文无特别提示均以 64 位环境为前提

基础知识

可能需要了解的一些小知识。

操作系统内核

操作系统内核本质也是一种软件。

image

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

比如,应用程序想要读取一个文件:

1
read(fd, buf, 100);

实际的路径应该如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
用户程序调用 read
通过 syscall 进入内核
内核检查 fd、buf、权限和长度
文件系统处理请求
设备驱动与存储设备交互
内核把结果返回用户程序

不过需要注意的是,上图的结构只是为了方便理解所谓“中间层”的功能关系,在内存意义上,内核只是我们抽象出的一个概念,本质与用户进程无异,都是位于物理内存中的代码 + 数据。

也就是说,应用页面与内核页面,在物理内存中完全可能是相邻或者交叉的

内核态和用户态的不同之处主要在于,往往 CPU 执行操作系统内核代码时拥有近乎完全的硬件访问能力,处于高权限,而执行用户态代码则处于低权限环境,受限较多。

CPU 分级

image

CPU 分级数值上越小,权限越大。CPU 会根据当前特权级、指令类型、段权限和页表权限进行检查。

不过虽然有四个权限等级,但包括 Linux 和 Windows 在内的大多数现代操作系统,在实际运行中主要只使用 Ring0 和 Ring3

  • Ring0(内核态):拥有最高权限,可以直接访问底层硬件和所有有映射的内存空间。Linux 内核及其核心驱动模块运行在此级别。
  • Ring3(用户态):拥有最低权限,代码受到严格限制。普通的应用程序(如浏览器、办公软件等)均运行在此级别。

使用 CPL(Current Privilege Level,当前特权级)来描述当前逻辑 CPU 处于的特权级,CPL 为几,CPU就处于Ring几

(考虑到多核问题,这里称“当前逻辑 CPU”,下文可能省略,直接称呼 CPU)

注意的是,CPL 并不是一个寄存器,因此它并不能通过类似 mov cpl,0 的方式赋值,而是 CS(Code Segment Register,代码段寄存器)的后两位,可以简单理解为是 CPL = CS & 3 得到的。

CS 和 SS、段选择子

CS 和 SS(Stack Segment Register,栈段寄存器)都是寄存器,但是它们保存的是段选择子,而并不保存地址,其分别对应的 RIP 保存代码地址,RSP 保存栈顶地址

段选择子可以理解为一个 16 位(2字节)的二进制数,其包含的描述符信息指向了当前代码段/栈的性质,段选择子的结构如下:

1
2
3
4
15                         3  2  1 0
┌──────────────────────────┬──┬─────┐
│        Index             │TI│ RPL │
└──────────────────────────┴──┴─────┘
  • Index = selector >> 3;,查描述符表的第几项。
  • TI = (selector >> 2) & 1;,查哪个描述符表,0 表示查 GDT,1 表示查 LDT。
  • RPL = selector & 3;,选择子的请求特权级。

比如常见的 CS = 0X33

  • Index = 0x33 >> 3 = 6
  • TI = 0
    • 即查 GDT[6]
  • RPL = 3,选择子的请求特权级为 3。

GDT 与 LDT、描述符

GDT(Global Descriptor Table,全局描述符表)与 LDT(Local Descriptor Table,局部描述符表)可以理解为一个数组,其内放的元素是描述符

普通的代码段和数据段的描述符是一个 8 字节大小的,描述了段信息的数。

其信息主要包括该代码段的位置、大小、类型、权限和执行模式。

举个例子:

假如内存中有一段内核代码:

1
2
3
0x00100000  push rbp
0x00100001  mov rbp, rsp
0x00100004  ...

那么 GDT 中就会有一段描述符,记录了类似:

1
2
3
4
5
6
7
8
Base    = 0x00100000    // 段基地址
Limit   = 0x000fffff    //段界限,即段内允许的最大偏移,可以理解为代码段允许使用的最大范围
Type    = 代码段        //代码段 or 数据段
DPL     = 0            //描述符特权级,Ring3 or Ring0
Present = 1            //描述符是否存在且有效
L                      // 是否是 64 位代码
D/B                    // 默认位宽
G                       // limit粒度

也就是说,描述符表仅仅存放了“代码如何被使用”的描述符,而并不存放代码本身。

页表、虚拟地址和物理地址

以上我们了解到特权级和相关的执行模式,那么我们要执行一段内核代码,还需要知道它的地址。

那么我们首先要了解虚拟地址和物理地址的概念:

  • 虚拟地址:程序指令里出现的地址,通常是虚拟地址。例如 mov rax, [0x401000]0x401000 指当前进程的虚拟地址,而不是内存条的实际位置。同一个虚拟地址,在不同进程中通常表示完全不同的物理内存
  • 物理地址:物理地址对应实际物理内存系统中的地址。例如物理地址 0x123456 最终可能落在某个 DRAM 内存位置,或者由硬件内存系统进一步处理。普通用户程序一般不能直接访问物理地址

也就是说,物理内存真正存储了代码、数据等,而虚拟地址只是对地址进行编号和解释的一套抽象空间

页表连接了虚拟地址和物理地址二者,页表担任了两项主要职责:地址翻译 + 权限保护

  • 地址翻译:把程序使用的虚拟地址转换成物理地址。
  • 权限保护:同时检查页面是否存在、是否允许写入、用户态能否访问、是否允许执行等等。

如果给每一个字节都保存一条映射,页表的大小将难以接受。

因此,页表之所以叫页表,是因为 CPU 将地址空间按照一定大小分块,比较常见页面大小的是 4Kib(0x1000字节),页表按页进行映射。

例如,假设以个虚拟地址 0x401234,页表的大小是 0x1000,因此,虚拟页的起始地址是 0x401000,页内偏移是 0x234。

如果该虚拟页 0x401000 对应了物理页框 0x12345000,那么最终这个虚拟地址对应的物理地址就是 0x12345000 + 0x234 = 0x12345234。

也就是说,对于普通的 4 KiB(0x1000)页面,虚拟页起始地址和物理页框起始地址都必须按照 0x1000 对齐

页表的结构

那么,页表是如何存储这些映射的呢?容易猜想到,页表是一个存在于物理地址上的结构

假如我们的页表,就像一维数组一样,是一个单层结构(仅有一级),因为页表的映射是以 0x1000 为单位对齐的,那么我们可以以 0xaxxx 中的 a 作为数组的下标进行映射。

比如,我们如果有一个虚拟地址 0x8123,得知其偏移是 0x123,物理页框就可以简单用 page_table[8] 来指向。

不过,真实系统通常是多级页表,以常见的 64 位四级页表举例,虚拟地址会被拆成:

  • PML4 索引
  • PDPT 索引
  • PD 索引
  • PT 索引
  • 页内偏移

而只有 PT 指向了最终的页表项(包含了我们想要的物理页框信息),其查询则是顺序查询的:

1
2
3
4
5
6
7
PML4[x]
PDPT[x]
PD[x]
PT[x]

在常见的 x86-64 四级页表中,每一级索引占 9 位二进制,例如 1000 0000 0 0100 0000 0 0010 0000 0 0001 0000 0 0000 0000 0000 这个二进制虚拟地址,我们可以得到:

  • PML4 索引是 1000 0000 0pml4_index = (va >> 39) & 0x1ff;
  • PDPT 索引是 0100 0000 0pdpt_index = (va >> 30) & 0x1ff;
  • PD 索引是 0010 0000 0pd_index = (va >> 21) & 0x1ff;
  • PT 索引是 0001 0000 0,pt_index = (va >> 12) & 0x1ff;
  • 页内偏移是 0000 0000 0000offset = va & 0xfff;

虚拟地址的结构为:

1
2
3
4
5
63       48 47       39 38       30 29       21 20       12 11        0
┌──────────┬───────────┬───────────┬───────────┬───────────┬───────────┐
│规范高位  │ PML4索引  │ PDPT索引  │ PD索引     │ PT索引    │ 页内偏移  │
└──────────┴───────────┴───────────┴───────────┴───────────┴───────────┘
               9位         9位         9位         9位         12位

对于 32 位环境(非 PAE),结构则如下,不再赘述:

1
2
3
4
5
31             22 21             12 11              0
┌────────────────┬─────────────────┬──────────────────┐
│     PD索引     │      PT索引     │     页内偏移      │
└────────────────┴─────────────────┴──────────────────┘
       10位               10位              12位

这时我们得到了通过多级查询,得到了页表项(PTE),页表项主要包含了两个信息:

  • 物理页框基址
  • 标志位

我们知道,在以 0x1000 为页面大小的标准情况中,物理页框的基质后三位必定是 000,也就是说就像我们的堆 size 一样,是有空闲的信息位的,因此,CPU 把空闲的后 12 位利用起来,作为标志位。

同理,对于多级查询的中间页,其指向的下一级的页面的页表项,其后 12 位也是标志位

也就是说,我们通过多级查询,每一级查询前都会先检查这些标志位,只要不满足条件就不会查询成功。

其中主要举五个重要的标志位为例:

  • P(Present)位:bit 0,表示这条表项有效,即其指向的下一张页表/最终物理页面存在
  • R/W(Read/Write)位:bit 1,0 代表只读,1 代表可写,如果要下级某一页可写,那么其所有上级页表项的 RW 都必须是 1,下级不能突破上级限制
  • U/S(User/Supervisor)位:bit 2,1 代表 User 页面,CPL3 可以访问;0 代表 Supervisor 页面,CPL3 不能访问。
  • NX(No Execute)位:bit 63,0 代表允许执行,1代表不允许执行。
  • PS(Page Size)位:bit 7,0 代表指向下一张页表,1 代表直接映射页面。
    • 页面大小由页偏移数决定,如正常是 2^12 = 4KiB,但是如果 PDE 的 PS 位为 1,那么 PTE 的 9 位和原本页内偏移的 12 位就会合并为一个大页面偏移,那么页面的大小则为 2^21 = 2MiB。

虚拟内存空间

在现代操作系统中,计算机的虚拟内存地址空间通常被分为两块——供用户进程使用的用户空间(user space)与供操作系统内核使用的内核空间(kernel space)

对于 Linux 而言,通常位于较高虚拟地址的虚拟内存空间被分配给内核使用,而位于较低虚拟地址的虚拟内存空间责备分配给用户进程使用

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

image

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

image

所以访问虚拟内存空间,其实就是通过虚拟地址和页表去访问物理地址的过程

那么在虚拟内存空间中,我们假如申请一段内存,它在虚拟内存空间中就可以是连续的,虽然在其对应的物理地址可能是间断的,这使内存管理更为方便,也是虚拟内存空间的意义之一。

特权级状态切换

CPU 在不同特权级之间的切换主要有两个途径:

  • 中断与异常(interrupt & exception):当 CPU 收到一个中断 / 异常时,会切换到 ring0,并根据中断描述符表索引对应的中断处理代码以执行。
  • 特权级转换相关指令:当 CPU 运行这些指令时会发生运行状态的改变。

中断与异常

准确的说,CPU 发生中断或异常时,会根据事件对应的中断向量号查询 IDT,读取相应的门描述符,并跳转到描述符指定的处理代码。是否切换到 Ring 0,取决于目标代码段的特权级和当前 CPL。

  • IDT(Interrupt Descriptor Table):中断描述符表,和上述 GDT 和 LDT 一样,只是每一种中断或者异常都对应了一个描述符。
  • 中断向量号:在作用意义上,和 CS、SS 的 Index 部分无异,即 GDT[Index] 和 IDT[中断向量号]。

每一种中断或者异常都对应了一个描述符,比如:

1
2
3
4
#DE 除零异常          → 向量号 0
#UD 非法指令异常      → 向量号 6
#GP 一般保护异常      → 向量号 13
#PF 缺页异常          → 向量号 14

例如,如果用户访问了非法地址,产生了 Page Fault,那么异常向量号等于 14,CPU 就会查询 IDT[14],得到一些信息,比如目标处理地址 RIP(发生该错误则跳转到该目标地址执行相关程序),目标代码段选择子 CS(以此特权级和执行模式执行 RIP 指向的地址)等等。

因此就可以出现这种情况:

发生这个中断或异常的时候,特权级是 Ring3,但是执行这个 RIP 的 CS 的 DPL 是 Ring0,所以在跳转到 RIP 之前,cpu 会实现一次受控转移,把原来的用户态现场保存在新的内核栈上,并实现特权级的转换

特权级转换相关指令

CTF-wiki 中,其写“例如 iret 指令(ring0->ring3)或是 sysenter 指令(ring3->ring0)”,实际上这是传统的 32 位快速系统调用。

我们知道,在用户态 CPL = 3 的情况下,即使知道内核函数的地址,也不能直接使用 jmp,因为这样是不能把特权级调为 Ring0 的,那么页表就会阻止 Ring3 执行。

程序执行的逻辑

假设:

1
2
CS  = 0x33
RIP = 0x401000

那么 CPU 的逻辑大概是:

1
2
3
4
5
6
7
8
9
CS=0x33
查GDT[6]
这是64位代码段
DPL=3
描述符有效
CPU建立Ring 3、64位代码执行环境

然后 RIP = 0x401000 告诉 CPU 从哪个虚拟地址取指令。

Licensed under CC BY-NC-SA 4.0