保护模式学习笔记之分页机制
2022-1-18 18:0:22 Author: mp.weixin.qq.com(查看原文) 阅读量:15 收藏


本文为看雪论坛优秀文章
看雪论坛作者ID:1900

控制寄存器

由于在页机制中,控制寄存器起到了比较大的作用,所以这里首先说一下控制寄存器。
控制寄存器(CR0、CR1、CR2、CR3、CR4如下图)确定了处理器的操作模式和当前正在执行的任务的特征。这些寄存器在所有32位模式和兼容性模式下都是32位的。
这几个控制寄存器的作用如下:
CR0:包含控制处理器的操作模式和状态的系统控制标志。
CR1:保留。
CR2:包含页面故障的线性地址(导致页面故障的线性地址)。当CPU访问了某个无效的就会产生缺页异常,此时CPU就会讲引起缺页异常的线性地址放到控制寄存器CR2中。
CR3:包含分页结构层次结构基础的物理地址和两个标志(PCD和PWT)。只指定基本地址的最重要的位(减去下面的12位);地址的下12位被假定为0。因此,第一个分页结构必须与页面(4-KByte)边界对齐。PCD和PWT标志控制处理器内部数据缓存中的分页结构的缓存(它们不控制页面目录信息的TLB缓存)。
CR4:包含一组启用多个架构扩展的标志,并指示对特定处理器功能的操作系统或执行支持。
对于CR0寄存器比较重要的几个标志是:
PE位:CR0的位0是启用保护标志。当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  处理器工作在开启了分页机制的保护模式下

对于CR4寄存器比较重要的几个标志是:
PAE标志:启用物理页拓展(简称PAE),可以最多寻址64GB物理内存,否则最多寻址4GB物理内存。
PSE标志:用于启用大页面支持。在32位保护模式下,PAE=1时,大页面是2MB,当PAE=0时,大页面是4MB。

页机制

IA处理器从386开始支持分页机制(paging)。分页机制的主要目的是高效利用内存,按页来组织和管理内存空间,把暂时不用的数据交换到空间较大的外部存储器(通常是硬盘)上(称为page out,换出),需要时在交换回来(称为page in,换进)。
在启用分页机制以后,操作系统将线性地址空间划分为固定大小的页面(4KB,2MB,4MB等)。每个页面可以被映射到物理内存或外部存储器上的虚拟内存文件中。尽管原则上操作系统也可以利用段机制来实现虚拟内存,但是因为页机制具有功能更强大,灵活性更高等特点,今天的操作系统大多都是利用分页机制来实现虚拟内存和管理内存空间的。
首先,操作系统在创建进程时,就会为这个进程创建页表,从本质上讲,页表是进程空间的物理基础,所谓的进程空间隔离主要因为每个进程都有一套相对独立的页表,进程空间的切换实质上就是页表的切换。x86处理器中的CR3寄存器便是用来记录当前任务的页表位置的。当程序访问某一线性地址时,CPU会根据CR3寄存器找到当前任务使用的页表,然后根据预先定义的规则查找物理地址。
这个过程中,如果CPU找不到有效的页表项或者发现这次内存访问违反规则,便会产生页错误异常(#PF)。该异常的处理程序通常是操作系统的内存管理器。内存管理器得到异常报告后会根据CR2寄存器中记录的线性地址,讲所需的内存页从虚拟内存加载到物理内存中,并更新页表。做好以上工作以后,CPU从异常处理例程返回,重新执行导致页错误异常的那条指令,再次查找页表。这便是虚拟内存技术的基本工作原理。

32位经典分页

1、预备知识

在此模式下,控制寄存器CR3的结构如图4-4。该寄存器的12~31位(高20位)保存的是页目录的起始地址。因此,第一个分页结构必须与页面(4-KByte)边界对齐。低12位包含分页结构层次结构基础的物理地址和两个标志(PCD和PWT)。PCD和PWT标志控制处理器内部数据缓存中的分页结构的缓存(它们不控制页面目录信息的TLB缓存)。
关于PDE和PTE的内容在下面会有讲解,这里先给出结构中的几个位的含义方便后面讲解:

2、分页模式介绍

当CR0的PG标志为1,CR4的PAE为0时,CPU使用32位经典分页模式。所谓经典模式,是相对于后来的PAE模式而言,它是80386所引入的。此模式为2级页表,在这个模式下,页表结构为两级,第一级称为页目录表,第二级称为页表。
页目录表是用来存放页目录表项(PDE)的线性表。每个页目录占1个4KB内存页,每个PDE的长度为32比特位(4字节),因此每个页目录表中最多包含1024个PDE。由图4-4可知,每个PDE中的内容可能有三种格式,当P位为0,则此时PDE无效,当P为1时,又分为两种一种用于指向4KB的下一集页表,另一种用于指向4MB的大内存页。
当PS等于0,也就是PDE的第七位为0的时候,代表的是4KB内存页,此时高20位代表该PDE所指向页表的起始物理地址的高20位,该起始物理地址的低12位固定为0,所以页表一定是按4KB边界对齐的。
当PS等于1,也就是PDE的第七位为1的时候,代表的是4MB内存页,此时高10位代表的是4MB内存页的起始物理地址高10位,该起始地址的低22位固定为0,因此4MB的内存页一定是按4MB进行边界对齐。
页表用来存放页表表项(PTE)的线性表。每个页表占一个4KB的内存页,每个PTE的长度为32比特位,因此每个页表最多包含1024个PTE。2MB和4MB的大内存页是直接映射到页目录表项,不需要使用页表。由图4-4可知,每个PTE分为两种格式,当P为0,此时PET无效,当P为1时,高20位代表的是4KB的内存页的起始物理地址的高20位,该起始物理地址的低12位假定为0,所以4KB内存页都是按4KB进行边界对齐。
有了前面的基础,下面看一下CPU是如何利用页目录表和页表等数据结构将一个32位的虚拟地址翻译为32位的物理地址的。其过程可以概括为以下的几个步骤:
① 通过CR3寄存器定位到页目录的起始地址,取线性地址的高10位作为索引选取页目录的一个表项,也就是PDE。
② 判断PDE的PS位,如果为1,代表这个PDE指向一个4MB的大内存页,PDE的高10位便是4MB内存页物理地址的高10位,线性地址的低22位是页内偏移。将二者合并起来便得到了物理地址。如果PS位是0,那么根据PDE中的页表基地址(取PDE的高20位,低12位设为0)定位到页表。
③ 取线性地址的12位到21位(共10位)作为索引选取页表的一个表项,也就是PTE。
④ 取出PTE中的内存页基地址(取PTE的高20位,低12位设为0)。
⑤ 取线性地址的低12位作为页中偏移与上一步的内存页基地址相加便得到物理地址。
下图是上述过程的图示:

值得说明的是,页表本身也可能被交换到虚拟内存中,这时PDE的P位会是0,CPU会产生缺页异常,促使内存管理器将页表交换到物理内存。然后在重新执行访问内存的指令和地址翻译过程。与页表不同,每个进程的页目录是永久驻留在物理内存中的。

3、实验练习

接下来通过几个练习来体会上述内容,首先默认情况下Windows系统开起的不是经典32位分页模式,以XP系统为例,要开起经典32位模式只需要打开C盘中隐藏的boot.ini文件。
随后将noexecute改为execute,此时就打开了经典32位模式。

a.翻译过程练习

下面的两段代码会在不同进程的同一虚拟地址申请内存并对这块内存进行不同的赋值。
#include <cstdio>#include <windows.h>  int main(){    PDWORD pDwNum = (PDWORD)VirtualAlloc(NULL, 0x4, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);     if (pDwNum)    {        *pDwNum = 0x1900;        printf("address: 0x%X num:0x%X\n", (DWORD)pDwNum, *pDwNum);    }    system("pause");     return 0;} #include <cstdio>#include <windows.h>  int main(){    PDWORD pDwNum = (PDWORD)VirtualAlloc(NULL, 0x4, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);     if (pDwNum)    {        *pDwNum = 0x1874;        printf("address: 0x%X num:0x%X\n", (DWORD)pDwNum, *pDwNum);    }    system("pause");     return 0;}

结果如下图所示:
两个进程申请到的虚拟地址都是0x003A0000,将对应的二进制分成三部分作为索引,分别是00 0000 0000(0x0),11 1010 0000(0x3A0),0000 0000 0000(0x0)。
接下来就使用WinDbg来分别查询这两个进程申请的虚拟地址的物理页。
test.exe进程CR3为0x1A193000。
将0x1A193000高20位作为PDE表的基地址,将虚拟地址高10位的0x0作为索引找到对应的PDE。
将PDE的第12位清0得到PTE表的基地址,将虚拟地址中间10位最为索引获取对应的PTE。
接着取出PDE并将低12位清0,0x1A35A000作为物理页基地址,并将虚拟地址最后的12位作为偏移在内存中找到保存的0x1900。
demo.exe进程的CR3为0x18A7C000。
同样经历上述过程可以获得保存在内存中的0x1874。
上面的结果可以看到,两个进程都在虚拟地址0x003A0000写入了数据,但是经过分页模式以后,test进程最终保存数据的物理页是0x1A35A000,而demo进程最终保存数据的物理页是0x18C57000。
由此可以得出结论,不同进程之所以可以在同一虚拟地址读写内存,是因为虚拟地址会因为分页模式而映射到不同的物理页上。

b.0地址读写

正常情况下,下面这段代码运行是会出错的,因为程序不能对0地址进行读写。
#include <cstdio>#include <windows.h>  int main(){    PDWORD pDwNum = (PDWORD)0;         system("pause");     *pDwNum = 0x1900;    printf("0x%x\n", *pDwNum);    system("pause");     return 0;}

错误如下:
报错结果是0地址不能读写,根据上面的内容可以知道,想要对一个虚拟地址进行读写,就需要成功将虚拟地址转换为合法的物理地址,之所以出错很有可能是0地址并没有被正确挂载上PDE和PTE。
使用WinDbg来观察虚拟地址为0的时候,PDE和PTE的情况,首先获得test的CR3为0x048A2000。
接着查看对应的PDE和PTE如下:
可以看到PDE是有效的但是PTE是无效的,这也是为什么0地址不能读写的,所以想要让0地址可以读写,就需要为其挂上有效的PDE和PTE。
更改代码如下,下面的代码申请了一块有效的虚拟地址:
#include <cstdio>#include <windows.h>  int main(){    PDWORD pDwNum = (PDWORD)0;    PDWORD pDwNum2 = (PDWORD)VirtualAlloc(NULL, 0x4, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);         if (pDwNum2)    {        *pDwNum2 = 0x1874;        printf("0x%X\n", (DWORD)pDwNum2);        system("pause");         *pDwNum = 0x1900;        printf("0x%x\n", *pDwNum);        system("pause");    }     return 0;}

首先获取test进程的cr3为0x1CCA9000。
首先获取pDwNum2的PDE和PTE。
由输出可知,PDE和PTE分别是0x1CDFE867和0x1CEB6867,接着在将这两个值赋给0地址的PDE和PTE。
此时在查询0地址的PDE和PTE可以看到,此时的0地址已经有了合法的PDE和PTE。
此时程序就可以对0地址进行读写。

c.常量读写

正常情况下下面这段代码的运行是会出错的,因为它对常量区保存的字符串进行更改。
#include <cstdio>#include <windows.h>  int main(){    char *pBuffer = "Hello 1900!";     printf("Buffer Address:0x%X\nBuffer:%s\n", (DWORD)pBuffer, pBuffer);         system("pause");    for (int i = 0; i < strlen(pBuffer); i++)    {        pBuffer[i] = 'A';    }    printf("%s\n", pBuffer);      return 0;}

此时程序会报不可写的错误:
接下来使用上面的方法查看常量区地址的PDE和PTE,首先对地址0x00422FCC进行拆分,得到索引00 0000 0001(0x1) 00 0010 0010(0x022)以及偏移0xFCC。 
查询进程的cr3为0x151C1000。
在使用这个cr3的值来获得物理地址。
在这个过程中发现PDE和PTE分别等于0x16CA5867和0x151BE025,最低4位的二进制分别是0111和0101,根据图4-4可以知道,PDE和PTE的第1个比特位是(R/W)位,该位可以指定对应物理页的读写属性,而物理页的读写属性是由PDE和PTE的属性共同决定的,此时PTE的R/W为0,这就让对应的物理页是只读的物理页,这就是为什么程序对常量区保存的数据进行修改会出错。
所以此时只要使用以下命令修改PTE使其为可读可写。
kd> !ed 0x16CA5088 0x151be027

此时程序就可以正常对常量区的内容进行修改。

d.高位地址读写

正常情况下,运行在用户层的程序是无法读写高2G的内存的。
#include <cstdio>#include <windows.h>  int main(){    PDWORD pNum = (PDWORD)0x8003F00C;     system("pause");    printf("0x%X\n", *pNum);      return 0;}

可以看到程序运行起来以后报了不可读的错误:
依然通过上面的方法来查看情况,首先将地址0x8003F00C拆分出索引10 0000 0000(0x200) ,00 0011 1111(0x03F)与偏移0x00C。使用WinDbg获取进程的CR3为:0x07C56000。
接着查看地址的PDE和PTE:
可以看到虚拟地址0x8003F00C的PDE和PTE分别为0x0003B163和0x0003F163,它们的最低4位都是3,对应二进制为0011,根据图4-4可以知道第2个比特位(U/S位)代表特权级别,如果为0则说明需要ring0权限才可以读取,如果是1则不需要,此时U/S位为0,所以在用户层是无法读取的,想要在用户层对其进行读写,就需要使用下面的命令将U/S位改成1,这样就无需ring0权限才可以读写。
kd> !ed 0x7c56800 0x0003b167kd> !ed 0x3b0fc 0003f167

可以看到,修改以后,成功将地址中的内容读取出来。

PAE分页

上面介绍的32位经典分页模式是由386CPU引入的,是x86架构实现的第一种分页模式。使用这种分页模式,可以将32位的线性地址映射到32位的物理地址。从地址空间的角度来讲,使用这种分页模式时,线性地址和物理地址的空间都是4GB大小。
随着计算机的发展,计算机所配备的物理内存不断增多。为了适应这种发展趋势,于1995年推出的Pentium Pro处理器引入了一种新的分页模式,物理地址的宽度被拓展到36位,可以最多支持64GB物理内存,这种分页模式称为物理地址扩展,简称PAE。相对于上面的32位经典分页模式,PAE分页的主要变化如下:
页目录表项和页表表项都从原来的32位扩展到64位,低12位仍然为标志位,从Bit 12到Bit 35的高24位用来表示物理地址的高24位。这样改变之后,物理地址就从原来的32位扩展到36位,最多可以索引64GB的物理内存。
将原来的二级页表结构改为三级,新增的一级称为页目录指针表(Page Directory Pointer,PDPT)。页目录指针表包含4个64位的表项,每个表项指向一个页目录。每张页目录描述1GB的线性地址空间,4张页目录一起刚好描述4GB的线性地址空间。
CR3寄存器的格式略有变化,低5位保留不用,高27位指向页目录指针表的起始物理地址。
在此模式下的CR3寄存器以及PDPTE,PDE,PTE的结构如图4-7所示,在该模式下PDE和PTE的位数变多,且新增了一个PDPTE结构。

在该模式下的PDE和PTE的最后一位是XD位(在AMD中为NX位),为执行禁用位。当XD=1时,对应的物理页的数据是无法被当成指令执行的。也就是说,Intel为我们做了硬件保护,将相应的物理页面的XD位置1,那么即使攻击者有办法跳转到相应的地址,也无法成功执行指令。

PAE模式下将32位线性地址映射到4KB内存页时的页表结构和映射方法如下图所示。此时32位的线性地址被分割成如下4个部分:

2位(位30和位31)的页目录指针表索引,用来索引本地址在页目录指针表中的对应表项。

9位(位21~29)的页目录表索引,用来索引本地址在页目录表中的对应表项。

9位(位12~20)的页表索引,用来索引本地址在页表中的对应表项。

12位(位0~11)的页内偏移,这与32位经典分页模式是相同的。

该转换过程具体可以由下面两张图体现出来:
结合图4-4,对于PAE分页还有以下几点需要说明:

因为使用CR3表示的页目录指针表的起始地址仍为32位,所以就要求页目标指针表一定要分配在物理地址小于4GB的内存中

因为CR3的低5位保留不用,所以又要求这个起始地址低5位为0,可以被32整除。

与32位分页相比,32位线性地址中的页目录索引位和页表索引位都从10位减少到9位,所以每张页目录表的页表总表项数也由1024项减少为512,同时每个表项大小由4个字节增加到8个字节,所以每张页目录或者页表总大小仍为4KB。总体来看,虽然每张页目录和页表表项数减少一半,但因增加了一级映射,页目录的数量由原来的1张变为最多4张,所以支持的最有大页面数为4*512*512=2^20,即2M个。

通过下面的代码来学习PAE分页下物理地址的查找过程。
首先将变量地址0x0012FF7C分为4个部分,00(0x0) , 0 0000 0000(0x0), 1 0010 1111(0x12F),0xF7C。接着查询test进程的cr3。
根据cr3与最高2位的索引可以查询到需要的PDPTE的值。
将该值低12位清0,获得PDE表的基地址,在使用虚拟地址随后9位的0x0作为索引找到需要的PDE。
将该值低12位清0,获得PTE表的基地址,在使用虚拟地址随后的9位0x12F作为索引找到需要的PTE。
这里最高位为1,则说明XD=1,此时对应的页保存的数据无法当成指令执行,将最高位与低12位清0,得到物理页的基地址,将虚拟地址最低12位的0xF4C作为偏移地址,获得物理地址。
这就是PAE模式下的地址转换过程。

TLB与CPU缓存

1、TLB

由于在地址转换过程中需要经历好几张表的查询,效率比较低。为了提高效率,CPU内部做了一个表,称为TLB表,用来记录相应的虚拟地址和物理地址。TLB表在CPU内部,所以和读写寄存器的速度一样。
TLB的结构如下图:
说明:

ATTR(属性):属性是PDPE PDE PTE三个属性&起来的. 如果是10-10-12就是PDE&PTE。

不同的CPU 这个表的大小不一样。

只要Cr3变了,TLB立马刷新,一核一套TLB。

操作系统的高2G映射基本不变,如果Cr3改了,TLB刷新 重建高2G以上很浪费。所以PDE和PTE中有个G标志位,如果G位为1刷新TLB时将不会刷新 PDE/PTE的G位为1的页,当TLB满了,根据统计信息将不常用的地址废弃,最近最常用的保留。

TLB在X86体系的CPU里的实际应用最早是从Intel的486CPU开始的,在X86体系的CPU里边,一般都设有如下4组TLB:

第一组:缓存一般页表(4K字节页面)的指令页表缓存(Instruction-TLB);

第二组:缓存一般页表(4K字节页面)的数据页表缓存(Data-TLB);

第三组:缓存大尺寸页表(2M/4M字节页面)的指令页表缓存(Instruction-TLB);

第四组:缓存大尺寸页表(2M/4M字节页面)的数据页表缓存(Data-TLB)。

2、CPU缓存

CPU缓存是位于CPU与物理内存之间的临时存储器,它的容量比内存小的多但是交换速度却比内存要快得多。CPU缓存可以做的很大,有几K、几十K、几百K甚至上M的也有。

TLB中保存的是线性地址和对应的物理地址,而CPU缓存中保存的是物理地址和该物理地址中的内容。

在经典分页模式下的CR3寄存器,PDPTE,PDE,PTE中都有PWT和PCD标志位,这两个标志位就是用在CPU缓存中的。
  • PWT=1:写Cache的时候也要将输入写入内存中。

  • PCD=1:禁止某个页写入缓存,直接写入内存。

《软件调试》(第二版)卷一:硬件基础
《英特尔白皮书3-1》

 

看雪ID:1900

https://bbs.pediy.com/user-home-835440.htm

*本文由看雪论坛 1900 原创,转载请注明来自看雪社区

# 往期推荐

1.非华为电脑安装华为电脑管家分析

2.某DEX_VMP安全分析与还原

3.怎样制作一个防止重打包的APK【反脱壳反HOOK】

4.CVE-2019-9081 Laravel5.7 反序列化 RCE复现

5.某火热区块链游戏(mir4)的一次通信协议分析

6.从分析一个赌球APP中入门安卓逆向、开发、协议分析

球分享

球点赞

球在看

点击“阅读原文”,了解更多!


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458419302&idx=1&sn=5f87fdce6006d4364bb002edca1923d5&chksm=b18fb0ec86f839fa77ac93d482805e20e1bcc5f9feb7177e35409a99a57515711d3dd73d9c3f#rd
如有侵权请联系:admin#unsafe.sh