前言
linux kernel pwn 在 32 位和 64 位的环境下有一定区别,本文无特别提示均以 64 位环境为前提。
基础知识
可能需要了解的一些小知识。
操作系统内核
操作系统内核本质也是一种软件。
其可以看作是普通应用程式与硬件之间的一层中间层,其主要作用便是调度系统资源、控制 IO 设备、操作网络与文件系统等,并为上层应用提供便捷、抽象的应用接口。
比如,应用程序想要读取一个文件:
| |
实际的路径应该如下:
| |
不过需要注意的是,上图的结构只是为了方便理解所谓“中间层”的功能关系,在内存意义上,内核只是我们抽象出的一个概念,本质与用户进程无异,都是位于物理内存中的代码 + 数据。
也就是说,应用页面与内核页面,在物理内存中完全可能是相邻或者交叉的。
内核态和用户态的不同之处主要在于,往往 CPU 执行操作系统内核代码时拥有近乎完全的硬件访问能力,处于高权限,而执行用户态代码则处于低权限环境,受限较多。
CPU 分级
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字节)的二进制数,其包含的描述符信息指向了当前代码段/栈的性质,段选择子的结构如下:
| |
Index = selector >> 3;,查描述符表的第几项。TI = (selector >> 2) & 1;,查哪个描述符表,0 表示查 GDT,1 表示查 LDT。RPL = selector & 3;,选择子的请求特权级。
比如常见的 CS = 0X33:
Index = 0x33 >> 3 = 6TI = 0- 即查
GDT[6]
- 即查
RPL = 3,选择子的请求特权级为 3。
GDT 与 LDT、描述符
GDT(Global Descriptor Table,全局描述符表)与 LDT(Local Descriptor Table,局部描述符表)可以理解为一个数组,其内放的元素是描述符。
普通的代码段和数据段的描述符是一个 8 字节大小的,描述了段信息的数。
其信息主要包括该代码段的位置、大小、类型、权限和执行模式。
举个例子:
假如内存中有一段内核代码:
| |
那么 GDT 中就会有一段描述符,记录了类似:
| |
也就是说,描述符表仅仅存放了“代码如何被使用”的描述符,而并不存放代码本身。
页表、虚拟地址和物理地址
以上我们了解到特权级和相关的执行模式,那么我们要执行一段内核代码,还需要知道它的地址。
那么我们首先要了解虚拟地址和物理地址的概念:
- 虚拟地址:程序指令里出现的地址,通常是虚拟地址。例如
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 指向了最终的页表项(包含了我们想要的物理页框信息),其查询则是顺序查询的:
| |
在常见的 x86-64 四级页表中,每一级索引占 9 位二进制,例如 1000 0000 0 0100 0000 0 0010 0000 0 0001 0000 0 0000 0000 0000 这个二进制虚拟地址,我们可以得到:
- PML4 索引是
1000 0000 0,pml4_index = (va >> 39) & 0x1ff;。 - PDPT 索引是
0100 0000 0,pdpt_index = (va >> 30) & 0x1ff;。 - PD 索引是
0010 0000 0,pd_index = (va >> 21) & 0x1ff;。 - PT 索引是
0001 0000 0,pt_index = (va >> 12) & 0x1ff;。 - 页内偏移是
0000 0000 0000,offset = va & 0xfff;。
虚拟地址的结构为:
| |
对于 32 位环境(非 PAE),结构则如下,不再赘述:
| |
这时我们得到了通过多级查询,得到了页表项(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 位下的虚拟内存空间布局:
64 位下的虚拟内存空间布局:
所以访问虚拟内存空间,其实就是通过虚拟地址和页表去访问物理地址的过程。
那么在虚拟内存空间中,我们假如申请一段内存,它在虚拟内存空间中就可以是连续的,虽然在其对应的物理地址可能是间断的,这使内存管理更为方便,也是虚拟内存空间的意义之一。
特权级状态切换
CPU 在不同特权级之间的切换主要有两个途径:
- 中断与异常(interrupt & exception):当 CPU 收到一个中断 / 异常时,会切换到 ring0,并根据中断描述符表索引对应的中断处理代码以执行。
- 特权级转换相关指令:当 CPU 运行这些指令时会发生运行状态的改变。
中断与异常
准确的说,CPU 发生中断或异常时,会根据事件对应的中断向量号查询 IDT,读取相应的门描述符,并跳转到描述符指定的处理代码。是否切换到 Ring 0,取决于目标代码段的特权级和当前 CPL。
- IDT(Interrupt Descriptor Table):中断描述符表,和上述 GDT 和 LDT 一样,只是每一种中断或者异常都对应了一个描述符。
- 中断向量号:在作用意义上,和 CS、SS 的 Index 部分无异,即 GDT[Index] 和 IDT[中断向量号]。
每一种中断或者异常都对应了一个描述符,比如:
| |
例如,如果用户访问了非法地址,产生了 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 执行。
程序执行的逻辑
假设:
| |
那么 CPU 的逻辑大概是:
| |
然后 RIP = 0x401000 告诉 CPU 从哪个虚拟地址取指令。
