0x00 前言
讲内存管理单纯的理论比较空洞,所以本文从探究DbgVeiw的内存分布开始,来探究windows系统的内存管理,讨论malloc和VirtualAlloc的差别,和缺页异常。
参考文章
https://blog.csdn.net/weixin_42052102/article/details/83757538
https://blog.csdn.net/weixin_42052102/article/details/83722047
https://blog.csdn.net/weixin_42052102/article/details/83751896
0x01 线性地址的管理
首先在虚拟机中打开一个.exe的文件,这里选择了DbgView,之后到Windbg里查看对应的进程
看一下_KPROCESS结构体
在0x11c的位置的VadRoot处是一个搜索二叉树的入口点,每一个节点都记录被占用的线性地址空间,每一个节点的结构,都是_MMVAD结构体
接下来,先手动找一下这个搜索二叉树的节点
观察一下1)Parent:因为是根节点所以没有父节点了
2)LeftChild / RightChild:左子树/右子树,一个是往左边拓展的线,一个是往右边拓展
重复这一过程就可以找到所有的节点(选择二叉树而并非链表是因为搜索二叉树查找的效率比链表高很多)
3)StartingVpn && EndingVpn: 这两个值是以页(4kb)为单位的,所以各把这两个数后面添上3个0,就是当前节点的起始位置与结束位置,也此两者之间就是已经被占用的位置,所以想找到那些已被占用和未被占用的线性地址,遍历这个二叉树就可以了。
4)ControlArea:看一下结构体
其中的FilePointer如果其中的值是NULL则线性地址对应的是真正的物理页
而如果指向一个_FILE_OBJECT(如上图),那线性地址对应的是某文件影射的内存,我们继续跟下去
5)_MMVAD+0x14成员u,我们不妨参考一下 ReactOS 里的说明
union {
ULONG_PTR LongFlags
MMVAD_FLAGS VadFlags
} u
主要使用的是MMVAD_FLAGS这个结构,跟进这个结构(和实际windows还是有一定差别的,但是主要成员没问题,其中Protection的偏移为0x14)
typedef struct _MMVAD_FLAGS
{
ULONG_PTR CommitCharge:19
ULONG_PTR NoChange:1
ULONG_PTR VadType:3
ULONG_PTR MemCommit:1
ULONG_PTR Protection:5//规定文件属性
// 1 READONLY 2 EXECUTE 3 EXECUTE _READ 4 READWITER
// 5 WRITECOPY 6 EXECUTE _READWITER 7 EXECUTE_WRITECOPY
ULONG_PTR Spare:2
ULONG_PTR PrivateMemory:1//是实际物理页(1:private)还是文件映射(0:mapped)
} MMVAD_FLAGS,*PMMVAD_FLAGS;
遍历所有节点,在windbg里也存在这样的指令! vad 0x89473968(这个是根节点的线性地址)
一下列举4种不同的情况
1. 其中的Level就是二叉树中的层级,
2. start和end 后面再加3个0(因为单位为4kb)就是每个节点线性地址的起始与终点位置
3.Private是指线性地址对应真实物理页(由),Mapped是指线性地址对应的是某文件影射的内存
4.文件的属性就是由上诉u里的MMVAD_FLAGS->Protection确定的,其中EXECUTE_WRITECOPY(写拷贝)这一情况下文会单独说明
(如果要把DLL模块隐藏的话,不止要断TEB,PEB的各种的链,vad这块也得想办法绕过去,如果直接删除的话一定会造成系统的不稳定,所以可以VAD树隐藏,一种是使 _MMVAD.StartingVpn=_MMVAD.EndingVpn,达到隐藏的效果,第二种可以将两个VAD结点融合达到隐藏效果,即p1.EndingVpn = p2.EndingVpn)
0x01 Private Memory
Private Memory,由上文可知线性地址对应的是实际物理页。具有Private Memory性质的线性地址是系统或者用户通过VirtualAlloc这个函数申请到的,那为什么叫Private?大概是因为VirtualAlloc申请的大小,是以物理页为单位的,当前线性地址独享整个物理页。
在我们写 C或C++时申请内存时常用的是 malloc(C)或new(C++),那与上段的 VirtualAlloc有是什么分别?
(因为new的底层实现就是malloc,所以不讨论new)以下分别说明:
VirtualAlloc:
首先看一下原型:
LPVOID VirtualAlloc
{
LPVOID lpAddress, // 要分配的内存区域的地址
DWORD dwSize, // 分配的大小以物理页为单位
DWORD flAllocationType, // 分配的类型
DWORD flProtect // 该内存的初始保护属性
};
1. lpAddress:不能再二叉树节点的StartingVpn && EndingVpn之间申请,要在节与节之间申请,如果没有特殊的需要通常填空
2. dwSize:以物理页(4KB)为单位
3.flAllocationType:1.MEM_RESERVE :保留指定线性地址空间,不分配物理内存
2.MEM_COMMIT :为指定线性地址地址空间提交物理内存
(这里多说以下,即使是commit时,刚刚申请下来,后也不会马上分物理页,而是等到真正使用到它的时候再使用物理页,下文缺页异常处会详细说明)
下面我们自己用VirtualAlloc申请一下内存:
#include <stdio.h>
#include <windows.h>
LPVOID lpAddress;
int main(int argc, char* argv[])
{
printf("还没有申请");
getchar();
lpAddress = VirtualAlloc(NULL, 0X1000, MEM_COMMIT,PAGE_READWRITE);//申请一个物理页 COMMIT 可读可写
printf("address:%x \n", lpAddress);
getchar();
return 0;
}
在还没申请的时候看一下对应进程的vad
继续运行:再看一下分布:
可以看到在3a0的地方多了我们申请的节点,一个物理页3a0,commit是1,如果是reserve的话commit就是0。
malloc
malloc是在堆里申请内存,实际上,是系统提前利用VirtualAlloc申请了一块很大的内存,malloc是在已经分配好的内存里,拿出一部分来用,VirtualAlloc就像学校到出版社批发一大堆教材,批发就必须有最低单位(物理页4kb),而malloc就像学生从学校批发来的教材中零散的选择自己想要的教材,没有最小的限制。
同样来验证一下:
int main(int argc, char* argv[])
{
printf("未开始分配");
getchar();
int x = 0x12345678;
printf("栈:%x \n",&x);
getchar();
int* y = (int*)malloc(sizeof(int)*128);
printf("堆:%x \n",y);
getchar();
return 0;
}
(可以逆一下malloc这个函数,会发现这个函数没有进入0环,也可以看出,malloc实际没有分配真正的内存)
运行,首先是栈:
运行,堆:
结果都如下图:
没有任何变化
堆在380~38f之间在节内
栈是从大到小的,理论上在30~12f之内。
验证完毕。
0x02 Mapped Memory
上文介绍了线性地址的其中一种(Private),这里来介绍线性地址的第二种mapped,Mapped性质的线性地址对应的是某文件影射的内存,这种线性地址不像Private(堆,栈...)独享物理页,而是同一物理页同时映射在不同的进程中,系统通过CreateFileMapping来申请
mapped性质的线性地址细分为两种:
一种是多个进程共享物理页
另一种是多个进程共享文件(物理页只有一份,物理上的文件映射到多个进程)
图解(嫖的):
看一下函数原型CreateFileMapping
HANDLE WINAPI CreateFileMapping(
_In_HANDLE hFile,//第一个参数如果不添则准备物理页,
//如果准备文件句柄,则不止准备物理页,还把文件与物理页关联上
_In_opt_LPSECURITY_ATTRIBUTES lpAttributes,
_In_DWORD flProtect,
_In_DWORD dwMaximumSizeHigh,
_In_DWORD dwMaximumSizeLow,
_In_opt_LPCTSTR lpName);
下面咱们自己实现以下:
以下是text1.exe(第一个进程)
#include <stdio.h>
#include <windows.h>
#define FileName "The_Shared_File"
int main(int argc, char* argv[])
{
//创建文件或物理页 我这里创建的是物理页
HANDLE hMapFile = CreateFileMapping(INVALID_HANDLE_VALUE,NULL,PAGE_READWRITE,0,BUFSIZ,FileName);
//将物理页与线性地址进行映射
LPTSTR lpBuff = (LPTSTR)MapViewOfFile(hMapFile,FILE_MAP_ALL_ACCESS,0,0,BUFSIZ);
*(PDWORD)lpBuff = 0x12345678;
printf("%p: %x",lpBuff, *(PDWORD)lpBuff);
getchar();
return 0;
}
运行:
到windbg里看一下
我们在另起一个进程来读取这个进程的内容
以下是text2.exe(第二个进程)
#include <stdio.h>
#include <windows.h>
#define FileName "The_Shared_File"
int main(int argc, char* argv[])
{
//打开相同的对象
HANDLE hMapFile = OpenFileMapping(FILE_MAP_ALL_ACCESS, FALSE, FileName);
//将物理页与线性地址进行映射
LPTSTR lpBuff = (LPTSTR)MapViewOfFile(hMapFile,FILE_MAP_ALL_ACCESS,0,0,BUFSIZ);
printf("进程2读取到的值: %x",*(PDWORD)lpBuff);
getchar();
return 0;
}
运行,成功得到共享物理页的值
上面说的是共享物理页,共享文件和共享物理页相似,下面把共享文件的脚本放一下:
进程1:
#include <stdio.h>
#include <windows.h>
int main(int argc, char* argv[])
{
HANDLE hFile = CreateFile("C:\\NOTEPAD.EXE",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ,NULL,OPEN_ALWAYS,FILE_ATTRIBUTE_NORMAL,NULL);
HANDLE hMapFile = CreateFileMapping(hFile,NULL,PAGE_READWRITE,0, BUFSIZ,NULL);
LPTSTR lpBuff = (LPTSTR)MapViewOfFile(hMapFile,FILE_MAP_ALL_ACCESS,0,0,BUFSIZ);
printf("共享文件的线形地址:%p",lpBuff);
getchar();
return 0;
}
vad里的情况:
但是,可以发现vad里还有如下这种形式
写拷贝
在此详细讲解
写拷贝的类型是由LoadLibrary来映射的,其目的是怕影响到其他进程,以上图为例,如果我们在ntdll里挂钩子,去修改的话,如果不是写拷贝,就会使所有ntdll映射的进程中ntdll改变,造成系统对不稳定,而写拷贝的话,会把ntdll映射到一份新的物理页,即使修改也是修改新物理页的ntdll(副本),和原来物理页的ntdll无关。
脚本:
#include <stdio.h>
#include <windows.h>
int main(int argc, char* argv[])
{
HMODULE hModule = LoadLibrary("C:\\NOTEPAD.EXE");
getchar();
return 0;
}
vad的情况就是写拷贝。
线性地址的管理的讨论到此为止,下面是物理内存的管理(要区分开)。
0x03 物理内存管理
物理内存的总物理页数可以在windbg里用MmNumberOfPhysicalPages这个全局变量查看
这个全局变量的单位是物理页(4KB)所以,总共的大小MmNumberOfPhysicalPages* 4
其中每个物理页都对应一个结构体来描述,_MMPFN,它的大小不同系统不一定大,笔者这个的大小是0x18(0x14+4)。
而这些结构体都装在一个大的结构体数组中,此数组的起始位置由MmPfnDatabase查看:
正因为是结构体数组,所以在_MMPFN里没有必要描述物理页的指针,假如要查看第4个物理页的指针,
80C43000(MmPfnDatabase) + 18h(sizeof(_MMPFN)) * 3,就可以找到。
物理页的状态
既然是物理页的信息,肯定要从_MMPFN结构体入手,这个结构体非常丧心病狂,我们只看和物理页状态相关的。
union {
PFN_NUMBER Flink
ULONG WsIndex
PKEVENT Event
NTSTATUS ReadStatus
SINGLE_LIST_ENTRY NextStackPfn
SWAPENTRY SwapEntry
} u1
PMMPTE PteAddress
union {
PFN_NUMBER Blink
ULONG_PTR ShareCount
} u2
union {
struct {
USHORT ReferenceCount
MMPFNENTRY e1
}
struct {
USHORT ReferenceCount
USHORT ShortFlags
} e2
} u3
union {
MMPTE OriginalPte
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
MMWSLE Wsle
看以下 u3的e1的结构MMPFNENTRY:
typedef struct _MMPFNENTRY
{
USHORT Modified:1
USHORT ReadInProgress:1
USHORT WriteInProgress:1
USHORT PrototypePte:1
USHORT PageColor:4
USHORT PageLocation:3//物理页的状态
//0:MmZeroedPageListHead
//1:MmFreePageListHead
//2:MmStandbyPageListHead
//3:MmModifiedPageListHead
//4:MmModifiedNoWritePageListHead
//5:MmBadPageListHead
USHORT RemovalRequested:1
USHORT CacheAttribute:2
USHORT Rom:1
USHORT ParityError:1
}
windows会把上述6种不同状态的物理页分别串成6个链表(具体解释其中4个)
<1> MmBadPageListHead
坏链
<2> MmZeroedPageListHead
零化链表(是系统在空闲的时候进行零化的,不是程序自己清零的那种)
<3> MmFreePageListHead
空闲链表(物理页是周转使用的,刚被释放的物理页是没有清0,系统空闲的时候有专门的线程从这个队列摘取物理页,加以清0后再挂入MmZeroedPageListHead)
<4> MmStandbyPageListHead
备用链表(当系统内存不够的时候,操作系统会把物理内存中的数据交换到硬盘上,此时页面不是直接挂到空闲链表上去,而是挂到备用链表上,虽然我释放了,但里边的内容还是有意义的)
ok,我们来跟一下其中一个双向链表(笔者这里选择零化链表),来探究结构
第3,4成员是前者是前一物理页的索引,向前遍历,后者是是后一物理页的索引,向后遍历,笔者这里往前走了(具体结构到ReactOS里看一下,笔者写不动了)
那如何找到具体的位置呢,用上文的公式80C43000(MmPfnDatabase) + 18h(sizeof(_MMPFN)) * n
重复上一过程:
发现此刻链表的第3个成员刚好是第一个前节点的索引
总结个图:
最后我们来找一下某进程的所有物理页
还是用DbgView为例先找到DbgView对应的_EPROCESS结构体
在+1f8的位置找到VM
进入_MMSUPPORT
其中遍历VmWorkingSetList就可以找到所有物理页了。
还有很多细节可以参考《Windows内核管理与实现》
0x04 缺页异常
PTE的结构:
虽然有2-2-9-12分页和10-10-12分页,但是在属性这块没有太大差别
P位判断当前页面是否有效,当p位为1则有对应物理页,为0则没有物理页
当CPU访问某地址,发现P位为0,则发生缺页异常
正是不断进行的缺页异常大大提高了内存的使用效率,windows只把当前需要使用的线性地址挂上物理页,而暂时不使用的线性地址,windows会把对应物理页的数据转移到硬盘上。
虚拟内存
可以看一下虚拟机的虚拟内存
1.右击“我的电脑”,选择“属性”。点击“高级”选择“性能”→“设置”
2、打开“性能选项”,选择“虚拟内存”就可以改了
把上述的虚拟内存改了之后,在c盘根目录下会生成一个pagefile.sys文件
这个文件的大小就是虚拟内存的大小。
大体上:当我们的物理页占的差不多的时候,windows会把不用的线性地址A的物理页的数据放到pagefile.sys文件文件中,再把这个物理页给别的正在使用的线性地址B使用,使用后并不会释放,如果地址再次访问线性地址A的时候,A此时没有对应的物理页,就会触发缺页异常。
细节:
图a:位于页面文件中
当你的线性地址的物理页被存放到页面文件(pagefile.sys)中的时候,你的PTE就会变成这样
CPU访问了这地址你的p:0,进入缺页异常处理程序,缺页异常位于idt表里的E号中断。
进入缺页异常处理它会回头查看你的这个PTE,这时地址时发现你的1-9位,12-31位都是有值的,它就会知道你这个线性地址是有效的,只不过数据放到页面文件里了
异常处理程序会到pagefile.sys中,按照PTE上描述的页面文件偏移将页的数据取出来挂到一个新的物理页上,将12-31位改为新的物理页地址,再将p=1
图b:要求0页面
页面尚未分配,下次访问时请求一个0页面
图c:转移
页面在物理内存中,但已被转移到某个物理页面链表中,可以通过查询_MMPFN数据库获取实际情况
图d:
缺页异常处理发现你PTE全部为0就会去查VAD,发现这个线性地址已经分配了它会帮你把物理页挂上,如果这个线性地址没有分配就会报0xC0000005。(具体看 “保留与提交的误区” )
保留与提交
详情见这位大佬的文章
使用VirtualAlloc申请时,并不会直接挂上物理页,只有真正使用的时候才会,上文在vad中显示的commit只是说保留了物理页,但比一定挂上。
0x05 总结
本文是我探究内存管理的过程,主要跟着My classment这位博主来的,记录了一些我在学习时的情况,实践比较多,如果有什么问题,望路过的大佬斧正。