保护模式之-页
页式管理内存
IA处理器从386开始支持分页机制(paging)。分页机制的主要目的是高效利用内存,按页来组织和管理内存空间,把暂时不用的数据交换到空间较大的外部存储器(通常是硬盘)上(称为page out,换出),需要时在交换回来(称为page in,换进)。在启用分页机制以后,操作系统将线性地址空间划分为固定大小的页面 (4KB,2MB,4MB,1GB等)。
每个页面可以被映射到物理内存或外部存储器上的虚拟内存文件中。尽管原则上操作系统也可以利用段机制来实现虚拟内存,但是因为页机制具有功能更强大,灵活性更高等特点,今天的操作系统大多都是利用分页机制来实现虚拟内存和管理内存空间的。
首先,操作系统在创建进程时,就会为这个进程创建页表,从本质上讲,页表是进程空间的物理基础,所谓的进程空间隔离主要因为每个进程都有一套相对独立的页表,进程空间的切换实质上就是页表的切换。
处理器中的CR3寄存器便是用来记录当前任务的页表位置的。当程序访问某一线性地址时,处理器会根据CR3寄存器找到当前任务使用的页表,然后根据预先定义的规则查找物理地址。
在这个过程中,如果CPU找不到有效的页表项或者发现这次内存访问违反规则,便会产生页错误异常(#PF)。该异常的处理程序通常是操作系统的内存管理器。内存管理器得到异常报告后会根据CR2寄存器中记录的线性地址,将所需的内存页从虚拟内存加载到物理内存中,并更新页表。做好以上工作以后,CPU从异常处理例程返回,重新执行导致页错误异常的那条指令,再次查找页表。这便是虚拟内存技术的基本工作原理。
地址
地址分为有效地址
,线性地址
,物理地址
。
有效地址与线性地址
举个例子就可以快速理解:mov eax,dword ptr ds:[0xdeadbeef]
对于这个代码,0xdeadbeef
即为有效地址,而ds.Base + 0xdeadbeef
为线性地址
物理地址
顾名思义也就是内存所在于物理设备上的真实地址。
从线性地址到物理地址的过程正是我们这章节要讨论的重点。
控制寄存器(Control Registers)
上文中提到的CR3寄存器,便是控制寄存器中的一个
控制寄存器一共有6个,分别是CR0 CR1 CR2 CR3 CR4 CR8
。
下图是结构图:
我们一个个来说
CR0
包含系统控制标志,用于控制处理器的运行模式和状态。
我们来说一下他的具体结构。
名称 | 含义 |
---|---|
PE (Protection Enable) | 当 PE = 1 时,处理器运行在保护模式下,可以使用内存保护等功能。当 PE = 0时,处理器运行在实模式下。 |
MP (Monitor Coprocessor) | 当 MP = 1 时,处理器监视协处理器的使用情况,当发生对协处理器的操作时,会触发异常。当 MP = 0 时,处理器不监视协处理器。 |
EM (Emulation) | 当 EM = 1 时,处理器不支持协处理器指令,会将协处理器指令转为软件模拟执行。当 EM = 0 时,处理器支持协处理器指令。 |
TS (Task Switched) | 他需要结合EM、MP来综合看,这里不赘叙,我个人认为不重要 |
ET (Extension Type) | 对于Pentium 4、Intel Xeon、P6系列和Pentium处理器,该标志位被保留且固定为1。而对于Intel386和Intel486处理器,当设置了该标志位时,表示支持Intel 387 DX数学协处理器指令。 |
NE (Numeric Error) | 当 NE = 1 时,处理器会将浮点异常的错误码保存到浮点异常状态寄存器中。 当 NE = 0 时,处理器在浮点异常发生时不保存错误码。 |
WP (Write Protect) | 当 WP = 1 时,R0层的程序无法写入只读内存。 当 WP = 0 时,R0层的程序可以写入只读内存。 |
AM (Alignment Mask) | 当 AM = 1 时,处理器会执行内存对齐检查。当 AM = 0 时,处理器不会执行对齐检查。 |
NW (Not Write-through) | 当 NW = 1 时,处理器执行不通过写缓冲进行写操作,而直接写入内存。当 NW = 0 时,处理器使用写缓冲进行写操作。 |
CD (Cache Disable) | 当 CD 为 1 时,处理器禁用数据缓存。当 CD 为 0 时,处理器启用数据缓存。 |
PG (Paging) | 当 PG = 1 时,处理器启用分页机制。当 PG = 0 时,处理器禁用分页机制。 |
说了这么多,我们来说一下几个关键的
PE是启用保护标志。当PE=1时为保护模式,否则为实模式,这个标志开起段级保护,而并没有页级保护
PG用于启用分页机制,从386开始的所有IA-32处理器都支持该标志。通常,操作系统在启动早期,初始化内存实施,并通过这一位正式启用页机制。
WP对于Intel 8086或以上的CPU,该位为写保护标志,当开起WP位,ring0程序是不可以对只读页面进行写操作。也就是说,当CPL<3的时候,如果WP=0,则可以读写任意物理页,只要线性地址有效。否则,只能读取任意地址,但对于只读页面不能进行写操作。
PG=0且PE=0 | 处理器工作在实地址模式下 |
PG=0且PE=1 | 处理器工作在没有开启分页机制的保护模式下 |
PG=1且PE=0 | 在PE没有开启的情况下 无法开启PG |
PG=1且PE=1 | 处理器工作在开启了分页机制的保护模式下 |
CR1
保留的
CR2
包含页错误线性地址(导致页错误的线性地址)
CR3
页表寄存器
每个任务(程序)都有自己的页目录和页表,页目录表的地址被记录在任务的 TSS 段中。
当操作系统调度任务的时候,处理器就会找到即将执行的新任务的 TSS 段信息,然后把新任务的页目录开始地址更新到 CR3 寄存器中。
CR3寄存器结构如下:
名称 | 含义 |
---|---|
PWT 页级写穿标志位(Page-level Write-Through ) | 用于指定页表是否应用写穿策略 |
PCD 页级高速缓存禁用标志位(Page-Level Cache Disable) | 用于指定页表是否应用高速缓存禁用策略 |
Page-Directory Base | 这些位存储了页目录表(Page Directory Table)的物理地址的高20位,用于指定页目录表的起始物理地址。 |
这里先不过多赘述,后面详细介绍这个寄存器
CR4
其字段意义如下:(值为1时为启用,0为关闭)
- VME:是否允许使用虚拟 8086 模式扩展。
- PVI:保护模式虚拟中断。
- TSD:是否禁用处理器在启动过程中使用时间戳计数器。
- DE:是否允许调试扩展。
- PSE:是否启用超级页(2MB页)
- PAE:是否启用物理地址扩展,支持大于4GB的物理内存。
当 PAE = 1 时,是 2-9-9-12 分页。
当 PAE = 0 时, 是 10-10-12 分页。 - MCE:是否启用机器检查异常。
- PGE:是否启用全局页表。
- PCE:是否启用性能监控计数器。
- OSFXSR:是否允许操作系统使用 FXSAVE 和 FXRSTOR 指令进行 XMM 寄存器的保存和恢复。
- OSXMMEXCPT:是否允许操作系统使用 SSE 指令集的浮点异常。
- UMIP:是否防止用户模式下执行特权指令。
- LA57:IA-32e模式下是否开启5级页表(1时为5级,0时为4级)
- VMXE:是否启用虚拟机扩展。
- SMXE:是否启用安全模式扩展。
- FSGSBASE:是否启用 FS、GS 寄存器的基址存储。
- PCIDE:是否启用 PCID(Process Context Identifier)。
- OSXSAVE:是否启用 XSAVE 和 XRSTOR 指令。
- KL:是否启用 LOADIWKEY 指令
- SMEP:是否启用用户模式执行保护。
- SMAP:是否启用内核空间和用户空间地址映射的严格分离。
- PKE:是否启用 4 级分页和 5 级分页将每个用户模式线性地址与保护密钥相关联。
- CET:是否启用控制流强制技术。
- PKS:是否为特权模式页面启用保护密钥。
- UINTR:是否启用用户中断。
CR8
个人认为在这里没啥用 更多信息详见《intel白皮书》
提供对任务优先级寄存器 (TPR) 的读写访问。它指定操作系统用来控制允许中断处理器的外部中断优先级的优先级阈值。该寄存器仅在 64 位模式下可用。然而,中断过滤继续适用于兼容模式
32位下
在32位下,我们分为2种分页方式。
32位经典分页
10-10-12分页
这样分页用的内存是4K的页
首先我们为什么称之为10-10-12分页,代表着把一个32位的线性地址(虚拟地址),按10位,10位,12位的方式切分。并分别代表着3个含义。
如图所示:
显然的,高10位为PDT偏移(获取PDE),中10位为PTT偏移(获取PTE),后12位为物理地址偏移。
什么是PDT,PDE,PTT,PTE呢?
如图所示:
我们先来看看字段含义
名称 | 位 | 含义 |
---|---|---|
P | 0 | 物理页面是否存在,1表示存在 |
R/W | 1 | 读写权限,0代表只读,1代表可读写 |
U/S | 2 | 特权级别,0代表管理权限,1代表用户权限 |
PWT | 3 | 页面级别写入(Page Write Through)。PWT=1时 写Cache的时候也要将数据写入内存中 |
PCD | 4 | 禁用页面级缓存(Page Cache Disable);PCD=1时,禁止某个页写入缓存,直接写内存 |
A | 5 | 已访问;指示此条目是否已用于线性地址转换 |
D/ignore | 6 | 忽略 |
PS | 7 | 页大小,0表示4KB,1表示4MB |
G | 8 | 全局页 |
PAT | 7或12 | 页属性表中的索引 |
了解完字段后,具体说说PDE以及PTE,在这里只关注红框标记的,其中代表了10-10-12分页时PDE与PTE结构。
PDE(Page-Directory Entry)
顾名思义,是页目录项的意思,PDE在内存中是呈一个表状,PDT(Page-Directory Table)即为页目录(存放PDE的表)。
CR3寄存器的高20位组成的指针指向PDT
PTE(Page-Table Entry)
顾名思义,是页表项的意思,PTE在内存中是呈一个表状,PTT(Page-Table Table)即为页表(存放PTE的表)。
PDE的高20位组成的指针指向PTT
过程如图所示:
10-22分页
这样分页用的内存是4MB的页
如图所示,高10位依然是与CR3中的PDT的偏移,通过它可以获取到PDE,此时PDE结构不一样,参考上文中,PDE:4MB page
时的结构,加上后22位作为偏移找到物理地址。
页表基址
在32位下,页表基址是一个固定的地址。
- 页表被映射到了从0xC0000000~0xC03FFFFF的4M地址空间
- 在这1024个表中有一张特殊的表:页目录表
- 页目录被映射到了0xC0300000开始处的4K地址空间
- 访问页表的公式:0xC0000000 + PDI* 4096 + PTI* 4(I=index)
PAE分页
经典32位无论怎么分页,都最多只能调用4GB内存1024 * 1024 * 4096
(4K页),1024 * 2 ^ 22
(4M页)。
随着时代的发展于1995年推出的Pentium Pro处理器引入了一种新的分页模式,物理地址的宽度被拓展到36位,可以最多支持64GB物理内存,这种分页模式称为物理地址扩展,简称PAE。
通过上图我们可以发现,CR3的长度被扩展为了64位,其中存储了PDPTT(页目录指针表),多了一个PDPTE(windbg中叫PPE)(Page-Directory-Point-Table Entry,页目录指针表项),同时多了个字段叫XD
(AMD下叫NX),其为1代表物理页数据无法执行,0则可以执行。
- 与32位分页相比,32位线性地址中的页目录索引位和页表索引位都从10位减少到9位,所以每张页目录表的页表总表项数也由1024项减少为512,同时每个表项大小由4个字节增加到8个字节,所以每张页目录或者页表总大小仍为4KB。总体来看,虽然每张页目录和页表表项数减少一半,但因增加了一级映射,页目录的数量由原来的1张变为最多4张,所以支持的最有大页面数为
4*512*512=2^20
,即2M个。
2-9-9-12分页
这样分页用的内存是4K的页
我们使用伪码描述一下就是:
1 | PDPTE = Cr3().PDPTT[LinearAddress[30:31]] |
2-9-21分页
这样分页用的内存是2M的页
我们使用伪码描述一下就是:
1 | PDPTE = Cr3().PDPTT[LinearAddress[30:31]] |
64位下
现在大多数64位CPU仅有48跟地址线
在64位下,有4级页表和5级页表2种分页方式,现常用4级。
故此后文都用4级页表说明
变化
从图中可以看出,多了个PML4Table、PML4E(windbg中叫PXE)、Prot Key(保护密钥这玩意是在上文中指出CR4寄存器的PKE字段控制开启的)。
9-9-9-9-12分页
这样分页用的内存是4K的页
从图中可以看出线性地址的高9位则为了PML4T的偏移,而后面则与32位的3级页表转换过程类似。
9-9-9-21分页
这样分页用的内存是2M的页
从图中我们可以看出,其类似32位下2-9-21分页,不过也是多了个PML4T的偏移
9-9-30分页
这样分页用的内存是1G的页
从图中我们可以看出,其在PDPTE时就解析出来了物理地址的表,用30位的偏移去寻找物理地址。
小结
其中需要注意一个x64线性地址通常具有64位,而在使用分页管理内存时,则直接忽视掉线性地址的高16位。
实验
让我们用上一章写的测试程序获取一下DS寄存器的值。
1 | GDT base=0xfffff8534b590000 limit=0x007f |
然后先获取一下指向这个段的虚拟地址:
1 | 1: kd> dq gdtr |
ffffb501b1146fd0
即为指向DS段描述符的地址,我们获取CR3并开始转换。
1 | 1: kd> r cr3 |
首先将虚拟地址开始拆分,11111111 11111111 10110101 00000001 10110001 00010100 01101111 11010000
其中高16位直接舍弃。
然后高9位则为10110101 0 -> 0x16A
,我们需要根据偏移去PXE中获取数据。
1 | 1: kd> !dq 1ad000 + 16A * 8 |
当获取了PDPTT的地址后,使用偏移0000001 10 -> 0x6
,获取PPE:
1 | 1: kd> !dq 04c31000 + 6 * 8 |
然后继续跟进,使用偏移110001 000 -> 0x188
,获取PDE:
1 | 1: kd> !dq 04c32000 + 188 * 8 |
继续跟进,使用偏移10100 0110 -> 0x146
,获取PTE:
1 | 1: kd> !dq 025c7000 + 146 * 8 |
此时,便可以通过PTE中的物理页地址加上偏移1111 11010000 -> 0xFD0
,获取到物理地址进而读取数据。
1 | 1: kd> !dq 014fb000 + FD0 |
并且可以方便的看一下我们是否过程正确,我们使用!pte
指令即可查看:
由此可见我们的过程是正确的
注:这里手动转换的过程是读取的物理地址,而!pte指令给出的则是虚拟地址
页表基址
在启动时,所有PTE从一个驱动时的随机地址(g_PTE_BASE)开始顺序排列,我们可以通过查询0地址的pte来获取它。
此时FFFF868000000000
便是PTE_BASE。
那么我们如何获得一个虚拟地址的PTE呢?我们可以使用如下公式:(((addr & 0xffffffffffff) >> 12) << 3) + PTE_BASE
注:addr & 0xffffffffffff其实就是取低48位
那么我们又如何得知PDE的基址呢,我们就PTE基址当作虚拟地址,使用上述公式即可得到。
那么PPE、PXE我们就可以以此类推。
我们可以通过如下代码,实现一个获取虚拟地址PTE、PDE、PPE、PXE的功能。
1 | PULONG64 GetPxeAddr(PVOID addr) { |
定位
定位一个随机的页表基址是一个重要的问题在x64下,一般使用MmGetVirtualForPhysical
、MmIsAddressValidEx
、MmMapViewInSystemCache
等使用了PTE基址的函数中的硬编码获取PTE基址。
- MmGetVirtualForPhysical
- MmIsAddressValidEx
- MmMapViewInSystemCache
其中硬编码的PTE基址会随着系统启动而被修改,每次值不一定一样
TLB
由于在地址转换过程中需要经历好几张表的查询,效率比较低。为了提高效率,CPU内部做了一个表,称为TLB表,用来记录相应的虚拟地址和物理地址。TLB表在CPU内部,所以和读写寄存器的速度一样。
TLB的结构如下图
其中:
ATTR(属性):属性是PDPE PDE PTE三个属性&起来的. 如果是10-10-12就是PDE&PTE
不同的CPU TLB的大小不一样
G位为1的页,当TLB写满时,CPU根据统计信息将不常用的地址废弃,保留最常用的地址
只要Cr3变了,TLB立马刷新,一核一套TLB
TLB在X86体系的CPU里的实际应用最早是从Intel的486CPU开始的,在X86体系的CPU里边,一般都设有如下4组TLB:
- 第一组:缓存一般页表(4K字节页面)的指令页表缓存(Instruction-TLB)
- 第二组:缓存一般页表(4K字节页面)的数据页表缓存(Data-TLB)
- 第三组:缓存大尺寸页表(2M/4M字节页面)的指令页表缓存(Instruction-TLB)
- 第四组:缓存大尺寸页表(2M/4M字节页面)的数据页表缓存(Data-TLB)
KPTI
即内核页表隔离,表现为用户层有个CR3,内核层也有个CR3。
这使得用户层即使通过IDT提权后,也无法读取到内核层的信息(除了KVASCODE和GDT,IDT)
在win10下使用IDT提权需要关闭SMAP与SMEP,可以通过修改CR4来做到
我们可以分析一下微软是怎么做的这个事情,我们首先获得3号中断的函数名。
1 | 0: kd> !idt 3 |
转到ida中查看。
其中可以发现,使用swapgs
先将gs指向_KPCR,然后判断是否开启了KPTI,如果开启了则使用内核的CR3给当前CR3寄存器,相当于把内核CR3给了当前CR3寄存器中,接着将rsp设置为内核栈。
1 | mov rsp, gs:9000 ;_KPCR.Prcb.KernelDirectoryTableBase |
此处为了使用rsi寄存器,将其内容放到了_KPCR.___u0.NtTib.StackLimit
中,后面又在用完后将其清0。
1 | mov gs:_KPCR.___u0.NtTib.StackLimit, rsi |
然后使用Idt表后面的一部分地址(此处是idtbase + 0x41D8)充当这个内核线程的存储空间,依次存入rip
,cs
,eflags
,rsp
,ss
。
1 | mov rsi, gs:_KPCR.IdtBase |
虽然图中没体现出,但是在函数的最后,依然是使用swapgs
和iretq
进行退出。
参考资料
- 标题: 保护模式之-页
- 作者: moshui
- 创建于 : 2024-08-16 11:48:02
- 更新于 : 2025-01-07 11:21:06
- 链接: https://www.moshui.eu.org/2024/08/16/WinProtect-page/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。