Windows内核实验 ——— 数据TLB

前言

回到我们之前提到的三级内存映射关系,对于我们之前查询地址的操作可以通过判断TLB中是否有对应的缓存来进行读取,当存在有对应数据时我们便从TLB进行直接获取,若不存在则是通过访问cr3进而访问对应的PDE、PTE信息,内存访问的大致流程为:

TLB直接记录了虚拟内存到物理内存的访问关系,对于TLB我们无法直接通过汇编语句来进行获取

实验过程

在此次实验中需要注意:

  • 代码顺序执行,即不产生页面异常
  • 确保测试虚拟地址已经存放到TLB中,即需要有最近的内存访问
  • 刷新TLB实际上是使TLB无效,不访问内存

验证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
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <stdio.h>
#include <Windows.h>

#define PTE(addr) ((DWORD *)(((addr >> 12) << 3) + 0xC0000000))

#pragma section("data_seg", read, write)
__declspec(allocate("data_seg")) DWORD page1[1024]; // addr 0x0041b000
__declspec(allocate("data_seg")) DWORD page2[1024]; // addr 0x0041c000

DWORD g_oldPTE[2];
DWORD g_out;

void __declspec(naked) IdtEntry()
{
__asm {
mov eax, ds: [0x0041c000] ; 通过访问,确保TLB中存在有该项目
}
// 保存page2的页表项
g_oldPTE[0] = PTE(0x0041c000)[0];
g_oldPTE[1] = PTE(0x0041c000)[1];
// 将page2映射改为page1
PTE(0x0041c000)[0] = PTE(0x0041b000)[0];
PTE(0x0041c000)[1] = PTE(0x0041b000)[1];
g_out = page2[0]; // 正常情况下应该为1,但是因为TLB的存在,我们没有通过cr3进行访问内存,结果为2
// 还原page2的页表项
PTE(0x0041c000)[0] = g_oldPTE[0];
PTE(0x0041c000)[1] = g_oldPTE[1];
__asm
{
iretd
}
}

void crash() {
page1[0] = 1; // 将两个页表项挂上物理页
page2[0] = 2;
__asm {
int 0x20
}
}

int main() {
if ((DWORD)IdtEntry != 0x401040) {
printf("IdtEntry : %p\n", IdtEntry);
return 0;
}
crash();
printf("g_out = %d\n", g_out);
system("pause");
return 0;
}

执行结果如下:

上述代码中我们将page2的页面先进行了一次读取,在此次读取时操作系统将对应物理地址保存到了TLB中,即使后续我们修改了对应的内存映射,我们所访问的仍然是通过TLB进行的,进而导致访问结果仍然为 2

如果我们加入刷新TLB时便会通过cr3来获取对应的物理地址信息,我们修改代码中的IdtEntry如下:

我们通过TLB机制来进行刷新:当对cr3进行赋值时便会刷新 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
void __declspec(naked) IdtEntry()
{
__asm {
mov eax, ds: [0x0041c000] ; 通过访问,确保TLB中存在有该项目
}
// 保存page2的页表项
g_oldPTE[0] = PTE(0x0041c000)[0];
g_oldPTE[1] = PTE(0x0041c000)[1];

// 将page2映射改为page1
PTE(0x0041c000)[0] = PTE(0x0041b000)[0];
PTE(0x0041c000)[1] = PTE(0x0041b000)[1];

// TLB刷新
__asm {
mov eax, cr3
mov cr3, eax
}

g_out = page2[0]; // 正常情况下应该为1,但是因为TLB的存在,我们没有通过cr3进行访问内存,结果为2

// 还原page2的页表项
PTE(0x0041c000)[0] = g_oldPTE[0];
PTE(0x0041c000)[1] = g_oldPTE[1];
__asm
{
iretd
}
}

修改后程序执行结果如下:

至此我们成功的验证了TLB的存在

从上面我们大致可以看出,当我们对页进行了修改时,对其内容进行读写。如果对应数据出现在了TLB中,那么便会从TLB中优先获取值,当刷新TLB后便会从内存中进行读取,也就是我们修改后的页上开始读

TLB G属性

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

一般来说三环程序来说没有G属性,我们可以手动添加一个int 3在代码中,使其中断到我们的Windbg中,

可以明显的看到我们的页属性中没有对应的 G位属性

我们观察GDTR的页属性可以看到 G位属性的存在

印证我们之前提到的高 2G 内存,每次重建会比较消耗时间,所以在系统内其会设置G位属性,通过查询Windbg的帮助手册我们可以看到

其对应位为0x100,所以我们在修改PTE时可以进行或上0x100,来达到添加 G位属性的目的

我们先不设置 G位属性来进行测试,我们将IdtEntry设置如下

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
void __declspec(naked) IdtEntry()
{
// 保存page2的页表项
g_oldPTE[0] = PTE(0x0041c000)[0];
g_oldPTE[1] = PTE(0x0041c000)[1];
// 将page2映射改为page1
PTE(0x0041c000)[0] = PTE(0x0041b000)[0];
PTE(0x0041c000)[1] = PTE(0x0041b000)[1];
// TLB刷新
__asm {
mov eax, cr3
mov cr3, eax
}
__asm {
mov eax, ds: [0x0041c000] ; 通过访问,确保TLB中存在有该项目
}
g_out_1 = page2[0]; // 此时记录的值为 1
// 还原page2的页表项
PTE(0x0041c000)[0] = g_oldPTE[0];
PTE(0x0041c000)[1] = g_oldPTE[1];
// TLB刷新
__asm {
mov eax, cr3
mov cr3, eax
}
g_out_2 = page2[0]; // 此时记录的值为 2
__asm
{
iretd
}
}

可以看到输出为:

我们第一次刷新TLB后读取到的数据为我们修改后的page1的内容,再次修改page2PTE后读取的是我们恢复的,证明对应TLB信息随cr3的更新而清空了,如果我们设置对应的G属性位后呢?代码如下:

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
void __declspec(naked) IdtEntry()
{
// 保存page2的页表项
g_oldPTE[0] = PTE(0x0041c000)[0];
g_oldPTE[1] = PTE(0x0041c000)[1];
// 将page2映射改为page1
PTE(0x0041c000)[0] = PTE(0x0041b000)[0] | 0x100; // 添加G位,防止在cr3更新时刷新TLB
PTE(0x0041c000)[1] = PTE(0x0041b000)[1];
// TLB刷新
__asm {
mov eax, cr3
mov cr3, eax
}
__asm {
mov eax, ds: [0x0041c000] ; 通过访问,确保TLB中存在有该项目
}
g_out_1 = page2[0]; // 此时记录的值为 1
// 还原page2的页表项
PTE(0x0041c000)[0] = g_oldPTE[0];
PTE(0x0041c000)[1] = g_oldPTE[1];
// TLB刷新
__asm {
mov eax, cr3
mov cr3, eax
}
g_out_2 = page2[0]; // 此时记录的值为 2
__asm
{
iretd
}
}

可以看到输出如下:

可以看到TLB所保存的数据并没有清除,而是得到了对应的保存

但是TLB中的数据是一直存在的吗?显然不是,我们可以通过以下汇编指令来强制对含有G位属性的页的TLB来进行刷新

1
2
3
__asm{
invlpg ds:[xxx] ; xxx 为对应地址
}

我们将原来的第二次TLB刷新更新为:

1
2
3
__asm{
invlpg ds:[0x0041b000]
}

可以成功的看到对应g_out_2的输出又回到了 2

总结

上述的应用我们可以应用到内核的Hook,我们可以将需要Hook的地址修改其PTE加入一个可写选项,需要注意的是我们需要对其TLB进行强制刷新,因为其处于内核状态下是肯定有G位属性的,如果简单的采用cr3的刷新方式来进行刷新TLB时可能会产生异常


Windows内核实验 ——— 数据TLB
https://equinox-shame.github.io/2023/07/01/Windows内核实验 — 数据TLB/
作者
梓曰
发布于
2023年7月1日
许可协议