内存管理

Windows的内存管理是很复杂的,我们没必要精确到每一行代码的学习,所以我们以线性地址的管理作为突破口,重点学习线性地址是如何管理的,物理地址、物理页是如何管理的,以及了解我们平时所听到的缺页解决了什么问题,堆、栈内存申请等内容。这样,我们才能对Windows的内存管理有一个清晰的认识。

线性地址的管理

进程空间的地址划分

每一个进程都有自己的4GB线性地址空间,这是我们之前的学习中反复提到的,这4GB的线性地址空间的划分大致如下图所示。虽然每个线程名义上有4GB的内存空间,但实际上只有低2G的内存空间可以使用,高2G的内存空间是共用的。除此之外,我们可以根据如下图知道,4GB空间中只有用户模式区和内核区可以访问使用,而空指针赋值区和64KB禁入区是不允许的。无论是用户空间还是内核空间,肯定都是要记录线性地址的分配情况的,这样才不会出现重复分配的情况

images/download/attachments/2949228/image2023-7-17_15-58-16.png

在内核中,它会通过一个链表将线性地址链起来,这样要想再分配内核空间的线性地址,直接通过这个链表查询线性地址的属性即可找到未分配的线性地址进行申请,这里不过多赘述,可以自行学习。我们现在主要还是了解用户空间的线性地址的管理,也就是进程的低2G内存空间这是因为不同进程的高2G地址往往是相同的,因此高2G的地址变化较少,使用链表的方式足够满足需求,而用户空间的地址管理就较为复杂。

用户空间线性地址的管理

在Windows下的用户空间线性地址是通过”搜索二叉树“的方式进行管理。在这里我们使用Windbg来看一下具体是如何管理的。

我们可以通过指令:!process 0 0,随便找一个进程,查看它的进程结构体_EPROCESS,在该结构体的0x11c偏移位成员VadRoot(Vad,Virtual Address Descriptor,虚拟地址描述符),这个成员记录当前进程线性地址空间的搜索二叉树(我们可以称之为Vad树),其对应的地址是二叉树根节点的地址。(它里面的每一个节点都记录了一块被占用的线性地址空间。)

images/download/attachments/2949228/image2023-9-8_16-53-51.png

_MMVAD

VadRoot对应的类型实际上就是_MMVAD结构体,也就表示该结构体为搜索二叉树结点的数据类型,我们可以通过该数据类型对这个节点所对应的线性地址区域有个整体上的认识:

images/download/attachments/2949228/image2023-9-8_17-1-36.png

该结构体中每个成员的含义如下:

  1. StartingVpn:当前节点对应的内存的线性地址起始位置(以页为单位,即4KB),因此本例中实际上对应的起始位置为0x190000。

  2. EndingVpn:当前节点对应的内存的线程地址结束位置(以页为单位,即4KB),因此本例中实际上对应的结束位置为0x19f000。

  3. Parent:父节点地址,本例中为根节点,没有父节点,所以为空。

  4. LeftChild:左子树地址。

  5. RightChild:右子树地址。

  6. u:用于表示内存属性。

  7. ControlArea:控制区域。

在知道左子树或右子树地址后,就可以通过指令:dt _MMVAD 地址,来一层一层找到所有的节点:

images/download/attachments/2949228/image2023-9-8_17-13-59.png

根据列出的二叉树结构体,我们知道成员StartingVpn、EndingVpn用于表达线性地址区间,因此我们可以通过遍历所有子树的这两个成员,只要不在线性地址区间内的线性地址就可以被我们使用的。

Windbg提供了一个更快捷的指令:!vad 根节点地址,该指令可以列出所在进程内线性地址的记录情况(包含了结构体地址、节点层级、线性地址区间、内存类型、内存属性等等):

images/download/attachments/2949228/image2023-9-8_17-15-28.png

ControlArea

那么我们想要了解线性地址到底是被谁占用的,就可以来看一下成员ControlArea,它同样也是一个结构体:_CONTROL_AREA。

我们需要关注的是FilePointer成员,当这个值为空的时候,这块内存就是Private类型,也就是进程自己申请的内存

images/download/attachments/2949228/image2023-9-12_16-27-28.png

当该值不为空的情况下,则表示当前内存为Mapped类型,即映射了其他类型的文件(DLL、EXE等)在内存中,我们就可以跟进对应的结构体_FILE_OBJECT,通过其成员FileName知道映射文件的描述信息。

images/download/attachments/2949228/image2023-9-12_16-37-23.png

_MMVAD_FLAGS

在_MMVAD结构体中有一个成员u,其指向的是如下所示的一个union共同体结构:

union {
ULONG_PTR LongFlags;
MMVAD_FLAGS VadFlags;
} u;

虽然有两个成员,但是一般情况下只使用VadFlags成员,该成员也是一个结构体_MMVAD_FLAGS:

images/download/attachments/2949228/image2023-9-12_17-0-27.png

这里我们主要了解以下几个重要的成员:

  1. CommitCharge:最大可提供物理页的数目。

  2. ImageMap:表示当前是否映射了镜像文件(通常是可执行文件),值为1,则说明映射了,为0,则不是。

    1. 在指令!vad所展示表中,ImageMap为1的话都会有一个Exe字符串的标识:

    2. images/download/attachments/2949228/image2023-9-12_17-14-27.png
  1. Protection:表示当前对应内存块的属性,取值如下:

    • 1:READONLY:只读

    • 2:EXECUTE:可执行

    • 3:EXECUTE _READ:可执行、读

    • 4:READWRITE:读、写

    • 5:WRITECOPY:写拷贝

    • 6:EXECUTE_READWRITE:可执行、读、写

    • 7:EXECUTE_WRITECOPY:可执行、写拷贝

  2. PrivateMemory:表示当前是否是Private类型内存,值为1,则说明是,为0,则说明是Mapped类型。

Private Memory

从线性地址角度看,内存分为Private和Mapped类型:

  1. 通过VirtualAlloc/VirtualAllocEx申请的就是Private类型内存。(独享物理页)

  2. 通过CreateFileMapping映射的就是Mapped类型内存。(会出现与其他进程共享物理页)

VirtualAlloc

我们来实际使用一下VirtualAlloc函数,便于我们更加清晰的了解私有内存:

LPVOID VirtualAlloc{
LPVOID lpAddress,
DWORD dwSize,
DWORD flAllocationType,
DWORD flProtect
};

VirtualAlloc函数一共有4个参数,其每个参数的含义和作用如下:

  1. lpAddress:想要申请的起始内存地址,需要结合第二个参数dwSize。如果申请的内存被占用,则无法申请。如果我们手动去填写该值还需要去遍历Vad树才知道什么地址区间可以申请,过程比较麻烦,所以我们可以将这个值填为NULL,系统就会自动去分配一块没被占用的内存空间。

  2. dwSize:想要申请的内存空间大小,如果lpAddress的值非NULL,那么lpAddress就是地址的起点,lpAddress+dwSize-1就是地址的终点。该值必须是0x1000(4KB,即一个物理页)的整数倍。

  3. flAllocationType:分配的类型,有两种类型:

    1. MEM_COMMIT:创建节点并分配物理页

    2. MEM_RESERVE:只创建节点,不分配物理页

  4. flProtect:保护属性,例如PAGE_READWRITE、PAGE_READONLY。

了解完每个参数的意义之后我们可以来编写一段简单的代码,来使用VirtualAlloc:

#include <windows.h>
#include <stdio.h>
 
LPVOID lpAddress;
 
void main()
{
printf("Start ...\n");
getchar();
printf("VirtualAlloc ...\n");
lpAddress = ::VirtualAlloc(NULL, 0x1000*2, MEM_COMMIT, PAGE_READWRITE);
printf("lpAddress: %x \n", lpAddress);
getchar();
}

将代码进行编译执行,由于使用了getchar函数所以程序会先停止,通过Windbg来看一下Vad树,然后再回到程序,回车一下,再来看Vad树:

images/download/attachments/2949228/image2023-9-12_18-29-46.png

在执行完VirtualAlloc后,新分配了一个大小为0x2000的内存空间(0x390000-0x391000),内存属性也对应了VirtualAlloc的参数。

images/download/attachments/2949228/image2023-9-12_18-29-56.png

如上所述,我们就能理清楚VirtualAlloc函数申请内存的过程了,它会在进程低2G还没有使用的内存空间中,分配一个指定大小的私有内存空间,然后将其对应的_MMVAD结构体添加到对应进程的Vad树中。

堆与栈

我们在之前的初级篇中了解到C语言可以使用malloc、C++语言可以使用new的方式来“申请内存”,为什么在这里我们不以它两为例呢。

我们可以来看一下这两个方法的调用链,也就表示无论是malloc和new,其实都是HeapAlloc:

malloc -> _nh_malloc_dbg -> _heap_alloc_dbg -> _heap_alloc_base -> HeapAlloc
new -> _nh_malloc -> _nh_malloc_dbg -> _heap_alloc_dbg -> _heap_alloc_base -> HeapAlloc

HeapAlloc

HeapAlloc是一个在堆中分配内存的函数,但它并不直接申请内存,因为HeapAlloc函数并没有进入到Ring 0,所以它没有权限直接向操作系统申请内存。那么HeapAlloc是如何在堆中分配内存呢?这涉及到对堆的理解。堆实际上是操作系统通过调用VirtualAlloc函数预先分配的一大块内存区域。HeapAlloc的作用是从这个预分配的内存区域中划分出一小部分来使用。

可以做一个比喻来理解,将VirtualAlloc看作是一个批发市场,需要一次性从操作系统那里批量购买内存,而且必须是4KB的整数倍。而HeapAlloc则类似于零售商,从已经批发到批发市场(VirtualAlloc)的货物中购买一小部分。换句话说,HeapAlloc并不是直接向操作系统申请内存,而是在已经预分配的堆内存中分配小块内存供程序使用。这种方式可以提高内存分配的效率,减少频繁调用操作系统的开销。

栈其实和堆一样,也是预先分配好的内存,但是栈不需要HeapAlloc这种API来进行分配,可以直接使用,例如我们声明一个局部变量就会使用到栈。

实验

我们可以写一段代码,结合Vad树来证实一下我们上面的内容。

#include <windows.h>
#include <stdio.h>
 
int a = 0x4321;
 
void main()
{
getchar();
 
int x = 0x12345678;
int* y = (int*)malloc(sizeof(int)*128);
 
printf("Global: %x \n", &a);
printf("Stack: %x \n", &x);
printf("Heap: %x \n", y);
getchar();
}

运行程序,在第一个getchar停留,看一下Vad树,接着继续执行:

images/download/attachments/2949228/image2023-9-12_19-41-5.png

images/download/attachments/2949228/image2023-9-12_19-41-18.png

执行完成在第二个getchar停留,再来看一下Vad树,我们会发现没有多出子节点,并且无论是全局变量、局部变量、堆,都是在已使用的内存地址区间去申请使用的,全局变量是编译时就会打包在文件里的,所以它的地址就在Mapped类型内存地址区间中(即当前进程的VATest.exe文件):

images/download/attachments/2949228/image2023-9-12_19-42-2.png

综上所示,我们可以知道VirtualAlloc/VirtualAllocEx是申请私有内存的唯一方式

Mapped Memory

我们再来看一下Mapped类型内存,其实我们通过查看Vad树就会发现一个进程的Mapped类型内存是多于私有内存的,Mapped类型内存会出现与其他进程共享物理页的情况。

images/download/attachments/2949228/image2023-9-13_16-5-36.png

如下图所示,是一个进程的Vad树,有很多Mapped类型内存,有具体的文件时我们可以称之为共享文件,没有具体文件则称之为共享物理页(即Pagefile section, shared commit xxx部分)。

images/download/attachments/2949228/image2023-9-12_19-42-2.png

共享内存函数

我们首先了解一下共享内存所需的三个函数:CreateFileMapping、OpenFileMapping、MapViewOfFile。

CreateFileMapping

CreateFileMapping的作用是创建一个共享的物理页/文件, 执行成功 返回映射对象句柄,语法格式:

HANDLE CreateFileMapping(
HANDLE hFile,
LPSECURITY_ATTRIBUTES lpAttributes,
DWORD flProtect,
DWORD dwMaximumSizeHigh,
DWORD dwMaximumSizeLow,
LPCTSTR lpName
);

参数含义如下:

  1. hFile:指定要映射的文件句柄(共享文件)或者是INVALID_HANDLE_VALUE(共享物理页)。

  2. lpAttributes:指定安全属性,通常设为NULL。

  3. flProtect:指定保护属性,确定其他进程对共享内存的访问权限,常见的属性有PAGE_READONLY、PAGE_READWRITE等。

  4. dwMaximumSizeHigh和dwMaximumSizeLow:两个参数合起来指定了文件映射对象的最大大小。这两个参数一起构成了一个64位的整数,表示以字节为单位的最大大小。dwMaximumSizeLow通常可以设为BUFSIZ。

  5. lpName:指定映射对象的名称。没有名称,可以设置为NULL。

OpenFileMapping

OpenFileMapping的作用是打开一个已存在的文件映射对象,并返回对应的句柄,语法格式:

HANDLE OpenFileMapping(
DWORD dwDesiredAccess,
BOOL bInheritHandle,
LPCSTR lpName
);

参数含义如下:

  1. dwDesiredAccess:指定映射对象的文件数据的访问方式,要与CreateFileMapping中设置的flProtect参数相匹配。

    1. FILE_MAP_ALL_ACCESS 对应 PAGE_READWRITE

    2. FILE_MAP_COPY 对应 PAGE_WRITECOPY

    3. FILE_MAP_EXECUTE 对应 PAGE_EXECUTE_READ 或 PAGE_EXECUTE_READWRITE

    4. FILE_MAP_READ 对应 PAGE_READONLY 或 PAGE_READWRITE

    5. FILE_MAP_WRITE 对应 PAGE_READWRITE

  2. bInheritHandle:指定句柄是否可以被子进程继承。如果为TRUE,则允许继承;如果为FALSE,则不允许继承。

  3. lpName:指定已存在的映射对象的名称。

MapViewOfFile

MapViewOfFile的作用是 将创建的映射对象映射到调用进程的地址空间(物理页与线性地址进行关联),执行成功返回共享映射对象的首地址指针 ,语法格式:

LPVOID WINAPI MapViewOfFile(
HANDLE hFileMappingObject,
DWORD dwDesiredAccess,
DWORD dwFileOffsetHigh,
DWORD dwFileOffsetLow,
SIZE_T dwNumberOfBytesToMap
);

参数含义如下:

  1. hFileMappingObject:指定要映射的映射对象句柄,CreateFileMapping或OpenFileMapping函数返回的映射对象句柄。

  2. dwDesiredAccess:与OpenFileMapping的参数意义一样。

  3. dwFileOffsetHigh和dwFileOffsetLow:指定要映射的文件的偏移量。这两个参数结合起来表示一个64位的偏移量。可以使用0表示从文件的开头进行映射。

  4. dwNumberOfBytesToMap:指定要映射的字节数。可以与CreateFileMapping要映射的大小对应,即映射全部。

共享内存实验

共享物理页

我们已经了解了基本的函数,接下来我们写两段代码。

首先第一段代码用来创建映射对象并共享,如下所示:

#include <windows.h>
#include <stdio.h>
 
#define M_NAME "MappedMemoryA"
 
void main()
{
HANDLE hMap = CreateFileMapping(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, BUFSIZ, M_NAME);
LPTSTR lpBuff = (LPTSTR)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, BUFSIZ);
*(PDWORD)lpBuff = 0x12345678;
printf("Process A Write Address: %p, Content: %x", lpBuff, *(PDWORD)lpBuff);
getchar();
}

我们可以运行该程序,然后去查看Vad树,成功创建并映射了我们要共享的数据。

images/download/attachments/2949228/image2023-9-13_17-12-28.png

images/download/attachments/2949228/image2023-9-13_17-12-36.png

接着第二段代码用于读取共享的数据,我们需要在原有进程不关闭的情况下另起一个进程:

#include <windows.h>
#include <stdio.h>
 
#define M_NAME "MappedMemoryA"
 
void main()
{
HANDLE hMap = OpenFileMapping(FILE_MAP_ALL_ACCESS, FALSE, M_NAME);
LPTSTR lpBuff = (LPTSTR)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, BUFSIZ);
printf("Process B Read Address: %p, Content: %x", lpBuff, *(PDWORD)lpBuff);
getchar();
}

同样运行该程序,B进程成功的通过共享映射对象的名称获取到了A进程共享的数据,并且在B进程的Vad树中也多了一个节点。

images/download/attachments/2949228/image2023-9-13_17-26-41.png

images/download/attachments/2949228/image2023-9-13_17-29-7.png

因此我们得出一个结论,一个进程在底层准备了一个或多个物理页,这些物理页在准备阶段不可被使用。但是,任何进程只要获得了该物理页的句柄,就可以将其映射到自己的内存空间中,从而能够使用这些内存。通过这种方式,进程可以存储数据或读取数据(根据创建物理页时设置的属性),实现了资源共享的目的。

共享文件

我们依旧需要写两端代码,第一段用于创建并映射共享文件:

#include <windows.h>
#include <stdio.h>
 
#define M_NAME "MappedMemoryA"
 
void main()
{
DWORD dwBytesWritten;
 
HANDLE hFile = CreateFile("C:\\abc.txt", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
WriteFile(hFile, M_NAME, strlen(M_NAME), &dwBytesWritten, NULL);
 
HANDLE hMap = CreateFileMapping(hFile, NULL, PAGE_READWRITE, 0, BUFSIZ, M_NAME);
LPTSTR lpBuff = (LPTSTR)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, BUFSIZ);
printf("Process A Write Address: %p", lpBuff);
getchar();
}

运行如上代码,会在C盘创建一个abc.txt然后写入MappedMemoryA,接着将该文件的句柄映射到Vad树中实现共享。

images/download/attachments/2949228/image2023-9-13_17-43-59.png

images/download/attachments/2949228/image2023-9-13_17-45-1.png

第二段用于读取共享文件:

#include <windows.h>
#include <stdio.h>
 
#define M_NAME "MappedMemoryA"
 
void main()
{
HANDLE hMap = OpenFileMapping(FILE_MAP_ALL_ACCESS, FALSE, M_NAME);
LPTSTR lpBuff = (LPTSTR)MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, BUFSIZ);
printf("Process B Read Address: %p, Content: %s", lpBuff, lpBuff);
getchar();
}

运行如上代码,我们可以成功读取共享文件的内容, 共享文件的主要好处是能有效地处理大文件,减少重复的内存加载和拉伸操作,从而节省资源开销。需要注意的是,如果一个进程修改了共享文件,那么所有使用该文件的进程都会受到影响

images/download/attachments/2949228/image2023-9-13_17-45-56.png

写拷贝

而我们会发现我们的共享文件在Vad树中并没有“Exe”的字符串标识,这是因为我们的文件并不是用来执行的,如果是用来执行的文件则会有该字符串标识,例如我们使用LoadLibrary来加载文件在Vad树中就会有所体现,并且在这里内存属性为可执行、写拷贝:

images/download/attachments/2949228/image2023-9-13_17-56-2.png

images/download/attachments/2949228/image2023-9-13_17-56-28.png

因此我们知道LoadLibrary函数底层实现利用了映射文件的机制来实现共享文件,被LoadLibrary加载的映射文件具备EXECUTE_WRITECOPY内存保护属性,该属性用于防止其他进程修改映射文件。

当一个进程试图修改映射文件时,如果该文件的内存保护属性是EXECUTE_WRITECOPY,操作系统会使该进程指向一个新的物理页,该物理页保存着映射文件的副本,这样进程对映射文件的修改不会影响真正的映射文件,这种保护机制可用于防止对系统函数进行Hook等操作。

物理内存的管理

在保护模式篇的学习中我们实际上已经有了一些物理内存的概念,结合之前的学习我们来重新的了解一下物理内存的管理。

认识物理内存

最大物理内存

  1. 10-10-12分页:最多识别物理内存为4GB;

  2. 2-9-9-12分页:最多识别物理内存为64GB。

操作系统限制

在Windows XP 32位操作系统中,即使是2-9-9-12分页模式,也仍然无法超越4GB,这是因为 在内核函数中(Ntoskrnl.exe模块),存在一个名为MmAddPhysicalMemoryEx的函数。该函数调用了ExVerifySuite函数,其限制了操作系统无法识别超过4GB的情况。关于ExVerifySuite函数的详细分析超出了当前学习的范围,请自行进行进一步研究。

images/download/attachments/2949228/image2023-9-13_18-14-15.png

实际物理内存

我们可以通过任务管理器来查看实际的物理内存:

images/download/attachments/2949228/image2023-9-13_18-26-23.png

也可以通过Windbg来查看,即指令dd MmNumberOfPhysicalPages,这个值是物理页总个数,我们只要乘以4KB即可算出物理内存的KB大小:

images/download/attachments/2949228/image2023-9-13_18-28-53.png

images/download/attachments/2949228/image2023-9-13_18-28-14.png

空闲页的管理

全局数组及成员

在操作系统中,存在一个全局数组,用于记录所有物理页的信息。

数组指针:_MMPFN* MmPfnDatabase
数组长度:MmNumberOfPhysicalPages

每一个物理页都是该全局数组的一个成员,对应结构体_MMPFN,该结构体有很多union类型成员,也就表示当前结构体的意义很多:

kd> dt _MMPFN
nt!_MMPFN
+0x000 u1 : __unnamed
+0x004 PteAddress : Ptr32 _MMPTE
+0x008 u2 : __unnamed
+0x00c u3 : __unnamed
+0x010 OriginalPte : _MMPTE
+0x018 u4 : __unnamed
 
typedef struct _MMPFN
{
union
{
PFN_NUMBER Flink;
ULONG WsIndex; // 该页面在进程工作集链表中的索引
PKEVENT Event;
NTSTATUS ReadStatus;
SINGLE_LIST_ENTRY NextStackPfn;
SWAPENTRY SwapEntry;
} u1;
PMMPTE PteAddress; // 执行此页面的PTE的虚拟地址
union
{
PFN_NUMBER Blink;
ULONG_PTR ShareCount; // 指向该页面的PTE数量
} u2;
union
{
struct
{
USHORT ReferenceCount; // 代表这个页面必须要保留在内存中的引用计数
MMPFNENTRY e1;
};
struct
{
USHORT ReferenceCount;
USHORT ShortFlags;
} e2;
} u3;
union
{
MMPTE OriginalPte; // 包含了指向此页面的PTE的原始内容
LONG AweReferenceCount;
PMM_RMAP_ENTRY RmapListHead;
};
union
{
ULONG_PTR EntireFrame;
struct
{
ULONG_PTR PteFrame:25;
ULONG_PTR InPageError:1;
ULONG_PTR VerifierAllocation:1;
ULONG_PTR AweAllocation:1;
ULONG_PTR Priority:3;
ULONG_PTR MustBeCached:1;
};
} u4; // 指向该页面的PTE所在的页表页面的物理页帧编号,以及一些标志位

如下图所示,我们可以在Windbg中查看该全局数组指针(0x81086000),当前这个地址就对应了一个_MMPFN结构体(用于描述物理页的信息),该结构体大小为0x1C(操作系统版本决定),该结构体本身并没有记录描述信息的归属。

images/download/attachments/2949228/image2023-9-13_18-37-41.png

微软使用了一种很巧妙的方式来找到结构体的归属物理页:MmNumberOfPhysicalPages存储的值表示物理页的总数,每个_MMPFN结构体用来描述一个物理页,因此就让每个物理页与全局数组中每个_MMPFN结构体按顺序对应。

算式为:MmPfnDatabase + 结构体大小 * 数组索引,举例说明:

  1. 第一个物理页(0x0000 - 0x0FFF,去掉后三位就是索引0x0000)对应的_MMPFN结构体地址:0x81086000 + 0x1C * 0;

  2. 第二个物理页(0x1000 - 0x1FFF,去掉后三位就是索引0x1000)对应的_MMPFN结构体地址:0x81086000 + 0x1C * 1;

  3. 以此类推...

物理页状态

物理页有很多种状态,在_MMPFN.u3.e1处的结构是一个位段(_MMPFNENTRY),其结构如下,其中成员PageLocation表示了当前物理页的状态:

kd> dt _MMPFNENTRY
nt!_MMPFNENTRY
+0x000 Modified : Pos 0, 1 Bit
+0x000 ReadInProgress : Pos 1, 1 Bit
+0x000 WriteInProgress : Pos 2, 1 Bit
+0x000 PrototypePte : Pos 3, 1 Bit
+0x000 PageColor : Pos 4, 3 Bits
+0x000 ParityError : Pos 7, 1 Bit
+0x000 PageLocation : Pos 8, 3 Bits
+0x000 RemovalRequested : Pos 11, 1 Bit
+0x000 CacheAttribute : Pos 12, 2 Bits
+0x000 Rom : Pos 14, 1 Bit
+0x000 LockCharged : Pos 15, 1 Bit
+0x000 DontUse : Pos 16, 16 Bits

每一种状态对应一个链表,该链表串着所有当前状态的物理页,成员PageLocation的值和链表、状态、含义对应如下(都可以表示为空闲的物理页):

取值

对应的全局链表

状态类型

含义

0

MmZeroedPageListHead

零化

表示页面是空闲的,不属于任何工作集。零化链表中的每个物理页的内容已经被全部清零,即所有数据被初始化为零。

1

MmFreePageListHead

空闲

表示页面是空闲的,不属于任何工作集。物理页是周转使用的,页中的数据可能包含不确定的内容。当系统处于空闲状态时,有专门的线程从该链表中获取物理页,并将其清零后放入零化链表。

2

MmStandbyPageListHead

备用

这种页面原本属于某个进程或系统工作集,但现在已从工作集中移除。这些页面包含的数据仍然对原来的工作集有效,原来工作集中的页表项PTE仍指向该页面,但已被标记为无效的正在转移的PTE。这种状态是导致缺页异常的主要原因之一。

简而言之:当系统内存不够的时候,操作系统会把物理内存中的数据交换到硬盘上,此时页面不是直接挂到空闲链表上去,而是挂到备用链表上,虽然释放了,但里边的内容还是有意义的。

3

MmModifiedPageListHead

修改

类似于备用状态,页面已从原来的工作集中移除,但页面的内容已被修改。原来工作集中的PTE仍然指向该物理页面,但已被标记为无效的正在转移的PTE。如果系统要回收此类页面以供其他用途,则必须将页面的内容写入磁盘。

4

MmModifiedNoWritePageListHead

已修改但不写出

类似于已修改状态,但区别在于内存管理器不会将页面的内容写入磁盘。这种状态通常用于临时修改的数据,系统不需要将其持久化到磁盘中,以提高性能。

5

MmBadPageListHead

损坏

表示页面发生了硬件错误,系统不再使用该页面。这种页面通常被标记为无效页,不再分配给任何进程或工作集。

每个链表的结构如下,可以看见都是第3、4个成员为LIST_HEAD,它们都是链表头的索引,不同的是前者是从前往后,后者是从后往前

MMPFNLIST MmZeroedPageListHead = {0, ZeroedPageList, LIST_HEAD, LIST_HEAD};
MMPFNLIST MmFreePageListHead = {0, FreePageList, LIST_HEAD, LIST_HEAD};
MMPFNLIST MmStandbyPageListHead = {0, StandbyPageList, LIST_HEAD, LIST_HEAD};
MMPFNLIST MmModifiedPageListHead = {0, ModifiedPageList, LIST_HEAD, LIST_HEAD};
MMPFNLIST MmModifiedNoWritePageListHead = {0, ModifiedNoWritePageList, LIST_HEAD, LIST_HEAD};
MMPFNLIST MmBadPageListHead = {0, BadPageList, LIST_HEAD, LIST_HEAD};
MMPFNLIST MmRomPageListHead = {0, StandbyPageList, LIST_HEAD, LIST_HEAD};

查询链表实验

我们了解到所有物理页都记录在一个全局数组中,每个物理页对应一个_MMPFN物理页描述结构,空闲的物理页有6种状态,并分别对应一个全局链表。现在我们将把这些知识点联系在一起,并通过一个实验来巩固所学。我们以零化链表为例,来查询该链表上的所有物理页。

直接在Windbg中通过指令: dd MmZeroedPageListHead ,来找到链表头索引:

images/download/attachments/2949228/image2023-9-13_19-29-8.png

接着我们只要通过指令:dd 0x81086000 + 0x1c * 0x0001a7a8,即之前所说的公式,找到_MMPFN结构体,再根据PageLocation(0xC偏移位结构体成员的成员)确认这是一个零化物理页:

images/download/attachments/2949228/image2023-9-13_19-41-33.png

而在这里我们找到了一个零化物理页,想要找到第二个该怎么办?其实很简单,我们再回到_MMPFN结构体上,它的成员u1可以表示为Flink,u2可以表示为Blink:

images/download/attachments/2949228/image2023-9-13_19-46-56.png

因此我们可以来看下当前结构体,u1是有值的,u2是没有值,那么我们就可以通过u1来找到下一个零化物理页对应的结构体:

images/download/attachments/2949228/image2023-9-13_19-34-4.png

即通过指令:dd 0x81086000 + 0x1c * 0x0007c7a8(Flink),成功找到了另一个零化物理页_MMPFN结构体:

images/download/attachments/2949228/image2023-9-13_19-50-54.png

那么也就表示在当前描述零化物理页的_MMPFN结构体中,u1、u2成员就分别代表了Flink、Blink,通过这两个成员将所有零化物理页串成一个链表。

images/download/attachments/2949228/image2023-9-13_19-53-22.png

不同状态物理页描述结构体_MMPFN对应的成员意义如下图所示:

images/download/attachments/2949228/image2023-9-13_19-55-1.png

活动页的管理

物理页分为两大类:空闲页、活动页,我们了解了空闲页再来看一下活动页。

首先任意打开一个进程,定位到进程结构体0x1f8偏移位,有一个Vm成员,是_MMSUPPORT结构体,该结构体下有一个VmWorkingSetList成员,它记录着当前进程相关的工作集信息:

images/download/attachments/2949228/image2023-9-13_20-7-39.png

VmWorkingSetList对应的结构体_MMWSL还有一个Wsle成员,用来描述一个有效页面。

这部分海东老师也是一笔带过,想要具体了解可以阅读《Windows内核原理与实现》。

缺页异常

什么是缺页异常

我们以32位的PTE为例,属性位P位标识当前页面是否有效,当CPU访问一个地址,其PTE的P位为0,此时就会产生缺页异常

images/download/attachments/2949228/image2023-9-13_20-23-41.png

缺页异常并非总是不利的,实际上,在Windows系统中,每秒都会发生缺页异常。正是通过这种异常机制,Windows才能够更有效地利用物理页资源。

内存交换与虚拟内存

假设当前操作系统只有2M的有效物理内存。如果一个线程使用VirtualAlloc函数申请了某个线性地址对应的物理页,并且一直占用不释放,很快就会占满整个内存。然而线程并非始终处于执行状态,有时会进入休眠,进入等待队列。线程处于休眠状态,却仍然占用着物理页,导致内存利用效率非常低下。

为了提高物理内存的利用效率,Windows引入了内存交换机制。该机制的核心思想是,只有正在被使用的线性地址才会被分配物理页。如果某个线性地址在一段时间内没有被使用,或者当前的物理页资源接近耗尽,操作系统将把该线性地址对应的物理页上的数据保存到硬盘,并将对应的页表项(PTE)中的P位设置为0。这样,该物理页就被释放出来供其他线性地址使用。

我们可以在Windows XP中找到虚拟内存:系统属性->高级->性能->设置->高级->虚拟内存->更改。

images/download/attachments/2949228/image2023-9-13_20-52-45.png

而这个虚拟内存实际上存放在C盘下的pagefile.sys,其对应的大小就是上图中的初始化的大小,内存交换到硬盘上的数据就会存在这个文件中。

images/download/attachments/2949228/image2023-9-13_20-54-31.png

当操作系统将物理页上的数据保存到硬盘上时,也将该线性地址对应物理页的PTE的属性位P位设位了0。一旦该线性地址再次被访问,由于P位为0,则会触发缺页异常。那么操作系统是如何处理的呢?当P位为0时,此时的PTE被称作无效PTE,有以下四种情形:

images/download/attachments/2949228/image2023-9-13_20-57-33.png

在不同的情况下,处理缺页异常的方式也不同。其中,由于内存交换引起的缺页异常属于第一种情况,即页面文件中的情况。在这种情况下,页表项(PTE)的1-4位、5-9位和12-31位都有值,表示线性地址是有效的,只是对应的数据位于硬盘上。

当发生这种缺页异常时,异常处理程序(例如e号中断)会根据PTE的描述(即12-31位的页面文件偏移),从pagefile.sys中获取数据内容,并将其加载到一个新的物理页上。然后将PTE的12-31位设置为新的物理页地址,并将P位设置为1。这样,缺页异常的处理就完成了。这种类型的缺页异常非常常见,几乎时刻都在发生,但对于用户来说,是察觉不到任何异常的,程序仍然正常执行。

保留与提交的误区

回顾之前介绍的VirtualAlloc函数,其第三个参数flAllocationType可以有两个值,含义如下:

  1. MEM_RESERVE:申请内存时,仅保留线性地址,不分配物理页。

  2. MEM_COMMIT:可以有物理页,但不是立即有或者一直有。

我们一直认为当参数值为MEM_COMMIT时,申请内存就会给我们物理页,但事实并非如此,我们可以写一段代码来论证:

#include <windows.h>
#include <stdio.h>
 
void main()
{
LPVOID lpAddress = ::VirtualAlloc(NULL, 0x1000*2, MEM_COMMIT, PAGE_READWRITE);
getchar();
*(PDWORD)lpAddress = 0x12345678;
getchar();
}

执行代码,在第一个getchar停留,来到Windbg查看进程的Cr3,使用指令:!vtop Cr3 线性地址,会帮我们自动将线性地址转换为物理地址,但是在转换过程中PTE为空,所以最后提示PAE zero PTE,也就表示当前并没有物理页与线性地址进行关联。

images/download/attachments/2949228/image2023-9-13_21-16-22.png

接着我们回到程序按回车键,在第二个getchar停留,此时已经向申请的线性地址写入了0x12345678,再使用上述指令在Windbg中查看,此时PTE有值,且成功算出了物理地址:

images/download/attachments/2949228/image2023-9-13_21-24-11.png

其实这里也运用了缺页异常机制,当CPU访问线性地址时,如果发现PTE的值为0,就会触发无效PTE的第四种情况(原因未知,需要检查Vad)。在这种情况下,操作系统会检查当前进程的VAD树。

images/download/attachments/2949228/image2023-9-13_20-57-33.png

如果线性地址存在于Vad树中,操作系统将为该线性地址分配一个物理页,并填写PTE的12-31位和1-9位,将P位设置为1。这样,线性地址与物理页之间建立了映射关系。

如果线性地址在Vad树中不存在,操作系统将报告0xC0000005错误,表示访问违规。通过利用缺页异常的机制,操作系统能够动态地管理线性地址和物理页之间的映射关系,以提供有效的内存访问和保护机制。

写拷贝原理

最后我们来看一下写拷贝的原理,PTE是与物理内存相关的,而Vad树则与线性地址有关,写拷贝实现就利用了这两者的属性。

下面是具体的步骤:

  1. 当一个进程试图对受到写拷贝保护的文件进行写操作时,操作系统会检查PTE的R/W属性。

  2. 受写拷贝保护的文件的物理页所在的PTE的R/W属性被设置为0(只读)。

  3. 当操作系统检测到进程尝试向只读的物理页写入数据时,会触发异常并跳转到异常处理函数。

  4. 异常处理函数会查找进程的Vad树,并发现该文件的内存保护属性为写拷贝。

  5. 操作系统会创建一个新的物理页,并将源文件的内容拷贝到新的物理页中。

  6. 让试图修改文件的进程中的映射指向这个新的物理页。

images/download/attachments/2949228/image2023-9-13_21-35-37.png

通过这种方式,写拷贝实现了对受保护文件的写保护,确保进程对文件的修改只会影响到副本,而不会修改源文件本身。(Bypass写拷贝也很简单,直接将PTE的R/W属性设1)