保护模式
保护模式
ZEROKO14Q:什么是保护模式?
A:x86 CPU的3个模式:
- 实模式
- 保护模式(虚拟8086模式)
- 系统管理模式
现在的操作系统大多数都是运行于保护模式下的
保护模式就是指给操作系统添加的保护特性,保护的目标是硬件资源和OS内核。
学习前的环境配置
由于本人使用的操作系统是win10 1909,因此开发当前版本的驱动需要vs2019+Windows 10 WDK 2004(10.0.19041.1) + Windows 10 SDK 2004(10.0.19041.1)
或者10.0.18362.1版本,vs安装要设置
C++的桌面开发,通用Windows平台开发(勾选Windows 10 SDK(10.0.18362.0)),Visual Studio拓展开发,完事了wdk也安装10.0.18362版本
要注意在单个组件中选上:
环境配置的参考网址
【小注意点】微软官网下载vs2019时有bug,必须等他一段时间弹出下载,不要再点重新下载,不然重新下载的是vs2017,而不是vs2019
下载完毕后,再到微软官网下载Windows 10 WDK 2004(10.0.19041.1)。
至此vs2019+Windows 10 WDK 2004(10.0.19041.1) + Windows 10 SDK 2004(10.0.19041.1)环境就装好了。
双机调试配置
由于系统调试要下断点,下断点后系统将只有调试子系统继续运行,因此不能直接调试本机的系统。因此需要双机调试配置
配置双机调试流程
在本机安装windbg(上述配环境的时候装WDK,里面自带有windbg)
在虚拟机中(WinXP)修改boot.ini(修改系统启动项)
C盘显示隐藏文件,找到boot.ini打开
原内容:
新内容
红线为添加行,
/debug表示调试模式,第一段黄标为名字,可以随便起;/debugport=com2表示指定的调试串口为com2,下图可见原本只有com1一个串口(黄标)。com2是为后续步骤虚拟机设置步骤中添加的串口自己添加的启动项如下图黄标
设置虚拟机(为虚拟机新增一个串口设备)
添加步骤:
P.s.打印机也会占用一个串口
添加好后查看设备管理器:
黄标为我们刚添加的新串口。
修改Windbg运行参数,指向虚拟机。
Windbg的选择简单参考:调试机器是32位系统就用32位windbg,64位系统就64位windbg(实则更为复杂)
p.s. WDK,Windows Driver Kit,用于开发驱动的驱动开发包
准备工作做好后
开始双机调试
必须在下图界面的时候先别按回车
本机上打开设置好的windbg,显示正在连接,再到虚拟机中点击回车系统进入调试模式。
此时成功断下,效果如下:
此时系统是断下状态,因此是类似黑屏
此时在windbg命令行中输入g,表示让系统继续执行
之后还想中断的话,按下图按钮。
至此双机调试配置成功!
保护模式学习的时候尽量虚拟机要设置为单核,防止干扰。
WinDbg的退出
通过 q 或者 ALT + F4 退出调试并销毁被调试进程:
1 | q |
通过 qd 退出调试,但被调试进程继续运行:
1 | qd |
部分使用方式
一些细节:
- 8086CPU不支持将数据直接送入寄存器的操作,
mov ds, 1000H这条指令是非法的。想要将1000H送入DS,需要使用一个寄存器进行中转,先将1000H送入一个一般的寄存器,再将这个寄存器中的内容送入DS - 8086的入栈和出栈操作都是以 字 为单位进行的
- 栈空的时候
SS:SP指向栈空间最高地址单元的下一个单元,如果把10000H-1000FH这段空间当作栈,初始状态栈是空的的时候SS:SP=1000:0010 - 用
xor ax, ax或者sub ax, ax而不是mov ax, 0来将ax清零的主要原因是前两个(在32位下)的机器码是3个字节,而mov ax,0的机器码是4个字节。 - 执行
push时,CPU要进行两步操作:先改变SP,后向SS:SP处传送。执行POP时,CPU先读取SS:SP处的数据,然后改变sp。 - 一个栈段最大64K(在8086CPU环境下),因为栈顶的变化范围是
0-FFFFH。如果一直压栈的话栈顶将环绕,覆盖原来栈中的内容 - 在汇编源程序中,数据不能以字母开头,所以要在前面加0
- Q:为什么不直接学习x64
- A:x86是由Intel推出的一种复杂指令集,能够生产支持这种指令集。CPU公司主要是Intel和AMD。AMD在1999年的时候,拓展了这套指令集,称为x86-64,后改名为AMD64,Intel也兼容了这个产品,称为Intel 64。但AMD64和Intel64几乎是一样的,所以在很多资料中统称为x64。而这套指令集是对x86的拓展,向下兼容的。
保护模式有什么特点?
- 段的机制
- 页的机制
通过这两种机制来达到保护系统的一些数据结构,还有一些关键的寄存器的目的。
- Q:学习保护模式有什么用?
- A:真正理解内核是如何运作的
参考书:**<Intel 白皮书第三卷>**,3,4,5,6,7章
p.s.
- Intel白皮书第二卷是查指令的
- Intel 白皮书第三卷是讲保护模式的
保护模式–段
保护模式的2种重要机制:
- 段
- 页
保护模式知识结构总览:
段的机制非常复杂,想了解段的机制要先了解段寄存器。
为何需要段的机制?16位系统的寄存器为16位二进制,只可以寻址64KB(2的16次方除以1024等于64)的大小内存,左移四位再加一千来索引1MB内存,因此出现了段的机制可以解决这个问题。
由于32位系统寄存器是32位,所以上述功能(base的功能)已经被弱化了,仅留下重要的是权限检查机制。
32位以后,现在除了FS寄存器以外,其他段寄存器的base字段已经全部设置为0了
段寄存器结构
段寄存器有哪些
ES CS SS DS FS GS LDTR TR GDTR IDTR等等
代码段寄存器CS(Code Segment)
存放当前正在运行的程序代码所在段的段基址,表示当前使用的指令代码可以从该段寄存器指定的存储器段中取得,相应的偏移量则由IP提供。
数据段寄存器DS(Data Segment)
指出当前程序使用的数据所存放段的最低地址,即存放数据段的段基址。
堆栈段寄存器SS(Stack Segment)
指出当前堆栈的底部地址,即存放堆栈段的段基址。
附加段寄存器ES(Extra Segment)
指出当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段。
段寄存器的结构
1 | struct SegMent |
我们能看到的段寄存器的值只有可见部分的16位。
段寄存器中有16位是可见部分,有八十位是不可见部分,一共92位。
可见部分为16位的Selector部分
12位的Attribute为这个段寄存器的属性,它的意义为:表示该段寄存器是可读还是可写还是可执行的。
32位的Base表示该段是从哪里开始的。
32位的Limit表示整个段的长度有多少。
段寄存器的读写
读段寄存器
比如:MOV AX,ES 读的时候,只能读16位的可见部分(必须写rX,不能写其他如ErX)
读写LDTR的指令为:SLDT/LLDT
读写TR的指令为:STR/LTR
写段寄存器
比如:MOV DS,AX 写时是写92位
段寄存器属性探测
| 段寄存器 | Selector | Attribute | Base | Limit |
|---|---|---|---|---|
| ES | 0023 |
可读可写 | 0 | 0xFFFFFFFF |
| CS | 001B |
可读可执行 | 0 | 0xFFFFFFFF |
| SS | 0023 |
可读可写 | 0 | 0xFFFFFFFF |
| DS | 0023 |
可读可写 | 0 | 0xFFFFFFFF |
| FS | 003B |
可读可写 | 0x7FFDE000 |
0xFFF |
| GS | - | - | - | - |
上图中有这个*标记*的表示他在不同操作系统的该值不一致
GS在windows中不使用,所以用短横杠填写。
上图的原因是ds段寄存器此时实际上是cs寄存器,cs寄存器不可写。
把cs换成es就不会报错,因为es段是可写的
1 | int var=0; |
写一个段寄存器的时候我们只给了16位,那么剩下的76位来自于哪里呢?
段描述符与段选择子
保护模式下三个重要的系统表——GDT、LDT和IDT
- GDT全局描述符表
- LDT局部描述符表(在windows中没有使用)
- 中断描述符表 IDT
当我们执行类似MOV DS,AX指令时,CPU会查表,根据AX的值来决定查找GDT还是LDT,查表的什么位置,查出多少数据。
gdtr是一个寄存器(48位),存储的是表的开始位置(32位)和长度(16位)
GDTR寄存器中存放的是GDT在内存中的基地址和其表长界限。
前16位是保存GDT里面的限长,后17到42位保存的是段基址
ldtr和gdtr有区别,存的是段选择子。
汇编指令LGDT和SGDT分别用于加载和保存GDTR寄存器的内容。
1 | kd>r gdtr//查看gdtr寄存器,gdt这样表在哪里。(r表示查看寄存器) |
图中dd命令罗列出来的就是GDT表。
剩下的76位来自于GDT表,表里存储的元素我们叫段描述符
段描述符
每个段描述符是8个字节,即64位。
黄色荧光笔标记出来的是图解中的低32位。
dq是按照qword(8字节)来分组,后接的L40表示显示0x40组。
段选择子
段选择子是一个16位的段描述符,该描述符指向了定义该段的段描述符。
写段寄存器的时候写的就是段选择子。
RPL:请求特权级别
TI:
- TI=0查GDT表
- TI=1查LDT表(Windows没有使用)
INDEX: 处理器将索引值乘以8再加上GDT表的基址,就是要加载的段描述符
加载段描述符到段寄存器
除了MOV指令,我们还可以使用LES,LSS,LDS,LFS,LGS指令修改寄存器(L表示load,LES表示load ES即加载ES段寄存器)
CS不能通过上述的指令进行修改,CS为代码段,CS的改变会导致EIP实际指向的代码的改变,要改变CS,必须要保证CS与EIP一起改,后面会讲。
LES的使用案例
1 | char buffer[6]; |
段描述符的属性
段寄存器的值是通过段描述符填充的
但段描述符只有64位,如何从64位变成76位(76位是92位撇去段选择子的16位)
LIMIT多了12位。(参考下方G位的讲解)
1 | struct SegMent |
- Attributes对应的是高4字节从第8位开始到第15位,第20位开始到23位结束共12位。
- Base是【高4字节的24
31位】+【高四字节的第07位】+【低四字节的16~31位】共同组成的32位 - Limit是【高4字节的16
19位】+【低4字节的015】确定了20位,剩下的12位依赖G位(下有详情)
P位和G位
P位
- P=1 表示段描述符有效
- P=0 表示段描述符无效
当我们将一个段描述符加载到段寄存器的时候,CPU做的第一件事就是检查P位,如果P为0,后续的检查就不做了,若P为1,后续的检查才做。
G位
- G=0 表示limit的单位是字节。在前面填充0x000补齐32位,此时LIMIT的值就是0x000XXXXX的字节大小。
- G=1 表示limit的单位是4Kb,4kb的地址为0~0xFFF(十进制4095),如果是G=1的话,在后面填充0xFFF。此时LIMIT为0xXXXXXFFF
p.s. FS对应的段描述符比较特殊,查分后的值与段寄存器中的值不符合,讲到操作系统(线程)的时候会有解释。
S位和TYPE域
S位
当我们将一个段描述符加载到段寄存器的时候,CPU做的第一件事就是检查P位,第二件事就是判断该段描述符是[数据或代码段描述符]还是[系统段描述符]
- S=1 代码段或者数据段描述符
- S=0 系统段描述符
P位为1,并且S位为1,DPL只有两种情况(即要么是全0,要么全1),所以可知GDT中,下图标黄部如果是9(二进制1001)或者F(二进制1111)表示此段是代码段或者数据段描述符。
TYPE域
type域的含义根据S位来变化
- 当S=1时
TYPE域中的第一位,也就是段描述符高4字节的第11位,区分该段到底是代码段还是数据段。如果有是1,则表示代码段,如果是0,表示数据段。
SS与FS都属于数据段。
这意味着下图标黄的16进制位,如果大于等于8,则表示是代码段,否则是数据段。
A 访问位:表示该位最后一次被操作系统清零后,该段是否被访问过。每当处理器将该段选择符置入某个段寄存器时,就将该位置1。
C 表示一致位
- C=1 一致代码段
- C=0 非一致代码段
E 拓展方向
- E=0 向上拓展
- E=1 向下拓展
图中橙色部分表示段所在位置。向上拓展是从fs.Base到fs.Base+Limit的区间,而向下拓展是该区间取反的区间
- 当S=0时
D/B位
D/B位对三种段有影响。
对CS段的影响
D=1 采用32位寻址方式
D=0 采用16位寻址方式
(前缀67 改变寻址方式 方便观察16位寻址方式是什么样的)
对SS段的影响(数据段的段描述符加载到SS段里他就是SS段了,但本质还是数据段)
D=1 隐式堆栈访问指令(如:PUSH POP CALL)使用32位堆栈指针寄存器ESP
D=0 隐式堆栈访问指令(如:PUSH POP CALL)使用16位堆栈指针寄存器SP
向下拓展的数据段
D=1 向下拓展段上限为4GB
D=0 向下拓展段上限为64KB
红色表示该段的区间。D=0表示上图右侧,两块红色相加为64KB。
AVL属性
AVL属性占1个比特,该属性的意义可由操作系统、应用程序自行定义。
Intel保证该位不会被占用作为其他用途。
段权限检查(CPL,RPL,DPL)
段选择子加载到段寄存器中,要进行段权限检查。
CPU分级
windows只使用了三环和零环。
R3与R0的理解
R0不能调用R3的函数,但是R0可以访问R3的内存空间,因此可以把函数拷贝到R0的内存空间再通过函数指针的方式调用,还要切换CR3,涉及后续课程知识。
如何查看程序处于几环
CPL(Current Privilege Level):当前特权级
段选择子后2位(段选择子后两位为RPL,但CS和SS的段选择子的后两位比较特殊,表示CPL):当前环数。
x86规定了CS和SS的后两位一定是一样的。
DPL(Descriptor Privilege Level):描述符特权级别
DPL存储在段描述符中,规定了访问该段所需要的特权级别是什么
通俗的理解:
如果你想访问我,那么你应该具备什么特权。
举例说明:
mov DS,AX
如果AX指向的段DPL = 0 但当前程序的CPL = 3 这行指令是不会成功的(因为权限检查的时候通不过)
RPL(Request Privilege Level):请求的特权级别
RPL是针对段选择子而言的,每个段的选择子都有自己的RPL
数据段的权限检查
参考如下代码:
1 | //比如当前程序处于0环,也就是说CPL=0 |
数据段的权限检查流程
$$
CPL <= DPL 并且 RPL <= DPL (数值上的比较)
$$
由于上面代码中RPL>DPL,所以权限检查无法通过。
注意:
代码段和系统段描述符中的检查方式并不一样,具体参考后面课程。(上面仅为数据段的权限流程检查)
词汇总结
- CPL CPU当前的权限级别
- DPL 如果你想访问我,你应该具备什么样的权限
- RPL 用什么权限去访问一个段
代码跨段
本质就是修改CS段寄存器
段寄存器:
ES,CS,SS,DS,FS,GS,LDTR,TR
段寄存器读写:除CS,LDTR,TR外,其他的段寄存器都可以通过MOV,LES,LSS,LSD,LFS,LGS指令进行修改。
CS为什么不可以直接修改呢?
CS(代码段)的改变意味着EIP的改变,改变CS的同时必须修改EIP,所以我们无法使用上面的指令来进行修改。
代码间的跳转(段间跳转 非调用门之类的)
段间跳转,有2种情况,即要跳转的段是一致代码还是非一致代码段。
- 同时修改CS与EIP的指令:
JMP FAR/CALL FAR/RETF/INT/IRETD/IRET - 【注意】只修改EIP的指令:
JMP/CALL/JCC/RET
p.s.以前IRETD/ IRET`这两个指令前者是32位,后者是16位的,但现在根据D/B位已经没有区别了,默认都是32位。
执行流程
1 | JMP 0x20:0x004183D7 --CPU如何执行该指令(长跳转/段间跳转) |
段选择子拆分
1
2
3
40x20 对应二进制形式 0000 0000 0010 0000
RPL=00
TI=0
Index=4查表得到段描述符
1
2(1)TI=0,所以查GDT表
(2)Index=4,所以找到对应的段描述符(四种情况的段描述符才可以跳转:代码段,调用门,TSS任务段,任务门)权限检查
1
2如果是非一致代码段,要求:CPL == DPL 并且 RPL <= DPL(数值上)
如果是一致代码段,要求:CPL >= DPL(数值上) 一致代码段(又名共享段)的理解:这个代码段是提供一些功能让应用层直接可以访问而不破坏内核的代码,就用一致代码段来修饰。
反之不想应用层访问,就用非一致代码段修饰。
加载段描述符
通过上面的权限检查后,CPU会将0x20段选择子对应的段描述符加载到CS段寄存器中
代码执行
CPU将CS.Base+Offset的值写入EIP,然后执行CS:EIP处的代码,段间跳转结束
【总结】
对于一致代码段,也就是共享的段
- 特权级高的程序不允许访问特权级低的数据:核心态不允许访问用户态的数据
- 特权级低的程序可以访问到特权级高的数据,但特权级不会改变:用户态还是用户态
对于普通代码段,也就是非一致代码段
- 只允许同级访问
- 绝对禁止不同级别的访问:核心态不能访问用户态,用户态不能访问核心态。
直接对代码段进行JMP或者CALL的操作,无论目标是一致代码段还是非一致代码段,CPL都不会发生改变。如果要提升CPL的权限,只能通过调用门。
【最终总结】
- 为了对数据进行保护,普通代码段是禁止不同级别进行访问的。用户态的代码不能访问内核的数据,同样,内核态的代码也不能访问用户态的数据。
- 如果想提供一些通用的功能,而且这些功能并不会破坏内核数据,那么可以选择一致代码段,这些低级别的程序可以在不提升CPL权限等级的情况下既可以访问。
- 如果想访问普通代码段,只有通过“调用门”等提升CPL权限,才能访问。
实验
黄标表示如何查找gdt表。
eq表示往某地址写入某内存。
虚拟机中xp系统中的OD进行测试
004B表示段选择子,index为9(二进制1001),索引的段寄存器正是上上图黄标位置的段描述符。
单步执行看是否可以跳转到44E082地址
成功跳转。
将段描述符的RPL修改为0环。此时预测权限验证应该会失败。
修改后,再次尝试跳转
单步执行
按shift+F7
进入ntdll了,即进入异常模块了。和预期的结果一致,权限验证失败。
将对应段描述符修改为一致代码段
od中执行同一条指令:
单步执行
成功跳转,一句话总结实验:低权限(CPL=3)代码段用零环的权限(RPL=0)访问低权限代码段(DPL=3)成功,因为是一致代码段
如果是非一致代码段,要求:CPL == DPL 并且 RPL <= DPL
如果是一致代码段,要求:CPL >= DPL
长调用与短调用
我们通过JMP FAR可以实现段间的跳转,如果要实现跨段的调用就必须要学习CALL FAR,也就是长调用。
CALL FAR比JMP FAR要复杂,JMP并不影响堆栈,但CALL指令会影响堆栈,所以长调用比长跳转要复杂。
短调用
指令格式:CALL 立即数/寄存器/内存
短CALL是push了一个返回地址,EIP修改为调用位置。
发生改变的寄存器:ESP和EIP。
长调用(跨段不提权)
指令格式:CALL CS:EIP(EIP是废弃的)
【注意】:长调用的调用地址并不是由EIP决定的,而是通过CS段选择子找到段描述符(该段描述符必须是一个调用门)算出来的。
发生改变的寄存器:ESP EIP CS
通过长调用执行完代码后是通过长返回RETF返回。返回的时候,会将上图红色的调用者CS重新赋值给CS段寄存器
长调用(跨段并提权)
指令格式:CALL CS:EIP(EIP是废弃的)
ESP3表示当前执行的权限为3环。堆栈发生了切换,右边的堆栈已经不是左边的堆栈了,而是一个零环的堆栈了。
发生改变的寄存器:ESP EIP CS SS
【总结】
- 跨段调用时,一旦有权限切换,就会切换堆栈
- CS的权限一旦改变,SS的权限也要随着改变,CS与SS的等级必须一样
- JMP FAR只能跳转到同级非一致代码段,但CALL FAR可以通过调用门提权,提升CPL的权限。
SS与ESP从哪里来?参见TSS段。
调用门
调用门执行六项功能:
- 它指定要访问的代码段。
- 它为指定代码段中的过程定义了一个入口点。
- 它指定尝试访问过程的调用者所需的权限级别。
- 如果发生堆栈切换,则指定要在堆栈之间复制的可选参数的数量。
- 它定义了要推送到目标堆栈上的值的大小:16 位门强制 16 位推送,32 位门强制 32 位推送。
- 它指定调用门描述符是否有效。
调用门(无参)
调用门最大的好处就是提权,但提权的方式不仅仅是调用门
门描述符的结构
门描述符是系统段描述符的一类,所以S字段必须是0,type域为1100表示调用门。
调用门中存储了另一个代码段段的选择子指的就是上图段选择符字段
【重点】调用门真正要调用的地址:段选择符中存的段选择子指向的那个段描述符中的base + 上图中的两段段中偏移值拼接而成的值
不同门的TYPE:
调用门指令流程
为了访问调用门,在 CALL 或 JMP 指令中提供了一个指向该门的远指针作为目标操作数。来自该指针的段选择器标识了调用门(JMP还未亲自实验过);指针的偏移量(下面的EIP)是必需的,但不被处理器使用或检查。(偏移量可以设置为任何值。)
指令格式:JMP/CALL CS:EIP(EIP是废弃的)
执行步骤:
- 根据CS的值查GDT表,找到对应的段描述符,这个描述符是一个调用门。
- 在调用门描述符中存储另一个代码段段的选择子
- 选择子指向的段 段.Base+偏移地址 就是真正要执行的地址。
【重点】调用门权限相关
调用门的执行流程涉及到的权限
- CPL(当前特权级别)。
- 调用门的选择器的 RPL(请求者的特权级别)。
- 调用门描述符的 DPL(描述符特权级别)。
- 目标代码段的段描述符的 DPL。
权限检查规则在 CALL 和 JMP 指令之间有所不同,如下表所示。
| 指令 | 特权检查规则 |
|---|---|
| CALL | CPL ≤ 调用门 DPL;RPL ≤ 调用门 DPL目标一致代码段 DPL ≤ CPL 目标非一致代码段 DPL ≤ CPL |
| JMP | CPL ≤ 调用门 DPL;RPL ≤ 调用门 DPL目标一致代码段 DPL ≤ CPL 目标非一致代码段 DPL = CPL |
上图说明,只有 CALL 指令可以使用调用门将程序控制转移到更高特权(数字特权级别更低)的非一致性代码段
- 如果调用更高特权(数字特权级别更低)的非一致目标代码段,则 CPL 将降低到目标代码段的 DPL 并发生堆栈切换。
- 如果调用或跳转到更高特权的一致目标代码段,则 CPL 不会更改,也不会发生堆栈切换。
实验
windows内并没有使用调用门。
所以自己构造一个调用门(无参,提权)
1 | //x部分表示还不确定的部分。 |
查看段描述符
尝试用调用门调用上图黄标的段描述符的零环权限来提权。
所以构造的调用门低32位16进制为:0008XXXX(index为1,指向的正是上图黄标段描述符)
虚拟机上的xp系统上执行如下代码:
1 |
|
在上述代码的下断点!!!!!!!!!!!!!!!!!!!!!处下断点。然后执行代码断到此处断点,进入反汇编找到GetRegister裸函数的函数地址。
上图黄标可知跳转目标地址为00401010
所以构造的调用门段描述符为0040EC00`00081010
windbg下断点,修改gdt中0x48段选择子对应的位置内容为0040EC00`00081010
执行call fword ptr[buff]代码前的寄存器情况:
执行call fword ptr[buff]代码后,成功跳转了GetRegister裸函数代码中的int 3,所以windbg断了下来。
在00401010地址处因为int 3断了下来。
发现寄存器窗口为空,查明是有bug,解决方案(参考命令:!WingDbg.regfix)
此时windbg断下,查看windbg的寄存器显示(与执行call fword ptr[buff]前的寄存器比较):
对应颜色画笔标记为修改了的部分,其中ESP从0012FF28修改为B2B69DD0,直接从低2G空间跳转到高2G内核空间。CS变为8是因为我们在调用门描述符中设置为跳转到08段选择子对应的段描述符。
有部分其他寄存器也被改了,原因是系统底层代码写死了的部分修改,另外还有int 3的干扰。
windbg此时查看B2B69DD0处堆栈
到此已验证完毕!
将代码修改为:
1 |
|
由于代码修改了,所以GetRegister函数的首地址也修改了,因此要修改调用门段描述符的段中偏移值。
构造的调用门段描述符为0040EC00`00081020
修改:
执行代码,不能用单步执行的方式执行,而是直接不断点按F5
结果如图:
实验成功,提权成功!
调用门(有参)
调用门有权限切换时堆栈变化:
R3堆栈的参数1,2,3需要手动push,如果没有手动push,则R3堆栈不存在参数1,2,3。R0堆栈的参数1,2,3取决于调用门描述符中的参数个数,没有手动push的话,R0堆栈的参数1,2,3均为0。
实验
1 |
|
断点!!!!!!!!!!!!!!!处断点,查看断点处地址
构造调用门段描述符为0040EC03`00081020(3表示3个参数)
修改gdt第10个段描述符:
修改后F5执行
CateProc函数头加个int 3,来查看一下堆栈情况。
地址未变,段描述符不需要改,直接执行,windbg执行到int 3断下
此时windbg中查看堆栈情况:
int 3断下时候的堆栈结构
pushad和pushfd后的堆栈结构(参数此时的相对ESP位置结构):
【总结】
- 当通过门,权限不变的时候,只会PUSH两个值:CS和返回地址。新的CS的值由调用门决定。
- 当通过门,权限改变的时候,会PUSH四个值,
SS,ESP,CS,返回地址,新的CS的值由调用门决定,新的SS和ESP由TSS提供。 - 通过门调用时,要执行哪行代码由调用门决定,但使用RETF返回时,由堆栈中压入的值决定,这就是说,进门时只能按照指定路线走,出门时可以翻墙(只要改变堆栈里面的值就可以想去哪去哪)
- 可不可以再建个门出去呢?当然可以了,前门进,后门出
中断门
Windows没有使用调用门,但是使用了中断门。
windows系统使用了中断门的两种情况:
- [[系统调用]](老cpu使用中断门,新cpu已经不使用中断门了,而是使用快速调用)
- 调试(软件断点int 3就是用来执行中断门的)
- 键盘鼠标显示器等外部设备都是可屏蔽中断
- 电源等是不可屏蔽中断,无法操作。
- 现在的中断大多都是用的APIC实现,APIC(高级可编程中断控制器)编程强一些。
执行调用门的指令:CALL CS:EIP
但当CPU 执行如下指令:INT N(int 0表示0号中断;int 1表示1号中断…)
查询的却是另一张表,这张表叫IDT
中断指令
$$
INT\ N
$$
其中,N是索引,X*8+IDT的基址 就是具体的中断门描述符
保护模式下的中断和异常表解释
终止就是蓝屏
中断门的堆栈和返回
- 在没有权限切换时,会向堆栈PUSH3个值,分别是:CS EFLAG EIP(返回地址)
- 在有权限切换时,会向堆栈PUSH5个值,分别是:SS ESP EFLAGS CS EIP(返回地址)
在中断门中,不能通过RETF返回(其实也可以),而应该通过IRET/IRETD指令返回。IRET是16位,而IRETD是32位
裸函数的话必须写IRETD,部分IDE会自动根据D/B位在IRET/IRETD中变换。
IDT
IDT即中断描述符表,结构同GDT一样,IDT也是由一系列描述符组成的,每个描述符占8个字节。但要注意的是,IDT表中的第一个元素不是NULL。
IDT表中存的中断描述符按照中断编号从0号开始排序下去。
在Windbg中查看IDT表的基址和长度:
IDT表的构成
IDT表可以包含3种门描述符
- 任务门描述符
- 中断门描述符
- 陷阱门描述符
中断门描述符
高4字节的0~4位固定为0。
64进制中断门描述符表示:XXXXEE00`XXXXXXXX
中断门提权实验
参考流程图
提权与否取决于:中断门描述符的段选择子指向的段描述符的DPL。
实验:
1 |
|
断点!!!!!!!!!!!!!!处断下反汇编查看test的函数地址。
在IDT中找空的位置构造中断门描述符(空位如下图)
构造的中断门描述符:0040EE00`00081020
中断门的段选择子0x0008指向的是DPL为0的内核代码段。
int32之前的堆栈情况:
断到int3上:
查看寄存器变化:
有部分其他寄存器也被改了,原因是系统底层代码写死了的部分修改,另外还有int 3的干扰。
查看堆栈:
调用中断门的R3堆栈没有变化。
调用门与中断门的区别
- 调用门通过CALL FAR指令执行,但中断门通过INT指令
- 调用门查询GDT表,中断门查询IDT表
- CALL CS:EIP中的CS是段选择子,由3部分组成。但INT N指令中的N只是索引,中断门不检查RPL,只检查CPL
- 调用门可以有参数,但中断门没有参数
【重点理解】各种返回加深理解
iret可以理解为:
1 | pop eip |
各种返回
- RET 及其同义词 RETN,从堆栈中弹出 IP 或 EIP(返回地址) 并将控制权转移到新地址。可选的,如果提供了数字二的操作数,它们会在弹出返回地址后将堆栈指针再增加 imm16(16位即两个字节) 字节。
- RETF 执行远返回:在弹出 IP/EIP(返回地址)后,它会弹出 CS,然后通过可选参数(如果存在)递增堆栈指针,如果返回到另一个特权级别,IRET指令还会在恢复程序执行之前从堆栈中弹出SP(或ESP)和SS,最后将参数计数(以从 RETF 指令获得的字节数)添加到当前 ESP/SP 寄存器值。
- IRETW 将 IP、CS 和flags弹出为 每个2 个字节,总共从堆栈中取出 6 个字节,如果返回到另一个特权级别,IRET指令还会在恢复程序执行之前从堆栈中弹出SP和SS。
- IRETD 将 EIP 弹出为 4 个字节(返回地址)。再弹出 4 个字节,其中前两个被丢弃,后两个进入 CS,并将eflags也弹出为 4 个字节,从堆栈中取出 12 个字节,如果返回到另一个特权级别,IRET指令还会在恢复程序执行之前从堆栈中弹出ESP和SS。
- IRET 是 IRETW 或 IRETD 的简写,具体取决于当时的默认 BITS 设置。
【实验】在调用门中实现使用IRETD返回,在中断门中实现用RETF返回
中断门用RETF返回
1 | #include"stdafx.h" |
调用门用iretd 返回
1 |
|
陷阱门
陷阱门段描述符:XXXXEF00`XXXXXXXX
陷阱门与中断门几乎一样,陷阱门与中断门唯一的区别:
中断门执行时,会将IF位清零,但陷阱门不会。
IF位:eflags下标为9的位置。
IF的含义:
IF标志用于控制处理器对可屏蔽中断请求的响应。置1以响应可屏蔽中断,反之则禁止可屏蔽中断。
- IF=0 CPU不再接受可屏蔽中断
- IF=1 CPU接受可屏蔽中断
p.s.不可屏蔽中断不受IF位影响,比如说断电,就是电源通过电源管理器向CPU发送一个请求,这就是一个不可屏蔽中断。(CPU有电容,即使是断电了也能跑一会儿,执行一些清理工作)
通过中断门与陷阱门打印EFLAG寄存器的值
- 执行前:216
- 执行中
- 陷阱门:216
- 中断门:16
任务段TSS(难点非重点)
在调用门,中断门与陷阱门中,一旦出现权限切换,那么就会有堆栈的切换。而且,由于CS的CPL发生改变,也导致了SS也必须要切换。(CS和SS的权限级别永远都是一致的)
切换时,会有新的ESP和SS(CS是由中断门或者调用门指定)这2个值从哪里来的呢?
答案:TSS(Task-state segment),任务状态段。
TSS和TR寄存器
一块大于等于104字节的内存结构。(强调:不在CPU中,就是内存中)
I/O权限位图:I/O Map Base Address(没什么用,和硬件是相关的)
CR3:页目录基地址寄存器CR3(PDBR)
指向前一个任务段(TSS)的链接(如果不为空的话,存的是前一个TSS段的段选择子):Previous Task Link
LDT Segment Selector:LDT段选择子,会加载到LDTR寄存器中。
tss中的ss0,ss1,ss2修改是没用的,即使修改了,也会变回去,因为是系统填写的。
一个TSS对应一个LDT表。LDTR描述的是LDT表的地址和大小。
LDT和GDT从本质上说是相同的,只是LDT嵌套在GDT之中。LDTR记录局部描述符表的起始位置,与GDTR不同LDTR的内容是一个段选择子。由于LDT本身同样是一段内存,也是一个段,所以它也有个描述符描述它,这个描述符就存储在GDT中,对应这个描述符也会有一个选择子,LDTR装载的就是这样一个选择子。LDTR可以在程序中随时改变,通过使用lldt指令。如上图,如果装载的是Selector 2则LDTR指向的是表LDT2。
LDT描述符
windows 32位以后实际上没有使用LDT。因为用不着这么多段了
更多关于LDT的内容可以参考此处
1 | nt!_KTSS |
TSS的地址就是TSS段描述符描述的基地址,因此我们通过 dg tr 查看其Base为 80042000。对于查看TSS段,有一个单独的指令。
1 | dg tr |
TSS的作用
CPU层面的任务就是系统层面上的线程
Intel CPU设计思想的初衷:TSS就是实现任务切换(操作系统中也就是线程切换,CPU中没有线程的概念)
操作系统的设计思想:TSS的任务切换在操作系统中其实就是同时换掉”一堆“寄存器,与线程切换无关。windows和linux系统实际上只使用了ss和esp.
本质:不要把TSS与“线程切换“联系到一起,TSS的意义仅在于可以同时换掉”一堆“寄存器
CPU是如何找到TSS的
任务寄存器TR,16位寄存器。 其内保存的是任务状态段TSS的16位段选择子。每项任务都配有一个任务状态段TSS,用来描述该任务的运行状态。就用16位的选择子来检索任务状态段。总是将当前任务的TSS的选择符放在TR中,而TSS的描述符放在TSS描述符高速缓冲寄存器中(就是段描述符除了16位的端选择子部分的其他80位)(针对这句话一下说明)。
在保护方式下,选择器寄存器的D1 D0是特权标志,D2为描述符表类型标志,高13位是选择码,指出本段的描述符在由D2指出的描述符表中的逻辑排序。当一个段第一次被访问时,首先根据指令给出选择器的D2位及高13位,到内存中相应的描述符表内取出相应的描述符(64位),送入对应的描述符高速缓冲寄存器(64位),再从描述符中取出段基址进行逻辑到线性地址的变换。以后再访问该段时,直接从描述符寄存器(64位)中取地址信息,免去从内存中选取描述符的过程,实现加速。
在系统中 GDT,IDT 只有一个,所以GDTR,IDTR中存放的是该表入口地址;而任务不只一个,所以LDTR,TR存放的是当前任务的选择符。
TSS段描述符
TSS段描述符是系统段描述符的一种,在GDT中。
TSS的limit以字节为单位的,所以上图高4字节的23位的G代表0,含义是limit以字节为单位。
TYPE为1011和1001都表示TSS段描述符。
- 1001(0x9)表示当前的TSS段描述符没有加载到TR寄存器中。
- 1011(0xB)表示已经加载了(忙碌状态)
加载前TYPE是0xB,加载后0x9
- 构造TSS段描述符(零环已使用):XX008BXX`XXXX0068
- 构造TSS段描述符(零环未使用):XX0089XX`XXXX0068
- 构造TSS段描述符(三环已使用):XX00EBXX`XXXX0068
- 构造TSS段描述符(三环未使用):XX00E9XX`XXXX0068(下面实验使用这个才能成功)
之前的段描述符G位都是填1,TSS段描述符填的是0,表示按字节为单位
TSS段描述符
TR寄存器的读写
1)将TSS段描述符加载到TR寄存器
指令:LTR
- 用LTR指令去装载的话,仅仅是改变TR寄存器的值(92位),并没有真正改变TSS
- LTR指令只能在系统层使用(ltr是特权指令,CPU权限零环)
- 加载后TSS段描述符会状态位发生改变(加载完后,TYPE会从0x9变成0xB)
2)读TR寄存器
指令:STR
如果用STR去读的话,只读了TR的0~15位,也就是选择子。
修改TR寄存器
在Ring0,我们可以通过LTR指令去修改TR寄存器
在Ring3,我们可以通过CALL FAR或者JMP FAR指令来修改(不但改TR寄存器,还会通过TSS改所有的寄存器)
用JMP去访问一个代码段的时候,改变的是CS和EIP:
JMP 0x48:0x123456,如果0x48是代码段
执行后:CS->0x48 EIP->0x123456
用JMP去访问一个任务段的时候
如果0x48是TSS段描述符,先修改TR寄存器,再用TR.Base指向的TSS中的值修改当前的各种寄存器(TSS涉及的寄存器)。
CALL FAR或者JMP FAR一个任务段描述符的不同点(重点)
- 第一个不同点在于Previous Task Link
- CALL FAR调用了TSS段之后,Previous Task Link会被填入前一个TSS段的段选择子
- JMP FAR调用了TSS段之后,Previous Task Link不会被改变。
- 第二个不同点在于nt位(EFLAGS的第14位)
- CALL FAR调用了TSS段之后,nt位会置一
- JMP FAR调用了TSS段之后,nt位清0
nt位(EFLAGS的第14位)(保护模式下)的理解:
- NT=0时,iret为中断返回,会在堆栈中找返回值返回
- NT=1时,iret不是中断返回,会找TSS中的Previous Task Link返回
nt位(eflags有部分位)不允许被应用程序所修改
注意:int也会把nt位清零(无法实验证实)。所以call的方式调用任务段跳转到代码后用int会导致蓝屏,而jmp却可以。
TSS段描述符实验
不管跳几环,TSS中一定要改ss,cs,fs这三个段寄存器
使用CALL和JMP去访问一个任务段,并能正确返回。
CALL实验返回
1 |
|
在windbg中断下,输入!process 0 0指令,根据程序名查看CR3:
g放开内核断点,控制台输入上图黄标,得到iTss的地址,长调用之前调试器断下:
windbg再次断下,构造TSS段描述符:0000E912`FDCC0068
修改好后,确保iTss字符数组首地址不会变的时候,不下断点重新运行(否则会出现如下图的单步运行异常),填入新的CR3
p.s.单步运行异常是可以用windbg单步调试的状态,p指令单步走,r指令查看寄存器。
输入CR3时的寄存器情况
重新运行结果:(下图左边寄存器为返回后的值)
再次运行程序在test函数头加int3观察
与我们的修改完全一致,只是如果在test函数头加int3,会导致蓝屏,无法正常返回。
后面的实验发现CALL过去的方式不能在test函数头加int3(蓝屏)是因为int会将nt位清0,导致iretd在堆栈中找返回值(实则没有,所以蓝屏)
可以在CALL实验中将test函数中的int 3用pushfd和popfd包裹起来(防止int 3修改eflags寄存器),避免蓝屏。(已实验证实可以)
JMP过去的方式可以加int 3不蓝屏。
JMP实验返回
1 |
|
构造tss段描述符:0000E912`FD780068
1 | eq gdtr+0x48 0000E912`FD780068 |
获取CR3后输入,成功执行
JMP实验中test头添加int观察实验
1 |
|
正常执行。
任务门
这里主要介绍如何通过任务门去访问任务段
有了任务段为什么还要有任务门?
答:任务门为异常(INT)提供了可切换任务的机制,是一种被动的机制,而单纯的任务段必须被主动调用(CALL JMP)
任务门描述符可以放在GDT,LDT和IDT中
任务门描述符
TSS Segment Selector指向一个TSS段描述符。
任务门描述符的构造:0000E500`XXXX0000
任务门的执行过程
INT N—>查IDT表,找到任务门描述符—>通过中断门描述符,查GDT表,找到任务段描述符—>使用TSS段中的值修改寄存器—>IRETD返回。
任务门描述符不一定在IDT中。多个任务门可以指向同一个TSS段描述符(如下图)
课后练习:实现任务门进1环。
实验流程图:
1 |
|
构造任务门放进IDT中第33个位置,即int 0x20对应的位置
1 | eq 8003f500 0000E500`00480000 |
在GDT的0x48位置构造TSS段描述符
1 | eq 8003f048 0000E912`fdcc0068 |
将原本0x1B选择的CS,0x23选择的SS和0x3B选择的FS三个段描述符复制后到GDT表的空的空间中,仅修改DPL为01b(因为目的是要进1环)。
CS
1
eq 8003f090 00cfbb00`0000ffff//CS在TSS中修改为0x91
SS
1
eq 8003f098 00cfb300`0000ffff//SS在TSS中修改xin为0x99
FS
1
eq 8003f0a8 0040b300`00000fff//FS在TSS中修改为0xA9
修改后的gdt表:
成功执行:
逆向int 8实验
下图第三题
windbg指令U
1 | U //这个命令主要用于反汇编某个地址,其后面可以跟函数名和地址。 |
uf命令可以看到跳转等的下文。(更有用)
实验流程图:
查看idt表吗,下图黄标为int 8的中断门描述符
0x85拆解为1000 0101b,type为0101b,发现int 8指向的是任务门描述符。可知TSS段选择子为0x0050,拆解为:0101 0000b,IT位为0,因此在gdt表中找gdtr+0x50的位置为TSS段描述符(如下图黄标)
由TSS段描述符可知,TSS内存的所在位置为0x8054af00。
查看TSS内存:
上图黄标为EIP地址,0x805404ce。
windbg反汇编EIP地址,则找到了int 8将执行的目标代码:
黄标就是蓝屏
CLI表示禁止中断发生,STL允许中断发生
IDA中找该函数:
1 | kd> uf 0x805404ce |
上面代码分析不一定正确。
p.s.我取了内核态和用户态FS的值,在内核态FS=0x30, 在用户态FS=0x3B。
远程执行思路图:
略(未实验)
LDT相关
LDT段描述符结构
LDTR结构
与其他结构的关系
自己构建LDT表和其中的数据段描述符实验
- LLDT
LLDT的作用是装载局部描述符表寄存器LDTR。 - SLDT
SLDT的作用是读取局部描述符表寄存器LDTR中的内容读取出来并存储。 - LGDT
LGDT的作用是装载全局描述符表寄存器GDTR。 - SGDT
SGDT的作用是读取全局描述符表寄存器GDTR中的内容读取出来并存储。
LDT为什么叫局部描述符,是因为和程序挂钩,也就是用本程序的CR3才能获取到地址,所以应该放到三环。想看的话只能通过物理地址去看。
别的程序访问效果如下:
实验代码如下:
1 |
|
实验图解:
运行程序
401000为test地址,可知构造的调用门描述符为
1 | eq gdtr+0x48 0040ec00`00081000 |
运行,下图成功将b改为10了,实验成功。
此时查看gdt表:
0x90的位置(蓝色区域)已被修改为ldt段描述符
可进一步实验,将LDT中的数据段描述符改成DPL改为零环,则数据读写失败(略,已证明)
保护模式–页
实模式下访问的内存地址都是物理内存地址,保护模式下访问的内存地址都是线性地址
物理内存的大小等同于内存条,但物理内存不是内存条,之间还有映射关系
线性地址,有效地址,物理地址的概念理解
如下指令:
MOV eax,dword ptr ds:[0x12345678]
其中,0x12345678是有效地址
ds.Base+0x12345678是线性地址(通常有效地址和线性地址是一个值,因为ds.Base为0)
- 线性地址转换成物理地址的方式在x86 cpu下有两种模式,一种是10-10-12,另一种是2-9-9-12的形式。
- 如果是64位CPU的话,还有第三种更加复杂的方式。(类似2-9-9-12)
- Q:两个进程都存在0x12345678的线性地址,为什么找到的内容不一样
- A:因为每个进程都有各自的一堆表(存储着该进程线性地址到物理内存的映射关系)
10-10-12分页
10-10-12分页的内核模块是ntoskrnl.exe
修改10-10-12分页的方式:boot.ini中的**/execute=optin**表示10-10-12分页
每个进程都有一个CR3(准确来说是CR3中的值)
CR3本身是一个寄存器,一个CPU核只有一套寄存器。
CR3指向一个物理页(所有寄存器中,只有CR3存的是物理地址,其他寄存器存的都是线性地址),一共4096字节(4KB),如图:
将线性地址的32位拆分为10位,10位,12位。其中,第一个10位就代表了第一级内在什么位置。
windbg查看物理地址指令是
$$
!dd
$$
找第一级的物理地址:
1 | !dd DirBase(低3位十六进制清零)+线性地址第一个十位*4 |
将里面取得的值的低3位16进制置为0,因为低3位16进制代表的是属性
找第二级物理地址:
1 | !dd 上一步取到的值(低3位十六进制清零)+线性地址第二个十位*4 |
再次将取得的值的低3位16进制置为0
找到对应物理地址:
1 | 要找的物理地址=上一步取到的值(低3位十六进制清零)+线性地址第三个12位 |
取物理地址里存的值
1 | !dd 要找的物理地址 |
PDE与PTE
页目录表(占4KB)中每个页目录表项占4个字节,每个页目录项又指向一个4KB的页表,每个页表项占4个字节。因此每个页表有1024个页表项。
无论是PDE还是PTE,里面记录的前20位*2的12次方(后面添加12位0)是物理地址,后面12位是属性。
PTE的特点
- PTE可以没有物理页(P位为0即无效,也就是没有物理页),且只能对应一个物理页
- 多个PTE可以指向同一个物理页
10-10-12一些细节
- 一个物理页是4KB(4096个字节),刚好是2的12次方个字节,所以需要12个二进制位索引4KB大小的物理页,说明了10-10-12中最后一个为什么是12。
- 页表是1024个页表项,即2的10次方个成员,所以需要10个二进制位索引,说明了10-10-12的中间的10的由来。
- 同理页目录项。
- 10-10-12分页决定了当前CPU物理内存的最大值就是4GB(1024*1024*4096(B)=4GB)
同一个进程的两个线性地址只要前5位16进制是一样的,那么他们就一定在一个物理页上。因为PDE与PTE都一样,而后三位十六进制只决定他们在物理页上的偏移。
$$
物理页的属性=PDE属性\ &\ PTE属性
$$
| 位 | 功能描述 |
|---|---|
| P | 有效位:1-有效 0-无效 决定是否存在物理页 |
| R/W | 0-只读(常量区) 1-可读可写 |
| U/S | 权限位:0-特权用户(-1环【VT】和0环) 1-普通用户(1,2,3环) |
| P/S | 只对PDE有意义,PS(PageSize)的意思,当PS==1时,表示PDE指向大物理页(4MB),即不需要拆分PTE了,PDE的低22位直接是物理页的页内偏移。 |
| PAT | 页属性表(也是用来控制页属性的,但是对CPU有要求) |
| A | 是否被访问(读或者写)过 访问过置1,即使只访问一个字节也会导致PDE,PTE置1 |
| D | 脏位:是否被写过 0-没有被写过 1-被写过 |
| G | 1-全局页(全局TLB) 0-相对TLB,只有P/S位等于1的时候(大页),他才有效。P/S为零的时候,G位永远为零。全局页不会随进程切换清空TLB |
| PWT | - |
| PCD | - |
有效位,CPU没有使用,操作系统使用了,用来判断缺页。CPU寻址的时候发现P位为0就触发0xE号中断,此时操作系统得以发挥,在9~11位的有效位判断到底是没分物理页,还是缺页将此页挪到文件里了(然后再给补上页)。(具体的细节后面会有更详细的解读)
-的部分学完控制寄存器与TLB才能理解,此处先略过(后面有讲)
可见10-10-12分页没有可执行属性,但在2-9-9-12有个位对这种情况做了补充
页有两种
- 小页单位:4KB
- 大页单位:4MB(只有系统里面一部分经常使用的内存才会使用大页)
1 | mov dword ptr ds:[0] |
- 2G以上是内核才能访问的原因是U/S位的设置问题,如果将内核的某个页设置为1,就可以在R3访问了
- 0,1,2是系统环,可以访问系统页和用户页,0环是特权环;1,2环虽然不是特权级环,但是是系统环;3环是用户环,可以访问用户页
页目录表PDT基址(线性地址)
- Q:如果系统要保证某个线性地址是有效的,那么必须为其填充正确的PDE与PTE,如果我们想填充PDE与PTE,那么必须能够访问PDT与PTT。那么是谁帮我们填好了PDE与PTE呢?
- A:操作系统;如果A进程创建B进程,那么是A进程帮B进程填好PDT和PTT
在程序中,我们是不能直接访问物理页的,想要访问物理页,必须通过线性地址。
CR3中存储的是物理地址,不能在程序中直接读取的。如果想读取,也要把Cr3
的值挂到PDT和PTT中才能访问,那么怎么通过线性地址访问PDT和PTT呢?
拆解4G线性内存:
- 低2G(0~7FFFFFFF) 各个进程几乎不同
- 高2G(80000000~FFFFFFFF) 各个进程几乎相同
- 0~7FFFFFFF的前64K和后64K都是没有映射的
发现:
$$
0xC0300000线性地址存储的值就是PDT的基址
$$
通过0xC0300000(这个线性地址是一定存在的,如果它不存在,系统也没办法访问这个表)找到的物理页就是页目录表
这个物理页即是页目录表,本身也是页表
页目录表是一张特殊的页表,每一项PTE指向的不是普通的物理页,而是指向其他的页表
如果我们要访问低N个PDE,公式如下:
$$
0xC0300000+N*4
$$
CR3中的值+0xC00该物理地址中存的就是CR3中的值
PDT是PTT中的一个(如图红色区域就是PDT)。
页表PTT基址(线性地址)
- 0xC0000000对应的是第一个PTT的基地址
- 0xC0001000对应的是第二个PTT的基地址
- 每个PTT基地址之间隔着0x1000(4KB)(物理地址不连续,但线性地址是连续的)
- 页表被映射到了从0xC0000000到0xC03FFFFF的4M地址空间
- 在这1024个表中有一张特殊的表:页目录表(页表中的第0x300项)
- 页目录表被映射到了0xC0300000开始处的4K地址空间
0xC0300000实际上是通过0xC0000000算出来的:
$$
VirtualAddr对应的PDE线性地址=(VirtualAddr >> 12) * 4+0xC0000000
$$
Windows 把所有页表映射到0xC0000000到0xC03FFFFF 这4MB的地址空间中,对于这4M的地址空间也有一个页目录表与之对应。不妨设为Px。既然Windows 把全部页表都映射到上述的4M地址空间,那么页目录表Px自然也在其中。第一个页表对应的线性地址为0xC0000000H,即用上述公式应该能找到第一张页表对应的PDE所在线性地址。利用公式PDE_Addr = (VirtualAddr >> 12) * 4 + 0xC0000000,将0xC0000000H带入其中,可以求得第一个PDE地址:PDE_Addr =C0300000H。
C0300000H-0xC0300FFF的页表映射了0xC0000000到0xC03FFFFF 这4MB的地址空间的物理内存。而这4m地址空间恰恰是页目录表的物理地址。所以说C0300000H-0xC0300FFF对应的4k地址空间就是页目录。
windbg拆解线性地址命令:
1 | !vtop 3a37a000(CR3) 0xXXXXXXXX(要拆解的线性地址) |
小实验
1 |
|
掌握一个进程所有的物理内存读写权限【公式总结】
10-10-12 PDI-PTI-物理页内偏移
- PDI:页目录索引
- PTI:页表索引
访问页目录表项PDE地址的公式:
$$
0xC0300000+PDI*4
$$访问页表项PTE地址的公式:
$$
0xC0000000+PDI4096+PTI4
$$
$$
0xC0000000+PDI2的10次方4+PTI4=0xC0000000+(PDI2的10次方+PTI)*4
$$
$$
0xC0000000+PDI拼接PTI部分*4
$$
实验
将buf的物理页挂到0线性地址那附近,通过0 + buf的物理页偏移部分的地址调用MessageBoxW函数。
1 |
|
10-10-12内核逆向分析MmIsAddressValid函数
一个用于判断虚拟内存地址是否有效的API,逆向该函数可以知道操作系统是怎么找PDE和PTE的。
用IDA
可以将内核文件放入IDA分析。
用windbg
1 | uf MmIsAddressValid |
尝试逆向:
1 | kd> uf MmIsAddressValid |
sub eax,3FD00000h等同于add eax,0xc0300000详解:
因为3FD00000h+0xc0300000=0x100000000
eax+0xc0300000=eax+0x100000000-3FD00000h,0x100000000装不下所以会溢出等同于没有。
2-9-9-12分页
2-9-9-12分页的内核模块是ntkrnlpa.exe
在之前的课程中我们讲解了10-10-12分页方式,在这种分页方式下,物理地址最多可达4GB,但随着硬件发展,4GB的物理地址范围已经无法满足要求,Intel在1996年就已经意识到这个问题了,所以设计了新的分页方式。也就是我们本节课要讲的2-9-9-12分页,又称为PAE(物理地址拓展)分页。
与64位比较相似。
为什么是10-10-12
- 先确定了物理页的大小为4K,所以后面的12位的功能就确定了。
- 当初的物理内存比较小,所以4个字节的PTE就够了,加上页的尺寸是4K,所以一个页能存储1024个PTE,也就是2的10次方,第二个10位确定了。
- 剩下的10位为PDI,10+10+12=32。
为什么是2-9-9-12
物理页的大小是确定的,4KB不能随便改,所以后面的12位确定了
如果想增大物理内存的访问范围,就需要增大PTE,增大多少了呢,考虑对齐的因素,增加到8个字节。
因为一个物理页就4KB,由于PTE由4字节变成8字节,所以项数由1024项缩小为512项。
而512项需要9个二进制位进行索引,所以PTI是9。
同理PDI也是9位(2的9次方)
最后,32 - 9 - 9 - 12 还差2位 所以就再做一级 叫PDPI
因为只有两位,所以PDPTE只有4个。
2-9-9-12可以最大允许的物理内存为:2的36次方(PDE是36位的物理地址宽度),即64G。
2-9-9-12 VirtualAddr对应的PDE线性地址=(VirtualAddr >> 12) * 8+0xC0000000
代入第一个PTE的地址0xC0000000可知:PDT地址为0xC0600000
第4个PDPTE指向了一个PDT表,此表的前四项指向了PDPTE的每一个元素。
- C0600000是第一个PDT表的首地址
- C0601000是第二个PDT表的首地址
- C0602000是第三个PDT表的首地址
- C0603000是第四个PDT表的首地址
在同一个PDT中相邻两个PTT首地址间隔为0x1000
通过0xC0603000线性地址读取第四个PDT的第一个PDE内容成功:
访问页目录表项PDE地址的公式:
- 0xC0000000+PDI*4
- 0xC0100000+PDI*4
- 0xC0200000+PDI*4
- 0xC0300000+PDI*4
访问页表项PTE地址的公式:
$$
0xC0000000+PDI4096+PTI8
$$
页目录指针表项Page-Dircetory-Point-Table Entry
Avail:CPU设计给操作系统用的,操作系统设计者爱用不用
35~12位存储的是页目录表PDT的物理基址,低12位补零,共36位,即页目录表基址。
存的项也是占用8字节
PDE结构
| 位 | 功能描述 |
|---|---|
| P | 有效位:1-有效 0-无效 决定是否存在物理页 |
| R/W | 0-只读(常量区) 1-可读可写 |
| U/S | 权限位:0-特权用户(-1环【VT】和0环) 1-普通用户(1,2,3环) |
| P/S | 只对PDE有意义,PS(PageSize)的意思,当PS==1时,表示PDE指向大物理页(2MB) |
| PAT | 页属性表(也是用来控制页属性的,但是对CPU有要求) |
| A | 是否被访问(读或者写)过 访问过置1,即使只访问一个字节也会导致PDE,PTE置1 |
| D | 脏位:是否被写过 0-没有被写过 1-被写过 |
| G | 1-全局页(全局TLB) 0-相对TLB,只有P/S位等于1的时候(大页),他才有效。P/S为零的时候,G位永远为零。 |
| PWT | - |
| PCD | - |
2-9-9-12结构下的两种页
- 小页 4KB
- 大页 2MB(和10-10-12分页的4MB不一样,2的(9+12)次方)
PTE结构
小实验:
1 | //12FF7C地址中存了一个100 |
X/D标志位
在AMD中称为NX,即No Excetion
PDE/PTE结构如下
- 段的属性有可读,可写和可执行
- 页的属性只有可读,可写
当RET执行返回的时候,如果我修改堆栈里面的数据指向一个我提前准备好的数据(把数据当做代码来执行,漏洞都是依赖这点)
所以,Intel就做了硬件保护,做了一个不可执行位,XD=1时,那么你的软件溢出了也没关系,即使你的EIP蹦到了危险的“数据区“,也是不可以执行的!
在PAE(2-9-9-12)分页模式下,PDE或PTE的最高位为XD/NX位,为1表示该内存只可在代码段执行。数据段不可执行
0x8开头的就是被保护的,因为首位二进制为1,进0环了直接改了就是了
2-9-9-12内核逆向分析MmIsAddressValid函数
1 | nt!MmIsAddressValid: |
旁路转换缓冲TLB
每次访问一个物理页,查表的过程特别繁琐,举个例子,2-9-9-12分页模式下读一个四字节实际上会读24个字节,如果跨页读可能更多。
CPU内部做了一个表,来记录这些东西,这个表格是CPU内部的(不在内存中),和寄存器一样快,这个表格:TLB(Translation Lookaside Buffer)即旁路转换缓冲,或称为页表缓冲。
说明:
- ATTR(属性):属性是PDPE,PDE,PTE三个属性AND起来的。如果是10-10-12就是PDE and PTE
- 不同的CPU,这个表的大小不一样
- 只要CR3变了,TLB立马刷新,一核一套TLB
- LRU(统计信息),记录每个地址的读写情况,确定哪个地址访问更频繁
- 存的是线性地址的前20位对应物理地址。
操作系统的高2G映射基本不变,如果CR3改了,TLB刷新,重建高2G以上内存很浪费。所以PDE和PTE中有个G标志位,如果G位为1,刷新TLB时将不会刷新PDE/PTE的G位为1的页。当TLB满了,根据统计信息将不常用的地址废弃,最近最常用的保留。
高两G有大量G位为1的PDE,低2G也有G为1
TLB种类
TLB在X86体系的CPU里的实际应用最早是从Intel的486PU开始的,在X86体系的CPU里边,每个核都设有如下4组TLB:
- 缓存一般页表(4K字节页面)的指令页表缓存(Instruction-TLB)
- 缓存一般页表(4K字节页面)的数据页表缓存(Data-TLB)
- 缓存大尺寸页表(2M/4M字节页面)的指令页表缓存(Instruction-TLB)
- 缓存大尺寸页表(2M/4M字节页面)的数据页表缓存(Data-TLB)
CPU能区分是读一个内存地址还是执行一个内存地址
如果是mov指令访问的内存放入数据页表缓存(读一个内存地址),如果是call/jmp等指令访问的内存放入指令页表缓存(执行一个内存地址)。
可以用于过代码校验,在指令页表缓存中给目标地址修改为新的物理页。当执行的时候是到物理页执行,可是读的时候,读的还是原来的位置,即未被修改。
这种解决方案适合处理零环(也有点不稳定)而不适合处理三环,因为三环内存老是刷新(非常不稳定)。
三环有更好的方案挂钩子防止对方检测到(无痕hook)
TLB相关实验
体验TLB的存在
1 |
|
进程切换后,结果被刷新:
1 |
|
结果中的0是因为新物理页中的值就是0
全局页的意义
上面的代码修改为全局页:
把上述代码中的mov dword ptr ds:[0xc0000000],0x01234867从0x01234867修改为0x01234967
因此可知,全局页不会随进程切换清空TLB
INVLPG指令的意义
INVLPG dword ptr ds:[0]//手动清空0线性地址对应在TLB的一条缓存。
1 |
|
手动清空缓存成功,读出新物理页的值。
中断与异常
中断
什么是中断?
中断的本质目的就是改变CPU执行的路线
中断通常被视为硬件事件驱动的机制
中断通常是由CPU外部的输入输出设备(硬件)所触发的,供外部设备通知CPU“有事情需要处理”,因此又叫中断请求(Interrupt Request)
中断请求的目的是希望CPU暂时停止执行当前正在执行的程序,转而去执行中断请求所对应的中断处理例程(中断处理程序在哪由IDT表决定)
80x86有两条中断请求线
非可屏蔽中断
非可屏蔽中断是指无法被禁用或屏蔽的中断。这类中断通常用于处理紧急情况或重要事件,确保系统能够及时响应。例如,电源故障或硬件故障的信号通常会生成非可屏蔽中断
当非可屏蔽中断产生时,CPU在执行完当前指令后会从里面进入中断处理程序
非可屏蔽中断不受EFLAG寄存器中IF位的影响,一旦发生,CPU必须 处理
非可屏蔽中断处理程序位于IDT表中的2号位置
可屏蔽中断
可屏蔽中断是指可以被 CPU 或操作系统暂时禁用的中断。这意味着在某些情况下,系统可以选择忽略这些中断请求,以确保关键任务的执行。例如,操作系统在执行关键代码时可能会禁用可屏蔽中断,以防止中断干扰;网络适配器的中断可以在数据处理时被暂时禁用;大多数中断都是可屏蔽的
在硬件级,可屏蔽中断是由一块专门的芯片来管理的,通常称为中断控制器。它负责分配中断资源和管理各个中断源发出的中断请求。为了便于标识各个中断请求,中断管理器通常用IRQ(Interrupt Request)后面加上数字来表示不同的中断。
比如:在Windows中,时钟中断(即下图的系统计时器)的IRQ编号为0,也就是:IRQ0
win10查看IRQ:
大多数操作系统时钟在10100ms之间,Windows系统为1020MS。
时钟中断只是操作系统进行线程切换的一个机会。哪怕是一个无限循环的程序,一个单核系统,CPU依然有机会线程切换。
可屏蔽中断如何处理
如果自己的程序执行时,不希望CPU去处理这些中断
- 用CLI指令清空EFLAG寄存器中的IF位
- 用STI指令设置EFLAG寄存器中的IF位
硬件中断与IDT表中的对应关系并非固定不变的,参见APIC(高级可编程中断控制器)
异常
异常通常是CPU在执行指令时检测到的某些错误,比如除0,访问无效页面等。
中断与异常的区别:
- 中断来自于外部设备,是中断源(比如键盘)发起的,CPU是被动的。
- 异常来自于CPU本身,是CPU主动产生的(cpu在执行的时候发现错误了)。
- INT N虽然被称为“软件中断”,但其本质是异常。EFLAG的IF位对INT N无效。
异常处理
无论是由硬件设备触发的中断请求还是由CPU产生的异常,处理程序都在IDT表。
常见的异常处理程序如下:
举例缺页异常
缺页异常的产生,比如:
- 当PDE/PTE的P=0时
- 当PDE/PTE的属性为只读,但程序试图写入的时候
一旦发生缺页异常,CPU会执行IDT表中的0xE号中断处理程序,由操作系统来接管。
控制寄存器CR
控制寄存器用于控制和确定CPU的操作模式
共5个 CR0,CR1,CR2,CR3,CR4
CR1-保留,CR3-页目录表基址(不同分页模式不一样)
CR0寄存器
| 位 | 功能描述 |
|---|---|
| PE | CR0的位是启用保护标志位(Protection Enable).。PE=1保护模式 PE=0实地址模式,该标志仅开启段级保护,而并没有启用分页机制。若要启用分页机制,PE和PG标志位都要置位 |
| PG | 设置该位时即开启了分页机制。在开启这个标志位之前必须已经或同时开启PE标志 |
| WP | 对于Intel 80x86或以上的CPU,CR0的位16是写保护(Write Protect)标志,当设置该标志时,处理器会禁止超级用户程序(例如特权级0的程序)向用户级只读页面执行写操作。只要是写别人的内存,不管是干嘛的,首先把这个位设置0,写完了再改回来1。 |
| AM | 管理三环下的字节对齐检查。设置为1的时候,用户态当EFLAGS寄存器中的AC标志为1时候是按照段描述的D\B位字节对齐检查的。 |
| CD | 禁止写Cache总开关,相当于PDE,PTE中的PWT和PCD位的总控 |
- PG=0且PE=0时,处理器工作在实地址模式
- PG=0且PE=1时,处理器工作在没有开启分页机制的保护模式下(目前为止没有任何一个操作系统工作在这个模式)
- PG=1且PE=0时,这种情况不存在,在PE没有开启的情况下,无法开启PG
- PG=1且PE=1时,处理器工作在开启了分页机制的保护模式下
当CPL<3的时候
- 如果WP=0 可以读写任意用户级物理页,只要线性地址有效
- 如果WP=1 可以读取任意用户即物理页,但对于只读的物理页,则不可以写
CR2寄存器
当CPU访问某个无效页面时,会产生缺页异常,此时,CPU会将引起异常的线性地址存放在CR2中。
int 0xE里面有这个函数(函数非常长)的处理流程。
CR4寄存器
| 位 | 功能描述 |
|---|---|
| PAE | PAE=1是2-9-9-12分页 PAE=0是10-10-12分页 |
| PSE | 大页的总开关,PDE中的PS位的总开关,只有当PSE为1的时候,PDE中的PS位才有意义。仅PSE和PS位都是1的时候,该PDE指向的物理页才是大页 |
| PGE | 全局页总开关,PTE和PDE的G位总开关。PGE=1时,G位才有效。把PGE设为0,进程切换就会刷新全部TLB |
控制寄存器更多的细节参考白皮书第三卷
PDE,PTE中的PWT和PCD位
cpu缓存Cache
- CPU缓存是位于CPU与物理内存之间的临时存储器,它的容量比内存小的多,但是交换速度却比内存要快得多。
- CPU缓存可以做的很大,有几K,几十K,几百K,甚至上M的也有。
CPU缓存与TLB存储的东西的区别
- TLB:线性地址《—–》物理地址
- CPU缓存:物理地址《—–》内容
关于Cache的更多细节可以了解因特尔白皮书第三卷
PWT:Page Write Through
PWT=1时。写Cache的时候也要讲数据写入内存中
PWT=1时。写Cache的时候只写入Cache
PCS:Page Cache Disable
PCD=1时,禁止某个页的写入Cache,直接写内存。
比如:做页表用的页,已经存储在TLB中了,可能不需要再缓存了,因此他们的PCS都是置1的。
保护模式阶段总结
参见因特尔白皮书第三卷的第3章到第11章
保护模式实验(两道题):
第一题
1 | 1. 给定一个线性地址,和长度,读取内容; |
残缺版,并未实现所有功能:
1 |
|
改成读取0x12345678地址:
第二题
1 | 2. 申请长度为100的DWORD的数组,且每项用该项的地址初始化; |
略
补充知识点
CPU信息查询
1 | mov eax,80000008;//通过eax设置cpuid的参数 |
支持很多cpu信息查询,包括是否支持VT也通过该指令查找
1 | mov eax,1; |






















































































































































































