保护模式

Q:什么是保护模式?

A:x86 CPU的3个模式:

  1. 实模式
  2. 保护模式(虚拟8086模式)
  3. 系统管理模式

image-20210803161045133

现在的操作系统大多数都是运行于保护模式下的

保护模式就是指给操作系统添加的保护特性,保护的目标是硬件资源和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版本安装链接跳转

要注意在单个组件中选上:

image-20210721202744811

环境配置的参考网址

【小注意点】微软官网下载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)环境就装好了。

虚拟机和符号表对应下载

双机调试配置

由于系统调试要下断点,下断点后系统将只有调试子系统继续运行,因此不能直接调试本机的系统。因此需要双机调试配置

image-20210722134228056

配置双机调试流程

  1. 在本机安装windbg(上述配环境的时候装WDK,里面自带有windbg)

  2. 在虚拟机中(WinXP)修改boot.ini(修改系统启动项)

    C盘显示隐藏文件,找到boot.ini打开

    image-20210722135330345

    原内容:

    image-20210722140422465

    新内容

    image-20210722145554924

    红线为添加行,/debug表示调试模式,第一段黄标为名字,可以随便起;/debugport=com2表示指定的调试串口为com2,下图可见原本只有com1一个串口(黄标)。com2是为后续步骤虚拟机设置步骤中添加的串口

    image-20210722141112675

    自己添加的启动项如下图黄标

    image-20210722143157677

  3. 设置虚拟机(为虚拟机新增一个串口设备)

    添加步骤:

    image-20210722142547813

    P.s.打印机也会占用一个串口

    添加好后查看设备管理器:

    image-20210722145936297

    黄标为我们刚添加的新串口。

  4. 修改Windbg运行参数,指向虚拟机。

    Windbg的选择简单参考:调试机器是32位系统就用32位windbg,64位系统就64位windbg(实则更为复杂)

    image-20210722142840195image-20210722150950760

p.s. WDK,Windows Driver Kit,用于开发驱动的驱动开发包

准备工作做好后

开始双机调试

必须在下图界面的时候先别按回车

image-20210722143157677

本机上打开设置好的windbg,显示正在连接,再到虚拟机中点击回车系统进入调试模式。

此时成功断下,效果如下:

image-20210722144832857

此时系统是断下状态,因此是类似黑屏

此时在windbg命令行中输入g,表示让系统继续执行

image-20210722145152427

之后还想中断的话,按下图按钮。

image-20210722145334493

至此双机调试配置成功!

保护模式学习的时候尽量虚拟机要设置为单核,防止干扰。

WinDbg的退出

通过 q 或者 ALT + F4 退出调试并销毁被调试进程:

1
> q

通过 qd 退出调试,但被调试进程继续运行:

1
> qd

部分使用方式

image-20210820185552204

一些细节:

  • 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的拓展,向下兼容的。

保护模式有什么特点?

  1. 段的机制
  2. 页的机制

通过这两种机制来达到保护系统的一些数据结构,还有一些关键的寄存器的目的。

  • Q:学习保护模式有什么用?
  • A:真正理解内核是如何运作的

参考书:**<Intel 白皮书第三卷>**,3,4,5,6,7章

p.s.

  1. Intel白皮书第二卷是查指令的
  2. Intel 白皮书第三卷是讲保护模式的

保护模式–段

保护模式的2种重要机制:

保护模式知识结构总览:

20140711174502103

段的机制非常复杂,想了解段的机制要先了解段寄存器。

为何需要段的机制?16位系统的寄存器为16位二进制,只可以寻址64KB(2的16次方除以1024等于64)的大小内存,左移四位再加一千来索引1MB内存,因此出现了段的机制可以解决这个问题。

由于32位系统寄存器是32位,所以上述功能(base的功能)已经被弱化了,仅留下重要的是权限检查机制。

image-20210803164459681

32位以后,现在除了FS寄存器以外,其他段寄存器的base字段已经全部设置为0了

段寄存器结构

段寄存器有哪些

ES CS SS DS FS GS LDTR TR GDTR IDTR等等

image-20210803145809961

  • 代码段寄存器CS(Code Segment)

    ​ 存放当前正在运行的程序代码所在段的段基址,表示当前使用的指令代码可以从该段寄存器指定的存储器段中取得,相应的偏移量则由IP提供。

  • 数据段寄存器DS(Data Segment)

    ​ 指出当前程序使用的数据所存放段的最低地址,即存放数据段的段基址。

  • 堆栈段寄存器SS(Stack Segment)

    ​ 指出当前堆栈的底部地址,即存放堆栈段的段基址。

  • 附加段寄存器ES(Extra Segment)

    ​ 指出当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段。

段寄存器的结构

image-20210816220946809

1
2
3
4
5
6
7
struct SegMent
{
WORD Selector; //16位Selecter(可见部分),段选择子
WORD Attributes; //12位Attribute
DWORD Base; //32位Base
DWORD Limit; //32位Limit
}

我们能看到的段寄存器的值只有可见部分的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中不使用,所以用短横杠填写。

image-20210716115523846

上图的原因是ds段寄存器此时实际上是cs寄存器,cs寄存器不可写。

把cs换成es就不会报错,因为es段是可写的

1
2
3
4
5
6
7
8
9
10
11
12
13
int var=0;
void main()
{
__asm
{
mov ax,fs
mov gs,ax
mov eax,dword ptr ds:[0x1000]
//上面相当于mov eax,dword ptr fs:[0x1000]
//由于fs的limit为0xFFF,0x1000>0xFFF,所以访问报错
mov dword ptr ds:[var],eax
}
}

写一个段寄存器的时候我们只给了16位,那么剩下的76位来自于哪里呢?

段描述符与段选择子

保护模式下三个重要的系统表——GDT、LDT和IDT

当我们执行类似MOV DS,AX指令时,CPU会查表,根据AX的值来决定查找GDT还是LDT,查表的什么位置,查出多少数据。

gdtr是一个寄存器(48位),存储的是表的开始位置(32位)和长度(16位)

GDTR寄存器中存放的是GDT在内存中的基地址和其表长界限。

GDTR-300x103

前16位是保存GDT里面的限长,后17到42位保存的是段基址

ldtr和gdtr有区别,存的是段选择子。

汇编指令LGDT和SGDT分别用于加载和保存GDTR寄存器的内容。

1
2
3
4
kd>r gdtr//查看gdtr寄存器,gdt这样表在哪里。(r表示查看寄存器)
gtdr=8003f000
kd>r gdtl//查看的也是gdtr寄存器,查看gdt表有多长。
gdtl=000003FF

image-20210716151328569

图中dd命令罗列出来的就是GDT表。

剩下的76位来自于GDT表,表里存储的元素我们叫段描述符

段描述符

每个段描述符是8个字节,即64位。

d6ca7bcb0a46f21fbe09bc4ba26d7c600c33874400db

v2-2664817822c89c64a9e8adc8c34495cc_hd

image-20210716152133226

黄色荧光笔标记出来的是图解中的低32位。

image-20210716152630988

dq是按照qword(8字节)来分组,后接的L40表示显示0x40组。

段选择子

段选择子是一个16位的段描述符,该描述符指向了定义该段的段描述符。

写段寄存器的时候写的就是段选择子。

20180914112453186

  • RPL:请求特权级别

  • TI:

    • TI=0查GDT表
    • TI=1查LDT表(Windows没有使用)

    1226829-20200409145558030-1848157450

  • INDEX: 处理器将索引值乘以8再加上GDT表的基址,就是要加载的段描述符

加载段描述符到段寄存器

除了MOV指令,我们还可以使用LES,LSS,LDS,LFS,LGS指令修改寄存器(L表示load,LES表示load ES即加载ES段寄存器)

CS不能通过上述的指令进行修改,CS为代码段,CS的改变会导致EIP实际指向的代码的改变,要改变CS,必须要保证CS与EIP一起改,后面会讲。

LES的使用案例

1
2
3
4
5
6
char buffer[6];
__asm
{
les ecx,fword ptr ds:[buffer]//fword表示6个字节,高2个字节(段选择子)给es,低四个字节给ecx
}
//提供的RPL<=DPL(数值上),才能成功

段描述符的属性

段寄存器的值是通过段描述符填充

但段描述符只有64位,如何从64位变成76位(76位是92位撇去段选择子的16位)

LIMIT多了12位。(参考下方G位的讲解)

20180914111021493

1
2
3
4
5
6
7
struct SegMent
{
WORD Selector; //16位Selecter(可见部分),段选择子
WORD Attributes; //12位Attribute
DWORD Base; //32位Base
DWORD Limit; //32位Limit
}
  • Attributes对应的是高4字节从第8位开始到第15位,第20位开始到23位结束共12位。
  • Base是【高4字节的2431位】+【高四字节的第07位】+【低四字节的16~31位】共同组成的32位
  • Limit是【高4字节的1619位】+【低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)表示此段是代码段或者数据段描述符

image-20210719173833659

TYPE域

type域的含义根据S位来变化

  1. 当S=1时

20200924170933342

TYPE域中的第一位,也就是段描述符高4字节的第11位,区分该段到底是代码段还是数据段。如果有是1,则表示代码段,如果是0,表示数据段。

SS与FS都属于数据段

这意味着下图标黄的16进制位,如果大于等于8,则表示是代码段,否则是数据段

image-20210720135717758

A 访问位:表示该位最后一次被操作系统清零后,该段是否被访问过。每当处理器将该段选择符置入某个段寄存器时,就将该位置1。

C 表示一致位

  • C=1 一致代码段
  • C=0 非一致代码段

E 拓展方向

  • E=0 向上拓展
  • E=1 向下拓展

image-20210720142837575

图中橙色部分表示段所在位置。向上拓展是从fs.Base到fs.Base+Limit的区间,而向下拓展是该区间取反的区间

825979-20180526000445198-633418260825979-20180526000556126-973886506

  1. 当S=0时

20190117135616418_

D/B位

D/B位对三种段有影响。

  1. 对CS段的影响

    ​ D=1 采用32位寻址方式

    ​ D=0 采用16位寻址方式

    ​ (前缀67 改变寻址方式 方便观察16位寻址方式是什么样的)

  2. 对SS段的影响(数据段的段描述符加载到SS段里他就是SS段了,但本质还是数据段)

    ​ D=1 隐式堆栈访问指令(如:PUSH POP CALL)使用32位堆栈指针寄存器ESP

    ​ D=0 隐式堆栈访问指令(如:PUSH POP CALL)使用16位堆栈指针寄存器SP

  3. 向下拓展的数据段

    ​ D=1 向下拓展段上限为4GB

    ​ D=0 向下拓展段上限为64KB

    image-20210720150013578

红色表示该段的区间。D=0表示上图右侧,两块红色相加为64KB。

AVL属性

AVL属性占1个比特,该属性的意义可由操作系统、应用程序自行定义。
Intel保证该位不会被占用作为其他用途。

段权限检查(CPL,RPL,DPL)

段选择子加载到段寄存器中,要进行段权限检查。

CPU分级

image-20210720164312737

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
2
3
//比如当前程序处于0环,也就是说CPL=0
Mov ax.000B //1011 RPL=3
Mov ds,ax //ax指向的段描述符的DPL=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. 段选择子拆分

    1
    2
    3
    4
    0x20	对应二进制形式	0000 0000 0010 0000
    RPL=00
    TI=0
    Index=4
  2. 查表得到段描述符

    1
    2
    (1)TI=0,所以查GDT表
    (2)Index=4,所以找到对应的段描述符(四种情况的段描述符才可以跳转:代码段,调用门,TSS任务段,任务门)
  3. 权限检查

    1
    2
    如果是非一致代码段,要求:CPL == DPL 并且 RPL <= DPL(数值上)
    如果是一致代码段,要求:CPL >= DPL(数值上)

    ​ 一致代码段(又名共享段)的理解:这个代码段是提供一些功能让应用层直接可以访问而不破坏内核的代码,就用一致代码段来修饰。

    ​ 反之不想应用层访问,就用非一致代码段修饰。

  4. 加载段描述符

    ​ 通过上面的权限检查后,CPU会将0x20段选择子对应的段描述符加载到CS段寄存器中

  5. 代码执行

    ​ CPU将CS.Base+Offset的值写入EIP,然后执行CS:EIP处的代码,段间跳转结束

【总结】

对于一致代码段,也就是共享的段

  • 特权级高的程序不允许访问特权级低的数据:核心态不允许访问用户态的数据
  • 特权级低的程序可以访问到特权级高的数据,但特权级不会改变:用户态还是用户态

对于普通代码段,也就是非一致代码段

  • 只允许同级访问
  • 绝对禁止不同级别的访问:核心态不能访问用户态,用户态不能访问核心态。

直接对代码段进行JMP或者CALL的操作,无论目标是一致代码段还是非一致代码段,CPL都不会发生改变。如果要提升CPL的权限,只能通过调用门

【最终总结】

  1. 为了对数据进行保护,普通代码段是禁止不同级别进行访问的。用户态的代码不能访问内核的数据,同样,内核态的代码也不能访问用户态的数据。
  2. 如果想提供一些通用的功能,而且这些功能并不会破坏内核数据,那么可以选择一致代码段,这些低级别的程序可以在不提升CPL权限等级的情况下既可以访问。
  3. 如果想访问普通代码段,只有通过“调用门”等提升CPL权限,才能访问。

实验

image-20210803172413552

黄标表示如何查找gdt表。

eq表示往某地址写入某内存。

image-20210803202153149

虚拟机中xp系统中的OD进行测试

image-20210803202642190

004B表示段选择子,index为9(二进制1001),索引的段寄存器正是上上图黄标位置的段描述符。

单步执行看是否可以跳转到44E082地址

image-20210803203101666

成功跳转。

将段描述符的RPL修改为0环。此时预测权限验证应该会失败。

image-20210803203453670

修改后,再次尝试跳转

image-20210803203741984

单步执行

image-20210803204001574

按shift+F7

image-20210803204042928

进入ntdll了,即进入异常模块了。和预期的结果一致,权限验证失败。

将对应段描述符修改为一致代码段

image-20210803204413648

od中执行同一条指令:

image-20210803204541096

单步执行

image-20210803204617396

成功跳转,一句话总结实验:低权限(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。

image-20210804140440130

长调用(跨段不提权)

指令格式:CALL CS:EIP(EIP是废弃的)

【注意】:长调用的调用地址并不是由EIP决定的,而是通过CS段选择子找到段描述符(该段描述符必须是一个调用门)算出来的

image-20210804140726695

发生改变的寄存器:ESP EIP CS

通过长调用执行完代码后是通过长返回RETF返回。返回的时候,会将上图红色的调用者CS重新赋值给CS段寄存器

长调用(跨段并提权)

指令格式:CALL CS:EIP(EIP是废弃的)

image-20210804141821855

ESP3表示当前执行的权限为3环。堆栈发生了切换,右边的堆栈已经不是左边的堆栈了,而是一个零环的堆栈了

发生改变的寄存器:ESP EIP CS SS

image-20210804142949147

【总结】

  1. 跨段调用时,一旦有权限切换,就会切换堆栈
  2. CS的权限一旦改变,SS的权限也要随着改变,CS与SS的等级必须一样
  3. JMP FAR只能跳转到同级非一致代码段,但CALL FAR可以通过调用门提权,提升CPL的权限。

SS与ESP从哪里来?参见TSS段。

调用门

调用门执行六项功能:

  1. 它指定要访问的代码段。
  2. 它为指定代码段中的过程定义了一个入口点。
  3. 它指定尝试访问过程的调用者所需的权限级别。
  4. 如果发生堆栈切换,则指定要在堆栈之间复制的可选参数的数量。
  5. 它定义了要推送到目标堆栈上的值的大小:16 位门强制 16 位推送,32 位门强制 32 位推送。
  6. 它指定调用门描述符是否有效。

调用门(无参)

调用门最大的好处就是提权,但提权的方式不仅仅是调用门

门描述符的结构

196406-20191230101816343-1359964647

门描述符是系统段描述符的一类,所以S字段必须是0,type域为1100表示调用门。

调用门中存储了另一个代码段段的选择子指的就是上图段选择符字段

【重点】调用门真正要调用的地址:段选择符中存的段选择子指向的那个段描述符中的base + 上图中的两段段中偏移值拼接而成的值

不同门的TYPE:

196406-20191230101855524-2036155359

调用门指令流程

为了访问调用门,在 CALL 或 JMP 指令中提供了一个指向该门的远指针作为目标操作数。来自该指针的段选择器标识了调用门(JMP还未亲自实验过);指针的偏移量(下面的EIP)是必需的,但不被处理器使用或检查。(偏移量可以设置为任何值。)

指令格式:JMP/CALL CS:EIP(EIP是废弃的)

执行步骤:

  1. 根据CS的值查GDT表,找到对应的段描述符,这个描述符是一个调用门。
  2. 在调用门描述符中存储另一个代码段段的选择子
  3. 选择子指向的段 段.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内并没有使用调用门

所以自己构造一个调用门(无参,提权)

image-20210804145505767

1
2
3
4
5
6
7
8
//x部分表示还不确定的部分。
32位:xxxx xxxx xxxx xxxx 1(P) 11(DPL) 0 1100 0000 0000
//p为1表示该段描述符有效,DPL为11表示三环,否则三环测试连敲门的权限都没有了。
3216进制:XXXXEC00

32位:(xxxx xxxx xxxx xxxx) (xxxx xxxx xxxx xxxx)
//段选择符指向的段描述符如果是比当前CPL小的DPL的则表示要提权,否则表示无需提权。
3216进制:XXXXXXXX

查看段描述符

image-20210804163643397

尝试用调用门调用上图黄标的段描述符的零环权限来提权

所以构造的调用门低32位16进制为:0008XXXX(index为1,指向的正是上图黄标段描述符)

虚拟机上的xp系统上执行如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>

void __declspec(naked) GetRegister()
{
_asm
{
int 3//断点,由于是通过调用门提权为零环,该代码段和内核同权限,所以会断到windbg中,而不是断在IDE。
retf
}
}

int main(int argc, char* argv[])
{
char buff[6];//下断点!!!!!!!!!!!!!!!!!!!!!
*(DWORD*)&buff[0]=0x12345678;//随便输入
*(WORD*)&buff[4]=0x48;//段选择子,0x48对应的段描述符为空,所以用此位置装手动添加的调用门段描述符
_asm
{
call fword ptr[buff]
}
getchar();
return 0;
}

在上述代码的下断点!!!!!!!!!!!!!!!!!!!!!处下断点。然后执行代码断到此处断点,进入反汇编找到GetRegister裸函数的函数地址。

image-20210804170022297

上图黄标可知跳转目标地址为00401010

所以构造的调用门段描述符为0040EC00`00081010

windbg下断点,修改gdt中0x48段选择子对应的位置内容为0040EC00`00081010

image-20210804170558502

执行call fword ptr[buff]代码前的寄存器情况:

image-20210804170729597

执行call fword ptr[buff]代码后,成功跳转了GetRegister裸函数代码中的int 3,所以windbg断了下来。

image-20210804170914485

在00401010地址处因为int 3断了下来。

发现寄存器窗口为空,查明是有bug解决方案(参考命令:!WingDbg.regfix)

此时windbg断下,查看windbg的寄存器显示(与执行call fword ptr[buff]前的寄存器比较):

image-20210804181052839

对应颜色画笔标记为修改了的部分,其中ESP从0012FF28修改为B2B69DD0,直接从低2G空间跳转到高2G内核空间。CS变为8是因为我们在调用门描述符中设置为跳转到08段选择子对应的段描述符。

有部分其他寄存器也被改了,原因是系统底层代码写死了的部分修改,另外还有int 3的干扰。

windbg此时查看B2B69DD0处堆栈

image-20210804181623445image-20210804181720817

到此已验证完毕!

将代码修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>
BYTE GDT[6]={0};
DWORD dwH2GValue;

void __declspec(naked) GetRegister()
{
_asm
{
//此代码段的代码有内核权限,可以直接访问高2G内存空间。
pushad
pushfd

mov eax,0x8003f00c//读取高2G内存
mov ebx,[eax]
mov dwH2GValue,ebx
sgdt GDT;//sdgt指令的含义为读取gdtr寄存器(读取出6个字节的数据,两个字节的limit和4个字节的GDT起始地址)(该指令在三环也能使用)

popfd
popad
retf//注意长返回
}
}

int main(int argc, char* argv[])
{
char buff[6];//下断点!!!!!!!!!!!!!!!!!!!!!
*(DWORD*)&buff[0]=0x12345678;//随便输入
*(WORD*)&buff[4]=0x48;//段选择子,0x48对应的段描述符为空,所以用此位置装手动添加的调用门段描述符
_asm
{
call fword ptr[buff]
}
DWORD GDT_BASE=*(PDWORD)(&GDT[2]);//GDT表的地址
WORD GDT_LIMIT=*(PDWORD)(&GDT[0]);//GDT表有多长
printf("%x %x %x\n",dwH2GValue,GDT_BASE,GDT_LIMIT);
getchar();
return 0;
}

由于代码修改了,所以GetRegister函数的首地址也修改了,因此要修改调用门段描述符的段中偏移值。

image-20210804183542660

构造的调用门段描述符为0040EC00`00081020

修改:

image-20210804183704599

执行代码,不能用单步执行的方式执行,而是直接不断点按F5

结果如图:

image-20210804201255485

实验成功,提权成功!

调用门(有参)

调用门有权限切换时堆栈变化:

image-20210807195216123

R3堆栈的参数1,2,3需要手动push,如果没有手动push,则R3堆栈不存在参数1,2,3。R0堆栈的参数1,2,3取决于调用门描述符中的参数个数,没有手动push的话,R0堆栈的参数1,2,3均为0。

实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>

DWORD x;//用来存传入的参数1
DWORD y;//参数2
DWORD z;//参数3

void __declspec(naked) CateProc()
{
_asm
{
pushad
pushfd
mov eax,[esp+0x24+0x8+0x8]//读参数赋值给x,y,z
mov dword ptr ds:[x],eax
mov eax,[esp+0x24+0x8+0x4]
mov dword ptr ds:[y],eax
mov eax,[esp+0x24+0x8]
mov dword ptr ds:[z],eax
popfd
popad
retf 0xC //注意堆栈平衡,写错直接蓝屏。三个参数,所以为参数平栈为0xC
}
}
int main()
{
char buff[6];
*(DWORD*)&buff[0]=0x12345678;//断点!!!!!!!!!!!!!!!
*(WORD*)&buff[4]=0x48;
_asm
{
push 1
push 2
push 3
call fword ptr[buff]
}
printf("%x,%x,%x\n",x,y,z);
getchar();
return 0;
}

断点!!!!!!!!!!!!!!!处断点,查看断点处地址

image-20210804230009598

构造调用门段描述符为0040EC03`00081020(3表示3个参数)

修改gdt第10个段描述符:

image-20210804230204980

修改后F5执行

image-20210804230311195

CateProc函数头加个int 3,来查看一下堆栈情况。

image-20210804230456028

地址未变,段描述符不需要改,直接执行,windbg执行到int 3断下

image-20210804230842907

此时windbg中查看堆栈情况:

image-20210804231548046

int 3断下时候的堆栈结构

image-20210804232559040

pushad和pushfd后的堆栈结构(参数此时的相对ESP位置结构):

image-20210804232907329

【总结】

  1. 当通过门,权限不变的时候,只会PUSH两个值:CS和返回地址。新的CS的值由调用门决定。
  2. 当通过门,权限改变的时候,会PUSH四个值,SS,ESP,CS,返回地址,新的CS的值由调用门决定,新的SS和ESP由TSS提供
  3. 通过门调用时,要执行哪行代码由调用门决定,但使用RETF返回时,由堆栈中压入的值决定,这就是说,进门时只能按照指定路线走,出门时可以翻墙(只要改变堆栈里面的值就可以想去哪去哪)
  4. 可不可以再建个门出去呢?当然可以了,前门进,后门出

中断门

Windows没有使用调用门,但是使用了中断门。

windows系统使用了中断门的两种情况:

  1. [[系统调用]](老cpu使用中断门,新cpu已经不使用中断门了,而是使用快速调用)
  2. 调试(软件断点int 3就是用来执行中断门的)

image-20210806163434767

  • 键盘鼠标显示器等外部设备都是可屏蔽中断
  • 电源等是不可屏蔽中断,无法操作。
  • 现在的中断大多都是用的APIC实现,APIC(高级可编程中断控制器)编程强一些。

执行调用门的指令:CALL CS:EIP

但当CPU 执行如下指令:INT N(int 0表示0号中断;int 1表示1号中断…)

查询的却是另一张表,这张表叫IDT

中断指令

$$
INT\ N
$$

其中,N是索引,X*8+IDT的基址 就是具体的中断门描述符

保护模式下的中断和异常表解释

2018121811050612

终止就是蓝屏

中断门的堆栈和返回

  1. 在没有权限切换时,会向堆栈PUSH3个值,分别是:CS EFLAG EIP(返回地址)
  2. 在有权限切换时,会向堆栈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表的基址和长度:

image-20210806113112935

IDT表的构成

IDT表可以包含3种门描述符

  1. 任务门描述符
  2. 中断门描述符
  3. 陷阱门描述符

中断门描述符

image-20210607203904224

高4字节的0~4位固定为0。

64进制中断门描述符表示:XXXXEE00`XXXXXXXX

中断门提权实验

参考流程图

image-20210806171727490

提权与否取决于:中断门描述符的段选择子指向的段描述符的DPL

20181213133543199

实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>


void __declspec(naked) test()
{
_asm
{
int 3
iretd//中断门要用这个返回。如果不是裸函数的话写iret也可以,因为IDE会根据D/B位调成iretd
}
}
int main()
{
_asm
{
//32是后续知道idt的空位后回来补上的。IDT表中的第33个中断描述符(编号从0开始)
int 32;//断点!!!!!!!!!!!!!!
}
getchar();
return 0;
}

断点!!!!!!!!!!!!!!处断下反汇编查看test的函数地址。

image-20210806194755824

在IDT中找空的位置构造中断门描述符(空位如下图)

image-20210806195008729

构造的中断门描述符:0040EE00`00081020

image-20210806195231834

中断门的段选择子0x0008指向的是DPL为0的内核代码段。

int32之前的堆栈情况:

image-20210806195925841

断到int3上:

image-20210806200039623

查看寄存器变化:

image-20210806200514018

有部分其他寄存器也被改了,原因是系统底层代码写死了的部分修改,另外还有int 3的干扰。

查看堆栈:

image-20210806201413231image-20210806202256635

调用中断门的R3堆栈没有变化。

image-20210811145502207

调用门与中断门的区别

  1. 调用门通过CALL FAR指令执行,但中断门通过INT指令
  2. 调用门查询GDT表,中断门查询IDT表
  3. CALL CS:EIP中的CS是段选择子,由3部分组成。但INT N指令中的N只是索引,中断门不检查RPL,只检查CPL
  4. 调用门可以有参数,但中断门没有参数

【重点理解】各种返回加深理解

iret可以理解为:

1
2
3
pop eip
pop cs
popfd

各种返回

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>

void __declspec(naked) test()
{
_asm
{
RETF 0x4//中断门用RETF返回(反其道行之)

}
}
int main()
{
printf("%x",&test);
getchar();
_asm
{
int 32;//32编号对应位置的中断门段描述符为0040ee00`0008100f
sub esp,4//因为RETF在返回后还会再add ESP,4(根据RETF 0x4)
}
getchar();
return 0;
}
调用门用iretd 返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>
void __declspec(naked) test()
{
_asm
{
iretd//调用门用iretd返回(反其道行之)
}
}
int main()
{
char buff[6]={0x78,0x56,0x34,0x12,0x48,0};//指向的调用门描述符为0040EC01`0008100F
printf("%x",&test);
getchar();
_asm
{
call fword ptr[buff];
}
getchar();
return 0;
}

陷阱门

image-20210808163723779

陷阱门段描述符:XXXXEF00`XXXXXXXX

陷阱门与中断门几乎一样,陷阱门与中断门唯一的区别:

中断门执行时,会将IF位清零,但陷阱门不会。

IF位:eflags下标为9的位置。

1342712354_1402

IF的含义:

IF标志用于控制处理器对可屏蔽中断请求的响应。置1以响应可屏蔽中断,反之则禁止可屏蔽中断。

  • IF=0 CPU不再接受可屏蔽中断
  • IF=1 CPU接受可屏蔽中断

p.s.不可屏蔽中断不受IF位影响,比如说断电,就是电源通过电源管理器向CPU发送一个请求,这就是一个不可屏蔽中断。(CPU有电容,即使是断电了也能跑一会儿,执行一些清理工作)

通过中断门与陷阱门打印EFLAG寄存器的值

  • 执行前:216
  • 执行中
    1. 陷阱门:216
    2. 中断门:16

任务段TSS(难点非重点)

在调用门,中断门与陷阱门中,一旦出现权限切换,那么就会有堆栈的切换。而且,由于CS的CPL发生改变,也导致了SS也必须要切换。(CS和SS的权限级别永远都是一致的)

切换时,会有新的ESP和SS(CS是由中断门或者调用门指定)这2个值从哪里来的呢?

答案:TSS(Task-state segment),任务状态段。

TSS和TR寄存器

一块大于等于104字节的内存结构。(强调:不在CPU中,就是内存中)

196406-20200102102107308-1624147754

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表的地址和大小。

48588_1282618334lclL

LDT和GDT从本质上说是相同的,只是LDT嵌套在GDT之中。LDTR记录局部描述符表的起始位置,与GDTR不同LDTR的内容是一个段选择子。由于LDT本身同样是一段内存,也是一个段,所以它也有个描述符描述它,这个描述符就存储在GDT中,对应这个描述符也会有一个选择子,LDTR装载的就是这样一个选择子。LDTR可以在程序中随时改变,通过使用lldt指令。如上图,如果装载的是Selector 2则LDTR指向的是表LDT2。

LDT描述符

image-20210813135407001

windows 32位以后实际上没有使用LDT。因为用不着这么多段了

更多关于LDT的内容可以参考此处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
nt!_KTSS
+0x000 Backlink : Uint2B
+0x002 Reserved0 : Uint2B
+0x004 Esp0 : Uint4B
+0x008 Ss0 : Uint2B
+0x00a Reserved1 : Uint2B
+0x00c NotUsed1 : [4] Uint4B
+0x01c CR3 : Uint4B//重点寄存器CR3,后续会讲
+0x020 Eip : Uint4B
+0x024 EFlags : Uint4B
+0x028 Eax : Uint4B
+0x02c Ecx : Uint4B
+0x030 Edx : Uint4B
+0x034 Ebx : Uint4B
+0x038 Esp : Uint4B
+0x03c Ebp : Uint4B
+0x040 Esi : Uint4B
+0x044 Edi : Uint4B
+0x048 Es : Uint2B
+0x04a Reserved2 : Uint2B
+0x04c Cs : Uint2B
+0x04e Reserved3 : Uint2B
+0x050 Ss : Uint2B
+0x052 Reserved4 : Uint2B
+0x054 Ds : Uint2B
+0x056 Reserved5 : Uint2B
+0x058 Fs : Uint2B
+0x05a Reserved6 : Uint2B
+0x05c Gs : Uint2B
+0x05e Reserved7 : Uint2B
+0x060 LDT : Uint2B
+0x062 Reserved8 : Uint2B
+0x064 Flags : Uint2B
+0x066 IoMapBase : Uint2B
+0x068 IoMaps : [1] _KiIoAccessMap
+0x208c IntDirectionMap : [32] UChar

TSS的地址就是TSS段描述符描述的基地址,因此我们通过 dg tr 查看其Base为 80042000。对于查看TSS段,有一个单独的指令。

1
2
dg tr
dt _KTSS 80042000//Base

image-20210813144727834image-20210813144750275

TSS的作用

CPU层面的任务就是系统层面上的线程

Intel CPU设计思想的初衷:TSS就是实现任务切换(操作系统中也就是线程切换,CPU中没有线程的概念)

操作系统的设计思想:TSS的任务切换在操作系统中其实就是同时换掉”一堆“寄存器,与线程切换无关。windows和linux系统实际上只使用了ss和esp.

本质:不要把TSS与“线程切换“联系到一起,TSS的意义仅在于可以同时换掉”一堆“寄存器

CPU是如何找到TSS的

image-20210808175145728

任务寄存器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中。

123

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寄存器

  1. 在Ring0,我们可以通过LTR指令去修改TR寄存器

  2. 在Ring3,我们可以通过CALL FAR或者JMP FAR指令来修改(不但改TR寄存器,还会通过TSS改所有的寄存器)

    12313213213213

  • 用JMP去访问一个代码段的时候,改变的是CS和EIP:

    ​ JMP 0x48:0x123456,如果0x48是代码段

    ​ 执行后:CS->0x48 EIP->0x123456

  • 用JMP去访问一个任务段的时候

    ​ 如果0x48是TSS段描述符,先修改TR寄存器,再用TR.Base指向的TSS中的值修改当前的各种寄存器(TSS涉及的寄存器)

CALL FAR或者JMP FAR一个任务段描述符的不同点(重点)

  1. 第一个不同点在于Previous Task Link
    • CALL FAR调用了TSS段之后,Previous Task Link会被填入前一个TSS段的段选择子
    • JMP FAR调用了TSS段之后,Previous Task Link不会被改变。
  2. 第二个不同点在于nt位(EFLAGS的第14位)
    • CALL FAR调用了TSS段之后,nt位会置一
    • JMP FAR调用了TSS段之后,nt位清0

nt位(EFLAGS的第14位)(保护模式下)的理解:

  1. NT=0时,iret为中断返回,会在堆栈中找返回值返回
  2. NT=1时,iret不是中断返回,会找TSS中的Previous Task Link返回

nt位(eflags有部分位)不允许被应用程序所修改

注意:int也会把nt位清零(无法实验证实)。所以call的方式调用任务段跳转到代码后用int会导致蓝屏,而jmp却可以。

20181217172756828

jmp call 中断详解(对理解有一定帮助)

TSS段描述符实验

不管跳几环,TSS中一定要改ss,cs,fs这三个段寄存器

使用CALL和JMP去访问一个任务段,并能正确返回。

CALL实验返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>
DWORD dwOK;
DWORD dwESP;
DWORD dwCS;

void __declspec(naked) test()
{
//dwOK = 1;
_asm
{
//int 3
mov eax,esp;
mov dwESP,eax;
mov ax,cs;
mov word ptr[dwCS],ax;
iretd
}
}
int main()
{
char bu[0x10]={0};
int iCr3;
printf("input CR3:\n");
scanf("%x",&iCr3);//通过windbg工具!process 0 0指令获取

DWORD iTss[0x68]={//用栈空间构造TSS,TSS段描述符中的Base填该数组的首地址
0x00000000,//link
0x00000000,//esp0 也可填((DWORD)bu)-0x10,照常运行
0x00000000,//ss0
0x00000000,//esp1
0x00000000,//ss1
0x00000000,//esp2
0x00000000,//ss2
(DWORD)iCr3,//cr3
(DWORD)test,//eip 此处要填入跳转位置的地址
0x00000000,//eflags
0x00000000,//eax
0x00000000,//ecx
0x00000000,//edx
0x00000000,//ebx
((DWORD)bu)-0x10,//esp -0x10是因为esp是往小地址升栈的。
0x00000000,//ebp
0x00000000,//esi
0x00000000,//edi
0x00000023,//es
0x00000008,//cs 0x1B(cs段选择子决定是否升了权限,由于0x8选择子指向的段描述符是0环权限,所以此处是提权了的)
0x00000010,//ss 0x23
0x00000023,//ds
0x00000030,//fs 0x3B
0x00000000,//gs
0x00000000,//ldt(如果想使用LDT的话,可以自己构造LDT使用)
0x20ac0000 //I/O Map Base Address
};
/*__asm{
int 0x20
}*/
printf("%x\n",iTss);
char buff[6]={0x78,0x56,0x34,0x12,0x48,0};
_asm
{
call fword ptr[buff];
}
printf("ok=%d ESP=%x CS=%x \n",dwOK,dwESP,dwCS);
return 0;
}

在windbg中断下,输入!process 0 0指令,根据程序名查看CR3:

image-20210814132256597

g放开内核断点,控制台输入上图黄标,得到iTss的地址,长调用之前调试器断下:

image-20210814132349732

windbg再次断下,构造TSS段描述符:0000E912`FDCC0068

image-20210814132525309

修改好后,确保iTss字符数组首地址不会变的时候,不下断点重新运行(否则会出现如下图的单步运行异常),填入新的CR3

image-20210814133359037

p.s.单步运行异常是可以用windbg单步调试的状态,p指令单步走,r指令查看寄存器。

输入CR3时的寄存器情况

image-20210814133524278

重新运行结果:(下图左边寄存器为返回后的值)

image-20210814134241466

再次运行程序在test函数头加int3观察

image-20210814140654925

与我们的修改完全一致,只是如果在test函数头加int3,会导致蓝屏,无法正常返回。

后面的实验发现CALL过去的方式不能在test函数头加int3(蓝屏)是因为int会将nt位清0,导致iretd在堆栈中找返回值(实则没有,所以蓝屏)

可以在CALL实验中将test函数中的int 3用pushfd和popfd包裹起来(防止int 3修改eflags寄存器),避免蓝屏。(已实验证实可以)

JMP过去的方式可以加int 3不蓝屏。

JMP实验返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include"stdafx.h"
#include <stdio.h>
#include <windows.h>
char trs[6]={0};

void __declspec(naked) test()
{
__asm
{
jmp fword ptr trs;
//iretd;
}
}

int main(int argc,char * argv[])
{

char stack[100]={0};
DWORD cr3=0;
printf("cr3:");
scanf("%X",&cr3);

DWORD tss[0x68]={
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
cr3,
(DWORD)test,
0,
0,
0,
0,
0,
((DWORD)stack) - 100,
0,
0,
0,
0x23,
0x08,
0x10,
0x23,
0x30,
0,
0,
0x20ac0000
};
printf("%X\n",tss);
WORD rs=0;
_asm
{
str ax;//str指令为保存当前TSS的段选择子到ax中
mov rs,ax;
}
*(WORD*)&trs[4]=rs;//保存跳回来的jmp的目标段选择子和目标地址
char buf[6]={0,0,0,0,0x48,0};
__asm
{
jmp fword ptr buf;
}

printf("zsaddfsafdsa\n");
return 0;
}

image-20210814141149228

构造tss段描述符:0000E912`FD780068

1
eq gdtr+0x48 0000E912`FD780068

获取CR3后输入,成功执行

image-20210814142148937

JMP实验中test头添加int观察实验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include"stdafx.h"
#include <stdio.h>
#include <windows.h>
char trs[6]={0};

void __declspec(naked) test()
{
__asm
{
int 3//!!!!和上个实验唯一的区别:添加int 3!!!!!
jmp fword ptr trs;
//iretd;
}
}

int main(int argc,char * argv[])
{
char stack[100]={0};
DWORD cr3=0;
printf("cr3:");
scanf("%X",&cr3);
DWORD tss[0x68]={
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
0x0,
cr3,
(DWORD)test,
0,
0,
0,
0,
0,
((DWORD)stack) - 100,
0,
0,
0,
0x23,
0x08,
0x10,
0x23,
0x30,
0,
0,
0x20ac0000
};
printf("%X\n",tss);
WORD rs=0;
_asm
{
str ax;//str指令为保存当前TSS的段选择子到ax中
mov rs,ax;
}
*(WORD*)&trs[4]=rs;//保存跳回来的jmp的目标段选择子和目标地址
char buf[6]={0,0,0,0,0x48,0};
__asm
{
jmp fword ptr buf;
}
printf("zsaddfsafdsa\n");
return 0;
}

正常执行。

任务门

这里主要介绍如何通过任务门去访问任务段

有了任务段为什么还要有任务门?

答:任务门为异常(INT)提供了可切换任务的机制,是一种被动的机制,而单纯的任务段必须被主动调用(CALL JMP)

任务门描述符可以放在GDT,LDT和IDT中

任务门描述符

image-20210810155043085

TSS Segment Selector指向一个TSS段描述符。

任务门描述符的构造:0000E500`XXXX0000

任务门的执行过程

INT N—>查IDT表,找到任务门描述符—>通过中断门描述符,查GDT表,找到任务段描述符—>使用TSS段中的值修改寄存器—>IRETD返回。

任务门描述符不一定在IDT中。多个任务门可以指向同一个TSS段描述符(如下图)

image-20210810155400565

课后练习:实现任务门进1环。

实验流程图:

image-20210816225541578

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>

void __declspec(naked) test()
{
_asm
{
iretd
}
}
int main()
{
char bu[0x10]={0};
int iCr3;
printf("input CR3:\n");
scanf("%x",&iCr3);//通过windbg工具!process 0 0指令获取

DWORD iTss[0x68]={//用栈空间构造TSS,TSS段描述符中的Base填该数组的首地址
0x00000000,//link
0x00000000,//esp0
0x00000000,//ss0
((DWORD)bu)-0x10,//esp1 esp1必须修改,不然会蓝屏(不知为何任务段处做的零环实验,esp0可以不填)
0x00000000,//ss1
0x00000000,//esp2
0x00000000,//ss2
(DWORD)iCr3,//cr3
(DWORD)test,//eip 此处要填入跳转位置的地址
0x00000000,//eflags
0x00000000,//eax
0x00000000,//ecx
0x00000000,//edx
0x00000000,//ebx
((DWORD)bu)-0x10,//esp -0x10是因为esp是往小地址升栈的。
0x00000000,//ebp
0x00000000,//esi
0x00000000,//edi
0x00000023,//es
0x00000091,//cs 0x1B(cs段选择子决定是否升了权限,由于0x91选择子指向的段描述符是1环权限,所以此处是提权了的)
0x00000099,//ss 0x23
0x00000023,//ds
0x000000A9,//fs 0x3B
0x00000000,//gs
0x00000000,//ldt(如果想使用LDT的话,可以自己构造LDT使用)
0x20ac0000 //I/O Map Base Address
};
/*__asm{
int 0x20
}*/
printf("iTss:%x\n",iTss);
_asm
{
int 0x20;//放入任务门
}
return 0;
}

构造任务门放进IDT中第33个位置,即int 0x20对应的位置

1
eq 8003f500 0000E500`00480000

image-20210816223122790

在GDT的0x48位置构造TSS段描述符

image-20210816213131908

1
eq 8003f048 0000E912`fdcc0068

将原本0x1B选择的CS0x23选择的SS0x3B选择的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表:

image-20210816223047078

成功执行:

image-20210816223308855

逆向int 8实验

下图第三题

image-20210810152110148

windbg指令U

1
U  //这个命令主要用于反汇编某个地址,其后面可以跟函数名和地址。

uf命令可以看到跳转等的下文。(更有用)

实验流程图

image-20210816174502207

2018121811050612

查看idt表吗,下图黄标为int 8的中断门描述符

image-20210816170139162image-20210810155043085

0x85拆解为1000 0101b,type为0101b,发现int 8指向的是任务门描述符。可知TSS段选择子为0x0050,拆解为:0101 0000b,IT位为0,因此在gdt表中找gdtr+0x50的位置为TSS段描述符(如下图黄标)

image-2021081617135760867da19ce25a32b127b6e42f82ed6b0a3

由TSS段描述符可知,TSS内存的所在位置为0x8054af00。

查看TSS内存:

196406-20200102102107308-1624147754

image-20210816173952057image-20210816174123638

上图黄标为EIP地址,0x805404ce。

windbg反汇编EIP地址,则找到了int 8将执行的目标代码:

image-20210816190000468

黄标就是蓝屏

CLI表示禁止中断发生,STL允许中断发生

IDA中找该函数:

image-20210816190714434

image-20210816190840273

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
kd> uf 0x805404ce
nt!KiTrap08:
805404ce fa cli //IF位清零,即禁止中断发生
805404cf 8b0d3cf0dfff mov ecx,dword ptr ds:[0FFDFF03Ch]
805404d5 8d4150 lea eax,[ecx+50h]
805404d8 c6400589 mov byte ptr [eax+5],89h//改变任务段描述符状态为空闲,试图修改TSS段描述符中type中的忙位为0,
805404dc 9c pushfd
805404dd 812424ffbfffff and dword ptr [esp],0FFFFBFFFh//NT清0,不清楚意义何在
805404e4 9d popfd
805404e5 a13cf0dfff mov eax,dword ptr ds:[FFDFF03Ch]//eax是GDT表首地址
//下面几步取TSS内存基址放入ecx中
805404ea 8a6857 mov ch,byte ptr [eax+57h]
805404ed 8a4854 mov cl,byte ptr [eax+54h]
805404f0 c1e110 shl ecx,10h
805404f3 668b4852 mov cx,word ptr [eax+52h]//此后ecx是gdtr+0x50的任务段描述符中的tss地址
805404f7 a140f0dfff mov eax,dword ptr ds:[FFDFF040h]//似乎无用
805404fc 890d40f0dfff mov dword ptr ds:[0FFDFF040h],ecx//gdtr+0x50的任务段描述符中的tss地址放入[当前KPCR存TSS地址的地址中]

nt!KiTrap08+0x34://!!!!!!!!!!!!!!!!!
80540502 6a00 push 0
80540504 6a00 push 0
80540506 6a00 push 0
80540508 50 push eax
80540509 6a08 push 8
8054050b 6a7f push 7Fh
8054050d e8048dfbff call nt!KeBugCheck2 (804f9216)
80540512 ebee jmp nt!KiTrap08+0x34 (80540502) Branch//循环至!!!!!!!!!!!!!!!!!

上面代码分析不一定正确。

p.s.我取了内核态和用户态FS的值,在内核态FS=0x30, 在用户态FS=0x3B。

远程执行思路图:

image-20210817122234616

略(未实验)

LDT相关

LDT段描述符结构

20200220223113225

LDTR结构

20200221184557778

与其他结构的关系

20200221211315395

自己构建LDT表和其中的数据段描述符实验

  1. LLDT
    LLDT的作用是装载局部描述符表寄存器LDTR。
  2. SLDT
    SLDT的作用是读取局部描述符表寄存器LDTR中的内容读取出来并存储。
  3. LGDT
    LGDT的作用是装载全局描述符表寄存器GDTR。
  4. SGDT
    SGDT的作用是读取全局描述符表寄存器GDTR中的内容读取出来并存储。

LDT为什么叫局部描述符,是因为和程序挂钩,也就是用本程序的CR3才能获取到地址,所以应该放到三环。想看的话只能通过物理地址去看。

别的程序访问效果如下:

image-20210816135130139

实验代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include"stdafx.h"
#include <stdio.h>
#include <windows.h>

unsigned char ldtTable[0x3ff]={0};
unsigned char gdtr[6]={0};

__declspec(naked) void test()
{
__asm
{
pushad;//[去掉不影响实验成功]
pushfd;//[去掉不影响实验成功]
lea eax,[gdtr+2];//读gdtTable的首地址所在地址
mov eax,[eax];//读gdtTable的首地址
//------开始构造ldt段描述符低4字节-------
lea eax,[eax+0x90];//eax为gdtTable的首地址+0x90的地址
lea ecx,ldtTable;//ecx存自己构建的ldtTable的首地址
mov bx,cx;//存到bx中,只取cx就够了
shl ebx,0x10;//左移16位(补零)
mov bx,0x03ff;//ebx为[cx]+03ff
mov dword ptr ds:[eax],ebx;//构造好的ebx放进[gdtTable的首地址+0x90的地址]
//------开始构造ldt段描述符高4字节-------
lea eax,[eax+4];//eax调整为[gdtTable的首地址+0x94的地址]
shr ecx,0x10;//将存放着自己构建的ldtTable的地址右移16位,即只保留高位的16位
mov byte ptr ds:[eax],cl;//cx低8位放进[gdtTable的首地址+0x94的地址]
mov byte ptr ds:[eax+1],0xe2;//0xe2放到[gdtTable的首地址+0x95的地址] 0xE2的拆解:P=1 DPL=11 S=0 TYPE=0010
mov byte ptr ds:[eax+4],ch;//cx高8位放进[gdtTable的首地址+0x98的地址]
//-------ldt段描述符高4字节为[cl]+0xe2+0x00+[ch]------
mov ax,0x93;
lldt ax;//加载段选择子为0x93的ldt段描述符
popfd;//[去掉不影响实验成功]
popad;//[去掉不影响实验成功]
retf;//调用门提权返回
}
}

int main(int argc,char * argv[])
{
char buf[]={0,0,0,0,0x48,0};
char cldtr[]={0};
int a=10;
int b=0;
*((unsigned int *)(ldtTable+8))=0x0000ffff;//构造ldt表中的数据段描述符
*((unsigned int *)(ldtTable+0xc))=0x00cFF300;//0x00cFF300是三环,0x00cf9300为0环

printf("%X,ldtTable=%X\n",test,ldtTable);

__asm
{
sgdt gdtr;
push fs;//[去掉不影响实验成功]
call fword ptr buf;//调用门提权,用于提权后构造ldt表与其内的数据段描述符
sldt cldtr;
pop fs;//[去掉不影响实验成功]
mov ax,0x0f;
mov ds,ax;//加载ldt表内的数据段描述符
mov eax,a;//尝试读
mov b,eax;//尝试写(读a的值写入b)
}

return 0;
}

实验图解:

image-20210816164118935

运行程序

image-20210816163834960

401000为test地址,可知构造的调用门描述符为

1
eq gdtr+0x48 0040ec00`00081000

image-20210816164556502

运行,下图成功将b改为10了,实验成功。

image-20210816164830888

此时查看gdt表:

image-20210816164938808

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

image-20210820144620796

image-20210817141821836

修改10-10-12分页的方式:boot.ini中的**/execute=optin**表示10-10-12分页

每个进程都有一个CR3(准确来说是CR3中的值)

CR3本身是一个寄存器,一个CPU核只有一套寄存器。

CR3指向一个物理页(所有寄存器中,只有CR3存的是物理地址,其他寄存器存的都是线性地址),一共4096字节(4KB),如图:

image-20210817141832675

将线性地址的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

image-20210817143849226

页目录表(占4KB)中每个页目录表项占4个字节,每个页目录项又指向一个4KB的页表,每个页表项占4个字节。因此每个页表有1024个页表项。

无论是PDE还是PTE,里面记录的前20位*2的12次方(后面添加12位0)是物理地址,后面12位是属性。

PTE的特点

  • PTE可以没有物理页(P位为0即无效,也就是没有物理页),且只能对应一个物理页
  • 多个PTE可以指向同一个物理页

10-10-12一些细节

  1. 一个物理页是4KB(4096个字节),刚好是2的12次方个字节,所以需要12个二进制位索引4KB大小的物理页,说明了10-10-12中最后一个为什么是12。
  2. 页表是1024个页表项,即2的10次方个成员,所以需要10个二进制位索引,说明了10-10-12的中间的10的由来。
  3. 同理页目录项。
  4. 10-10-12分页决定了当前CPU物理内存的最大值就是4GB(1024*1024*4096(B)=4GB)

同一个进程的两个线性地址只要前5位16进制是一样的,那么他们就一定在一个物理页上。因为PDE与PTE都一样,而后三位十六进制只决定他们在物理页上的偏移。

image-20210817151041469
$$
物理页的属性=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位的有效位判断到底是没分物理页,还是缺页将此页挪到文件里了(然后再给补上页)。(具体的细节后面会有更详细的解读)

image-20210818201133076

-的部分学完控制寄存器与TLB才能理解,此处先略过(后面有讲)

可见10-10-12分页没有可执行属性,但在2-9-9-12有个位对这种情况做了补充

页有两种

  • 小页单位:4KB
  • 大页单位:4MB(只有系统里面一部分经常使用的内存才会使用大页)
1
2
3
4
5
mov dword ptr ds:[0]
//cpu两步判断这个线性地址可写:
//1. 判断ds段描述符是否可写
//2. 通过PDE&PTE的R/W属性判断该物理地址是否可写
0的线性地址如果不可写,直接意味着0x~0xFFF的线性地址都不可写,因为前516进制一致,即都在同一个物理页上
  1. 2G以上是内核才能访问的原因是U/S位的设置问题,如果将内核的某个页设置为1,就可以在R3访问了
  2. 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线性内存:

  1. 低2G(0~7FFFFFFF) 各个进程几乎不同
  2. 高2G(80000000~FFFFFFFF) 各个进程几乎相同
  3. 0~7FFFFFFF的前64K和后64K都是没有映射的

发现:
$$
0xC0300000线性地址存储的值就是PDT的基址
$$

  1. 通过0xC0300000(这个线性地址是一定存在的,如果它不存在,系统也没办法访问这个表)找到的物理页就是页目录表

  2. 这个物理页即是页目录表,本身也是页表

  3. 页目录表是一张特殊的页表,每一项PTE指向的不是普通的物理页,而是指向其他的页表

  4. 如果我们要访问低N个PDE,公式如下:
    $$
    0xC0300000+N*4
    $$

    image-20210818111918749

CR3中的值+0xC00该物理地址中存的就是CR3中的值

PDT是PTT中的一个(如图红色区域就是PDT)

页表PTT基址(线性地址)

  • 0xC0000000对应的是第一个PTT的基地址
  • 0xC0001000对应的是第二个PTT的基地址
  • 每个PTT基地址之间隔着0x1000(4KB)(物理地址不连续,但线性地址是连续的)

image-20210818120306245

  1. 页表被映射到了从0xC0000000到0xC03FFFFF的4M地址空间
  2. 在这1024个表中有一张特殊的表:页目录表(页表中的第0x300项)
  3. 页目录表被映射到了0xC0300000开始处的4K地址空间

0xC0300000实际上是通过0xC0000000算出来的:
$$
VirtualAddr对应的PDE线性地址=(VirtualAddr >> 12) * 4+0xC0000000
$$
image-20210822152334263

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>

DWORD result1;
DWORD result2;
DWORD result3;

__declspec(naked) void callGate()
{
__asm
{
pushad
pushfd
mov eax,dword ptr ds:[0xC0300000];
mov result1,eax;
mov eax,dword ptr ds:[0xC0000000];
mov result2,eax;
mov eax,dword ptr ds:[0xC0001000];
mov result3,eax;
popfd
popad
retf
};
}

int main()
{
printf("callGateFunc address:%X\n",callGate);
char gate[6]={0,0,0,0,0x48,0};
getchar();
_asm
{
call fword ptr ds:[gate];
}
printf("0xC0300000 result:%X\n",result1);
printf("0xC0000000 result:%X\n",result2);
printf("0xC0001000 result:%X\n",result3);
getchar();
return 0;
}

image-20210821182319577

掌握一个进程所有的物理内存读写权限【公式总结】

10-10-12 PDI-PTI-物理页内偏移

  • PDI:页目录索引
  • PTI:页表索引
  1. 访问页目录表项PDE地址的公式:
    $$
    0xC0300000+PDI*4
    $$

  2. 访问页表项PTE地址的公式:
    $$
    0xC0000000+PDI4096+PTI4
    $$

$$
0xC0000000+PDI2的10次方4+PTI4=0xC0000000+(PDI2的10次方+PTI)*4
$$

$$
0xC0000000+PDI拼接PTI部分*4
$$

实验

将buf的物理页挂到0线性地址那附近,通过0 + buf的物理页偏移部分的地址调用MessageBoxW函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include "stdafx.h"
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>

/*
push 0
push 0
push 0
push 0
call 0
retn
*/
char buf[]={0x6a,0x00,0x6a,0,0x6a,0,0x6a,0,0xE8,0,0,0,0,0xc3};

__declspec(naked) void callGate()
{
__asm
{

push 0x30;//手动修改fs框架1
pop fs;//手动修改fs框架2
pushad;
pushfd;

lea eax,buf;
mov ebx,dword ptr ds:[0xc0300000];
test ebx,ebx;
je __gpPDE;//PDE为0则跳转

shr eax,12;//去掉buf地址的最后12位物理页偏移
and eax,0xfffff;//取buf地址前20位
shl eax,2;//buf地址前20位乘4

add eax, 0xc0000000;//buf的PTE对应地址
mov eax,[eax];//buf对应的PTE
mov dword ptr ds:[0xc0000000],eax;//将buf对应PTE放到0线性地址对应的PTE位置中
jmp __retR;

__gpPDE: //如果0线性地址对应的PDE是0的话跳转到这里来
shr eax,22;
and eax,0x3ff;//取第一个10位
shl eax,2;//第一个10位乘4

add eax, 0xc0300000;//找到对应PDE的地址
mov eax,[eax];//找到对应PDE
mov dword ptr ds:[0xc0300000],eax;//将buf对应PDE放到0线性地址对应的PDE位置中,PDE就换了,所以后面的PTE也就不用换了,反正整个PTT表都换了
__retR:
popfd;
popad;
retf;
};
}

int main(int argc, char* argv[])
{

unsigned int functionAddress = (unsigned int)MessageBox;//获取MessageBox地址
int offset1=((unsigned int)buf) & 0xfff;//获取buf本身的物理页上的偏移
//修正EIP,调用MessageBoxW函数
*((unsigned int *)&buf[9])= functionAddress - (13 + offset1);
char segmentGate[]={0,0,0,0,0x48,0};//调用门,0040EC00`00081000
printf("MessageBox:%X callGate = %X,buf=%X\n",MessageBox,callGate,buf);
system("pause");
__asm
{
call fword ptr segmentGate;
push 0x3b;//手动修正fs框架1
pop fs;//手动修正fs框架2
mov eax,offset1;
call eax;//调用offset地址
};

system("pause");
return 0;
}

image-20210821192154718

10-10-12内核逆向分析MmIsAddressValid函数

一个用于判断虚拟内存地址是否有效的API,逆向该函数可以知道操作系统是怎么找PDE和PTE的。

用IDA

image-20210819130503077

可以将内核文件放入IDA分析。

image-20210819132944697

用windbg
1
uf MmIsAddressValid

尝试逆向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
kd> uf MmIsAddressValid
nt!MmIsAddressValid:
804e4661 8bff mov edi,edi
804e4663 55 push ebp
804e4664 8bec mov ebp,esp
804e4666 8b4d08 mov ecx,dword ptr [ebp+8]//要检查的线性地址存入ecx
804e4669 8bc1 mov eax,ecx
804e466b c1e814 shr eax,14h//要检查的线性地址右移20位,相当于右移22位再左移2位,即乘4,下一句修正后两位
804e466e bafc0f0000 mov edx,0FFCh
804e4673 23c2 and eax,edx//(要检查的线性地址前12位)的后两位清零
804e4675 2d0000d03f sub eax,3FD00000h//等同于add eax,0xc0300000
804e467a 8b00 mov eax,dword ptr [eax]//此后eax为PDE的值
804e467c a801 test al,1//检测PDE末尾(P位)是否为1
804e467e 0f84d2f10000 je nt!MmIsAddressValid+0x4f (804f3856) Branch//P位不为1就跳

nt!MmIsAddressValid+0x1f:
804e4684 84c0 test al,al
804e4686 7824 js nt!MmIsAddressValid+0x53 (804e46ac) Branch//判断PDE后8位的首位(P/S位)是否1,是1就跳,表示指向大物理页,不用拆pte了

nt!MmIsAddressValid+0x23:
804e4688 c1e90a shr ecx,0Ah//要检查的线性地址右移10位,相当于左移12位后右移两位,因为要乘4,下一句修正后两位
804e468b 81e1fcff3f00 and ecx,3FFFFCh//右移后的线性地址的后两位清零
804e4691 81e900000040 sub ecx,40000000h//等同于add ecx,0xc0000000
804e4697 8bc1 mov eax,ecx
804e4699 8b08 mov ecx,dword ptr [eax]//此后ecx是PTE的值
804e469b f6c101 test cl,1//判断P位
804e469e 0f84b2f10000 je nt!MmIsAddressValid+0x4f (804f3856) Branch//P位不为1就跳

nt!MmIsAddressValid+0x3b:
804e46a4 84c9 test cl,cl//判断PAT位
804e46a6 0f88b6de0300 js nt!MmIsAddressValid+0x3f (80522562) Branch//PAT位是1就跳,即PAT为0则一定是有效的

nt!MmIsAddressValid+0x53:
804e46ac b001 mov al,1//al置1,因为eax是默认返回值,返回1,线性地址有效

nt!MmIsAddressValid+0x55://返回
804e46ae 5d pop ebp
804e46af c20400 ret 4

nt!MmIsAddressValid+0x4f://PDE或PTE的P位检查未通过
804f3856 32c0 xor al,al//al置0,因为eax是默认返回值,返回0,线性地址无效
804f3858 e9510effff jmp nt!MmIsAddressValid+0x55 (804e46ae) Branch

nt!MmIsAddressValid+0x3f://PAT位=1的处理
80522562 23c2 and eax,edx//edx:0FFCh,eax:PTE的线性地址
//比如说0xC0000XXX变成XXX
80522564 8b80000030c0 mov eax,dword ptr [eax-3FD00000h]//比如说0xC0000XXX变成0xC0300XXX,自己实验中发现0xC0300XXX地址中为0
8052256a 66258100 and ax,81h
8052256e 3c81 cmp al,81h//第0位和第7位必须为1内存地址才有效
80522570 0f853621fcff jne nt!MmIsAddressValid+0x53 (804e46ac) Branch

nt!MmIsAddressValid+0x53://检查未通过
80522576 e9db12fdff jmp nt!MmIsAddressValid+0x4f (804f3856) Branch

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

  1. 先确定了物理页的大小为4K,所以后面的12位的功能就确定了。
  2. 当初的物理内存比较小,所以4个字节的PTE就够了,加上页的尺寸是4K,所以一个页能存储1024个PTE,也就是2的10次方,第二个10位确定了。
  3. 剩下的10位为PDI,10+10+12=32。

为什么是2-9-9-12

  1. 物理页的大小是确定的,4KB不能随便改,所以后面的12位确定了

  2. 如果想增大物理内存的访问范围,就需要增大PTE,增大多少了呢,考虑对齐的因素,增加到8个字节。

    image-20210819142745181

    因为一个物理页就4KB,由于PTE由4字节变成8字节,所以项数由1024项缩小为512项。

    而512项需要9个二进制位进行索引,所以PTI是9。

  3. 同理PDI也是9位(2的9次方)

  4. 最后,32 - 9 - 9 - 12 还差2位 所以就再做一级 叫PDPI

image-20210819143625209

因为只有两位,所以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

image-20210822194231558

第4个PDPTE指向了一个PDT表,此表的前四项指向了PDPTE的每一个元素。

  • C0600000是第一个PDT表的首地址
  • C0601000是第二个PDT表的首地址
  • C0602000是第三个PDT表的首地址
  • C0603000是第四个PDT表的首地址

image-20210822211646330

image-20210822212611498

在同一个PDT中相邻两个PTT首地址间隔为0x1000

通过0xC0603000线性地址读取第四个PDT的第一个PDE内容成功:

image-20210822213911198

  1. 访问页目录表项PDE地址的公式:

    • 0xC0000000+PDI*4
    • 0xC0100000+PDI*4
    • 0xC0200000+PDI*4
    • 0xC0300000+PDI*4
  2. 访问页表项PTE地址的公式:
    $$
    0xC0000000+PDI4096+PTI8
    $$

页目录指针表项Page-Dircetory-Point-Table Entry

image-20210819144239301

Avail:CPU设计给操作系统用的,操作系统设计者爱用不用

35~12位存储的是页目录表PDT的物理基址,低12位补零,共36位,即页目录表基址。

存的项也是占用8字节

PDE结构

image-20210819144958741

功能描述
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结构下的两种页

  1. 小页 4KB
  2. 大页 2MB(和10-10-12分页的4MB不一样,2的(9+12)次方)

PTE结构

image-20210819151236597

小实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//12FF7C地址中存了一个100
//拆分线性地址:
//00
//0 0000 0000
//1 0010 1111 =0x12F *8=0x978
//F7C

Failed to get VadRoot
PROCESS 818eeb70 SessionId: 0 Cid: 0714 Peb: 7ffde000 ParentCid: 0648
DirBase: 089c02e0 ObjectTable: e118dde0 HandleCount: 15.
Image: test.exe

kd> !dq 089c02e0
# 89c02e0 00000000`18116001 00000000`18057001
# 89c02f0 00000000`18058001 00000000`18095001
# 89c0300 00000000`17d7b001 00000000`17efc001
# 89c0310 00000000`17fbd001 00000000`17ffa001
# 89c0320 00000000`15886001 00000000`15707001
# 89c0330 00000000`155c8001 00000000`158c5001
# 89c0340 00000000`f8d0e360 00000000`164cc001
# 89c0350 00000000`166cd001 00000000`1674a001
kd> !dq 18116000
#18116000 00000000`17bfe067 00000000`18019067
#18116010 00000000`17f3d067 00000000`00000000
#18116020 00000000`00000000 00000000`00000000
#18116030 00000000`00000000 00000000`00000000
#18116040 00000000`00000000 00000000`00000000
#18116050 00000000`00000000 00000000`00000000
#18116060 00000000`00000000 00000000`00000000
#18116070 00000000`00000000 00000000`00000000
kd> !dq 17bfe000+0x12F*8
#17bfe978 80000000`17f92067 80000000`0e2c3025
#17bfe988 80000000`0e244025 00000000`00000000
#17bfe998 00000000`00000000 00000000`00000000
#17bfe9a8 00000000`00000000 00000000`00000000
#17bfe9b8 00000000`00000000 00000000`00000000
#17bfe9c8 00000000`00000000 00000000`00000000
#17bfe9d8 00000000`00000000 00000000`00000000
#17bfe9e8 00000000`00000000 00000000`00000000
kd> !dd 17f92000+0xF7C
#17f92f7c 【000000640012ffc0 00401309 00000001//【】中确实为100
#17f92f8c 00380b90 00380c08 00380039 00360032
#17f92f9c 7ffde000 00000006 b2cded04 0012ff94
#17f92fac 8061850d 0012ffe0 00404950 00410278
#17f92fbc 00000000 0012fff0 7c817067 00380039
#17f92fcc 00360032 7ffde000 80545bfd 0012ffc8
#17f92fdc 8197eb40 ffffffff 7c839ac0 7c817070
#17f92fec 00000000 00000000 00000000 00401220

X/D标志位

在AMD中称为NX,即No Excetion

PDE/PTE结构如下

image-20210819151523802

  • 段的属性有可读,可写和可执行
  • 页的属性只有可读,可写

当RET执行返回的时候,如果我修改堆栈里面的数据指向一个我提前准备好的数据(把数据当做代码来执行,漏洞都是依赖这点)

所以,Intel就做了硬件保护,做了一个不可执行位,XD=1时,那么你的软件溢出了也没关系,即使你的EIP蹦到了危险的“数据区“,也是不可以执行的!

在PAE(2-9-9-12)分页模式下,PDE或PTE的最高位为XD/NX位,为1表示该内存只可在代码段执行。数据段不可执行

0x8开头的就是被保护的,因为首位二进制为1,进0环了直接改了就是了

2-9-9-12内核逆向分析MmIsAddressValid函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
nt!MmIsAddressValid:
80511980 8bff mov edi,edi
80511982 55 push ebp
80511983 8bec mov ebp,esp
80511985 51 push ecx
80511986 51 push ecx
80511987 8b4d08 mov ecx,dword ptr [ebp+8]//ecx取到要判断是否有效的线性地址
8051198a 56 push esi//push esi代表后面要用esi
8051198b 8bc1 mov eax,ecx//eax也为要判断是否有效的线性地址
8051198d c1e812 shr eax,12h//右移18位
80511990 bef83f0000 mov esi,3FF8h
80511995 23c6 and eax,esi//与11 1111 1111 1000,此后eax为pdi部分
80511997 2d0000a03f sub eax,3FA00000h//add eax,C0600000,eax此后为线性地址对应的PDE地址
8051199c 8b10 mov edx,dword ptr [eax]//edx为pde低64位
8051199e 8b4004 mov eax,dword ptr [eax+4]//eax为pde高64位
805119a1 8945fc mov dword ptr [ebp-4],eax//pde高64位存进局部变量1中
805119a4 8bc2 mov eax,edx//eax为pde低64位
805119a6 57 push edi//要用edi
805119a7 83e001 and eax,1//eax为PDE的p位
805119aa 33ff xor edi,edi//edi清零
805119ac 0bc7 or eax,edi//判断PDE的p位是否为0
805119ae 7461 je nt!MmIsAddressValid+0x91 (80511a11) Branch//PDE的p位为零则跳

nt!MmIsAddressValid+0x30://PDE的p位不是零
805119b0 bf80000000 mov edi,80h
805119b5 23d7 and edx,edi//取pde的PS位,判断是否大页
805119b7 6a00 push 0
805119b9 8955f8 mov dword ptr [ebp-8],edx//pde的PS位存入局部变量2空间
805119bc 58 pop eax//eax清零
805119bd 7404 je nt!MmIsAddressValid+0x43 (805119c3) Branch//PS位为0,即小页就跳转

nt!MmIsAddressValid+0x3f://大页,则不用判断PTE
805119bf 85c0 test eax,eax
805119c1 7452 je nt!MmIsAddressValid+0x95 (80511a15) Branch//百分百跳转到线性地址有效后返回

nt!MmIsAddressValid+0x43://PDE PS位为0,为小页则跳转
805119c3 c1e909 shr ecx,9//线性地址右移9位,相当于右移12位,再左移3位(*8)
805119c6 81e1f8ff7f00 and ecx,7FFFF8h//后三位置零,此后ecx为(pdpte+pdi+pti)部分
805119cc 8b81040000c0 mov eax,dword ptr [ecx-3FFFFFFCh]//mov eax,[ecx+0xC0000004],此后eax为PTE高64位
805119d2 81e900000040 sub ecx,40000000h//mov eax,[ecx+0xC0000000],此后ecx为PTE低64位地址
805119d8 8b11 mov edx,dword ptr [ecx]//edx为PTE低64位
805119da 8945fc mov dword ptr [ebp-4],eax//高64位部分存入局部变量1
805119dd 53 push ebx//临时保存ebx,要用
805119de 8bc2 mov eax,edx//eax为PTE低64位
805119e0 33db xor ebx,ebx//ebx清零
805119e2 83e001 and eax,1//取PTE的P位
805119e5 0bc3 or eax,ebx//判断PTE的P位是否为0
805119e7 5b pop ebx//还原ebx
805119e8 7427 je nt!MmIsAddressValid+0x91 (80511a11) Branch//PTE的P位是零就跳

nt!MmIsAddressValid+0x6a://PTE的P位不是零
805119ea 23d7 and edx,edi//edx为PTE的PAT位是否为0
805119ec 6a00 push 0
805119ee 8955f8 mov dword ptr [ebp-8],edx//PTE的PAT位存入局部变量2
805119f1 58 pop eax//eax清零
805119f2 7421 je nt!MmIsAddressValid+0x95 (80511a15) Branch//PTE的PAT位为0就跳到函数有效返回部分

nt!MmIsAddressValid+0x74://无用
805119f4 85c0 test eax,eax
805119f6 751d jne nt!MmIsAddressValid+0x95 (80511a15) Branch//绝对不跳

nt!MmIsAddressValid+0x78://PTE的PAT位为1的处理
805119f8 23ce and ecx,esi//and PTE低64位地址,3FF8h,可能是取PTE低64位地址的PDI部分
805119fa 8b89000060c0 mov ecx,dword ptr [ecx-3FA00000h]//mov ecx,[ecx+C060 0000h],???
80511a00 b881000000 mov eax,81h
80511a05 23c8 and ecx,eax//ecx为[(PTE低64位地址&3FF8h)+C060 0000h]&81h
80511a07 33d2 xor edx,edx//edx清零
80511a09 3bc8 cmp ecx,eax
80511a0b 7508 jne nt!MmIsAddressValid+0x95 (80511a15) Branch//判断[(PTE低64位地址&3FF8h)+C060 0000h]的第7和第0位是否均为1,若有一个不是1,跳转。

nt!MmIsAddressValid+0x8d://无用
80511a0d 85d2 test edx,edx
80511a0f 7504 jne nt!MmIsAddressValid+0x95 (80511a15) Branch//绝对不跳

nt!MmIsAddressValid+0x91://PDE P位为零的处理
80511a11 32c0 xor al,al//返回0,线性地址无效
80511a13 eb02 jmp nt!MmIsAddressValid+0x97 (80511a17) Branch

nt!MmIsAddressValid+0x95:
80511a15 b001 mov al,1//返回1,线性地址有效

nt!MmIsAddressValid+0x97://退出函数
80511a17 5f pop edi
80511a18 5e pop esi
80511a19 c9 leave//?
80511a1a c20400 ret 4

旁路转换缓冲TLB

每次访问一个物理页,查表的过程特别繁琐,举个例子,2-9-9-12分页模式下读一个四字节实际上会读24个字节,如果跨页读可能更多。

CPU内部做了一个表,来记录这些东西,这个表格是CPU内部的(不在内存中),和寄存器一样快,这个表格:TLB(Translation Lookaside Buffer)旁路转换缓冲,或称为页表缓冲

image-20210819194408894

说明:

  1. ATTR(属性):属性是PDPE,PDE,PTE三个属性AND起来的。如果是10-10-12就是PDE and PTE
  2. 不同的CPU,这个表的大小不一样
  3. 只要CR3变了,TLB立马刷新,一核一套TLB
  4. LRU(统计信息),记录每个地址的读写情况,确定哪个地址访问更频繁
  5. 存的是线性地址的前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:

  1. 缓存一般页表(4K字节页面)的指令页表缓存(Instruction-TLB)
  2. 缓存一般页表(4K字节页面)的数据页表缓存(Data-TLB)
  3. 缓存大尺寸页表(2M/4M字节页面)的指令页表缓存(Instruction-TLB)
  4. 缓存大尺寸页表(2M/4M字节页面)的数据页表缓存(Data-TLB)

CPU能区分是读一个内存地址还是执行一个内存地址

如果是mov指令访问的内存放入数据页表缓存(读一个内存地址),如果是call/jmp等指令访问的内存放入指令页表缓存(执行一个内存地址)。

Shadow Walker,一种隐藏内存的技术。

可以用于过代码校验,在指令页表缓存中给目标地址修改为新的物理页。当执行的时候是到物理页执行,可是读的时候,读的还是原来的位置,即未被修改。

这种解决方案适合处理零环(也有点不稳定)而不适合处理三环,因为三环内存老是刷新(非常不稳定)。

三环有更好的方案挂钩子防止对方检测到(无痕hook)

image-20210819215515748

TLB相关实验

体验TLB的存在

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>

DWORD result;

void __declspec(naked) func()
{
_asm
{
//给零地址挂物理页,可能会蓝屏(有可能被用着中) 0x01234867(G=0) 0x01234967(G=1)
mov dword ptr ds:[0xc0000000],0x01234867

//给零地址赋值
mov dword ptr ds:[0],0x11111111

//将物理页改了,随便改成别的物理页
mov dword ptr ds:[0xc0000000],0x02345867

//再次读线性地址
mov eax,dword ptr ds:[0]
mov result,eax

retf
}
}

int main()
{
printf("func address:%X\n",func);
char buff[6]={0x11,0x11,0x11,0x11,0x48,0};//调用门描述符0040EC00`00081000
_asm
{
call fword ptr[buff]
}
printf("result:%X\n",result);
return 0;
}

image-20210820124123585

进程切换后,结果被刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>

DWORD result;

void __declspec(naked) func()
{
_asm
{
//给零地址挂物理页,可能会蓝屏 0x01234867(G=0) 0x01234967(G=1)
mov dword ptr ds:[0xc0000000],0x01234867

//给零地址赋值
mov dword ptr ds:[0],0x11111111

//进程切换,只要有加载CR3的操作,那么就会清除TLB(除了G=1的内存)
mov eax,cr3
mov cr3,eax

//将物理页改了,随便改成别的物理页
mov dword ptr ds:[0xc0000000],0x02345867

//再次读线性地址
mov eax,dword ptr ds:[0]
mov result,eax

retf
}
}

int main()
{
printf("func address:%X\n",func);
char buff[6]={0x11,0x11,0x11,0x11,0x48,0};
_asm
{
call fword ptr[buff]
}
printf("result:%X\n",result);

return 0;
}

image-20210820133141085

结果中的0是因为新物理页中的值就是0

全局页的意义

上面的代码修改为全局页:

把上述代码中的mov dword ptr ds:[0xc0000000],0x01234867从0x01234867修改为0x01234967

image-20210820134644415

因此可知,全局页不会随进程切换清空TLB

INVLPG指令的意义

INVLPG dword ptr ds:[0]//手动清空0线性地址对应在TLB的一条缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include"stdafx.h"
#include "stdio.h"
#include <windows.h>

DWORD result;

void __declspec(naked) func()
{
_asm
{
//给零地址挂物理页,可能会蓝屏 0x01234867(G=0) 0x01234967(G=1)
mov dword ptr ds:[0xc0000000],0x01234967

//给零地址赋值
mov dword ptr ds:[0],0x11111111

//进程切换
mov eax,cr3
mov cr3,eax

INVLPG dword ptr ds:[0]

//将物理页改了,随便改成别的物理页
mov dword ptr ds:[0xc0000000],0x02345867

//再次读线性地址
mov eax,dword ptr ds:[0]
mov result,eax

retf
}
}

int main()
{
printf("func address:%X\n",func);
char buff[6]={0x11,0x11,0x11,0x11,0x48,0};
_asm
{
call fword ptr[buff]
}
printf("result:%X\n",result);

return 0;
}

image-20210820135304899

手动清空缓存成功,读出新物理页的值。

中断与异常

中断

什么是中断?

中断的本质目的就是改变CPU执行的路线

中断通常被视为硬件事件驱动的机制

  1. 中断通常是由CPU外部的输入输出设备(硬件)所触发的,供外部设备通知CPU“有事情需要处理”,因此又叫中断请求(Interrupt Request)

  2. 中断请求的目的是希望CPU暂时停止执行当前正在执行的程序,转而去执行中断请求所对应的中断处理例程(中断处理程序在哪由IDT表决定)

  3. 80x86有两条中断请求线

非可屏蔽中断

非可屏蔽中断是指无法被禁用或屏蔽的中断。这类中断通常用于处理紧急情况或重要事件,确保系统能够及时响应。例如,电源故障或硬件故障的信号通常会生成非可屏蔽中断

image-20210820165939064

当非可屏蔽中断产生时,CPU在执行完当前指令后会从里面进入中断处理程序

非可屏蔽中断不受EFLAG寄存器中IF位的影响,一旦发生,CPU必须 处理

非可屏蔽中断处理程序位于IDT表中的2号位置

可屏蔽中断

可屏蔽中断是指可以被 CPU 或操作系统暂时禁用的中断。这意味着在某些情况下,系统可以选择忽略这些中断请求,以确保关键任务的执行。例如,操作系统在执行关键代码时可能会禁用可屏蔽中断,以防止中断干扰;网络适配器的中断可以在数据处理时被暂时禁用;大多数中断都是可屏蔽的

在硬件级,可屏蔽中断是由一块专门的芯片来管理的,通常称为中断控制器。它负责分配中断资源和管理各个中断源发出的中断请求。为了便于标识各个中断请求,中断管理器通常用IRQ(Interrupt Request)后面加上数字来表示不同的中断。

比如:在Windows中,时钟中断(即下图的系统计时器)的IRQ编号为0,也就是:IRQ0

win10查看IRQ:

image-20210820171447027image-20210820171507433

大多数操作系统时钟在10100ms之间,Windows系统为1020MS。

时钟中断只是操作系统进行线程切换的一个机会。哪怕是一个无限循环的程序,一个单核系统,CPU依然有机会线程切换。

可屏蔽中断如何处理

image-20210820172028590

  1. 如果自己的程序执行时,不希望CPU去处理这些中断

    • CLI指令清空EFLAG寄存器中的IF位
    • STI指令设置EFLAG寄存器中的IF位
  2. 硬件中断与IDT表中的对应关系并非固定不变的,参见APIC(高级可编程中断控制器)

异常

异常通常是CPU在执行指令时检测到的某些错误,比如除0,访问无效页面等。

中断与异常的区别:

  1. 中断来自于外部设备,是中断源(比如键盘)发起的,CPU是被动的。
  2. 异常来自于CPU本身,是CPU主动产生的(cpu在执行的时候发现错误了)。
  3. INT N虽然被称为“软件中断”,但其本质是异常。EFLAG的IF位对INT N无效

异常处理

无论是由硬件设备触发的中断请求还是由CPU产生的异常处理程序都在IDT表

常见的异常处理程序如下:

image-20210820173459803

举例缺页异常

缺页异常的产生,比如:

  1. 当PDE/PTE的P=0时
  2. 当PDE/PTE的属性为只读,但程序试图写入的时候

一旦发生缺页异常,CPU会执行IDT表中的0xE号中断处理程序,由操作系统来接管。

image-20210820173746701

控制寄存器CR

控制寄存器用于控制和确定CPU的操作模式

共5个 CR0,CR1,CR2,CR3,CR4

CR1-保留,CR3-页目录表基址(不同分页模式不一样)

CR0寄存器

image-20210820174741733

功能描述
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位的总控
  1. PG=0且PE=0时,处理器工作在实地址模式
  2. PG=0且PE=1时,处理器工作在没有开启分页机制的保护模式下(目前为止没有任何一个操作系统工作在这个模式)
  3. PG=1且PE=0时,这种情况不存在,在PE没有开启的情况下,无法开启PG
  4. PG=1且PE=1时,处理器工作在开启了分页机制的保护模式下

当CPL<3的时候

  • 如果WP=0 可以读写任意用户级物理页,只要线性地址有效
  • 如果WP=1 可以读取任意用户即物理页,但对于只读的物理页,则不可以写

CR2寄存器

image-20210820175813882

当CPU访问某个无效页面时,会产生缺页异常,此时,CPU会将引起异常的线性地址存放在CR2中。

int 0xE里面有这个函数(函数非常长)的处理流程。

CR4寄存器

image-20210820180052860

功能描述
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

控制寄存器更多的细节参考白皮书第三卷

image-20210820180519008

PDE,PTE中的PWT和PCD位

cpu缓存Cache

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

CPU缓存与TLB存储的东西的区别

  • TLB:线性地址《—–》物理地址
  • CPU缓存:物理地址《—–》内容

关于Cache的更多细节可以了解因特尔白皮书第三卷

image-20210820181214528

PWT:Page Write Through

PWT=1时。写Cache的时候也要讲数据写入内存中

PWT=1时。写Cache的时候只写入Cache

PCS:Page Cache Disable

PCD=1时,禁止某个页的写入Cache,直接写内存。

比如:做页表用的页,已经存储在TLB中了,可能不需要再缓存了,因此他们的PCS都是置1的。

保护模式阶段总结

参见因特尔白皮书第三卷的第3章到第11章

image-20210820181901011

保护模式实验(两道题):

第一题

1
2
3
4
5
6
1. 给定一个线性地址,和长度,读取内容;
int ReadMemory(OUT BYTE* buffer,IN DWORD dwAddr,IN DWORD dwLeght)
要求:
1) 可以自己指定分页方式。
2) 页不存在,要提示,不能报错。
3) 可以正确读取数据。

残缺版,并未实现所有功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include "stdafx.h"
#include <stdio.h>
#include <stdlib.h>
#include <Windows.h>

int result;

void __declspec(naked) callGate()
{
_asm
{
pushfd;
pushad;
mov esi,[esp+0x24+0x8+0x8];//dwLeght
mov eax,[esp+0x24+0x8+0x4];//dwAddr
mov ecx,[esp+0x24+0x8];//buffer
mov edx,eax;
//取gdi
shr edx,21;
and edx,0x1FF;
shl edx,3;//*8
mov edx,dword ptr ds:[edx+0xc0600000];
test edx,1;
jz __PERROR;
test dl,dl;
//js __bigPage;
//取gti
mov edx,eax
shr edx,12;
and edx,0xFFFFF;
shl edx,3;//*8
mov edx,dword ptr ds:[edx+0xc0000000];
test edx,1;
jz __PERROR;
//读数据
mov result,1;
__for:
mov dl,byte ptr ds:[eax];
inc eax;
mov byte ptr ds:[ecx],dl;
inc ecx;
dec esi;
cmp esi,0;
jnz __for;
jmp __ret;


//__bigPage:


__PERROR:
mov result,0
__ret:
popad;
popfd;
retf 0xC;
}
}

int ReadMemory(BYTE* buffer,DWORD dwAddr,DWORD dwLeght)
{
//此处需要对线性地址做是否跨页的判断,决定下面校验几次.
char buff[6]={0,0,0,0,0x48,0};//0040EC03`0008100A
_asm
{
push dwLeght
push dwAddr
push buffer
call fword ptr[buff];
}

return result;// 返回值取决于全局变量
}

int main(int argc, char* argv[])
{
//构造测试用例
int x=(int)0x12345678;
printf("%X\n",callGate);
getchar();
BYTE a[4]={0};
int iRet=ReadMemory(a,(DWORD)&x,4);
if(!iRet)
printf("内存无效\n");
else
{
printf("用函数读到:%X\n",*(DWORD*)a);
}
getchar();
return 0;
}

image-20210824161101189

改成读取0x12345678地址:

image-20210824161159229

第二题

1
2
3
4
5
6
2. 申请长度为100的DWORD的数组,且每项用该项的地址初始化;
把这个数组所在的物理页挂到0x1000的地址上;
定义一个指针,指向0x1000这个页里的数组所在的地址,用0x1000这个页的线性地址打印出这数组的值;

要求:
数组所在的物理页,是同一个页;

补充知识点

CPU信息查询

1
2
3
4
mov eax,80000008;//通过eax设置cpuid的参数
cpuid;
;//此后eax获取到的值为0x00003028
;//解读:0x30为线性地址位数,0x28为物理地址位数。

image-20210822171000712

支持很多cpu信息查询,包括是否支持VT也通过该指令查找

1
2
3
mov eax,1;
cpuid;
;//此后ecx的第五位为1的话表示CPU支持VT

MDL?物理页地址映射?