页面大小
大页面优势:当引用同一页面内其他的数据时,地址转译速度更快。因为命中地址转译快查缓冲区TLB几率更大。为了在拥有超过2GB物理内存的系统上充分利用大页面的优势,Windows用大页面来映射核心的操作系统映像(Ntoskrnl.exe和Hal.dll)以及操作系统的核心数据(比如非换页池的初始部分,以及一些描述了每个物理内存页面状态的数据结构)
除了读/写访问外,大页面不可以被指定为其他访问方式,内存也总是不可换页的。如果一个大页面包含了只读的代码和可读写的数据,则该页面必须被标记为读/写,这意味着设备驱动程序或者其他内核模式代码可能修改一段本该是只读的操作系统和驱动程序代码,却不会引发任何一个内存访问违例。
物理内存范围
虚拟空间布局
在Windows中,有三种类型的数据被映射到虚拟地址空间中:
- 每个进程特有的私有代码和数据 - 用户空间
一个虚拟地址总是在当前进程的环境中被,地址转译模块控制它不会引用属于另一个进程的物理地址。
- 会话范围内的代码和数据 - 会话空间
包含针对每个会话的全局信息,每个会话有一个专门的换页池区域,Windows子系统的内核模式部分(Win32k.sys)利用这部分内存区域来分配一些属于该会话私有的GUI数据。而且每个会话都有它自己私有的Windows子系统进程(Csrss.exe)和登录进程(Winlogon.exe)拷贝。为了虚拟化会话,所有会话范围的数据结构都被映射到系统空间中一个被称为会话空间的区域中。当一个进程被创建的时候,这一地址范围被映射到该进程所属会话的页面上。
- 系统范围内的代码和数据 - 系统空间
包含了全局的操作系统代码和数据结构,它们对于所有的进程都是可见的。
- 系统代码
- 非换页内存池
- 换页内存池
- 系统页表项(PTE)
- 系统缓存 用于映射在系统缓存中已打开文件的虚拟地址空间
虚拟地址空间范围
- 64位虚拟地址空间允许最大可达16EB的虚拟内存
- 为了简化芯片体系结构,避免在地址转译方面不必要的额外负荷,目前AMD和Intel的x64处理器只实现了256TB的虚拟地址空间。
- 虚拟地址仍是64位,占用8字节,但只使用了低48位,高16位必须与第47位值一致
- 用户空间,0x0000 0000 0000 0000 - 0x0000 7FFF FFFF FFFF (128T)
- 系统空间,0xFFFF 8000 0000 0000 - 0xFFFF FFFF FFFF FFFF (128T)
- x64上的Windows有着另外一层限制,从x64处理器可用的256TB虚拟地址空间中,目前的Windows版本之允许使用略微超过16TB的空间。
- 用户空间,0x0000 0000 0000 0000 - 0x0000 07FF FFFF FFFF (8T)
- 系统空间,0xFFFF F800 0000 0000 - 0xFFFF FFFF FFFF FFFF (8T)
虚拟内存API
Windows API有三组函数可用来管理应用程序中的内存:
- 虚拟内存,以页面为粒度直接操作虚拟内存,对应API是Virutalxxx
- 内存映射文件,最适合用来操作大型文件、以及在同一机器进程间共享数据;操作系统加载映射.EXE, .Dll也用到它。 对应API是CreateFileMapping, CreateFileMappingNuma, MapViewOf- File, MapViewOfFileEx, _and _MapViewOfFileExNuma
- 堆,能用来分配小于页面大小的内存,对应API是Heapxxx
进程内部看到的都是虚拟内存,堆、栈、内存映射文件都是基于虚拟内存的,它们有各自不同的用途。
虚拟内存API
- 预定地址空间中的区域
PVOID VirtualAlloc( PVOID pvAddress,
SIZE_T dwSize,
DWORD fdwAllocationType,
DWORD fdwProtect);
fdwAllocationType传入MEM_RESERVE
- 提交预定的虚拟内存
PVOID VirtualAlloc( PVOID pvAddress,
SIZE_T dwSize,
DWORD fdwAllocationType,
DWORD fdwProtect);
fdwAllocationType传入MEM_COMMIT
- 释放区域
BOOL VirtualFree( LPVOID pvAddress, SIZE_T dwSize, DWORD fdwFreeType); - 改变保护属性
BOOL VirtualProtect( PVOID pvAddress, SIZE_T dwSize, DWORD flNewProtect, PDWORD pflOldProtect);
不能跨区域修改,如果横跨同一区域多个页面,则多个页面保护属性都被修改
用户地址空间布局
按状态粗分的话:空闲的、保留的、提交的、共享的,下面是把提交的、共享的这两个分类做了细分;访问空闲的、保留的页面会导致异常,因为该页面还没有被映射到任何能够解析该引用操作的存储介质中。
Address Space Layout Randomization
内核的地址空间是动态的,类似地,用户地址空间也是动态地被构建的。
映像随机化
Windows中的所有PE文件都有一个预定的基地址(默认为0x00400000),它在PE文件头中被称为映像基地址。
对于可执行文件来说,每次被加载时会计算一个偏移值,这个偏移值被加到/BASE指定的偏好地址上,得到最终加载地址。
对于DLL,则是每次系统引导时计算一个偏移值,然后加到DLL的偏好地址上,保证只重定位一次。这是因为DLL加载到虚拟地址空间后,要进行重定位以修正DLL中所有的绝对地址跳转、函数调用和全局/静态变量引用。如果DLL在不同进程中被映射到不同虚拟地址空间的话,每次都要做重定位、产生写时复制,也就达不到代码共享的目的。Windows操作系统自带的大多数DLL有不通的预订基地址,而且它们之间不会产生冲突。
地址重定位:
PE头中的.reloc节会包含需要修订位置的地址列表,PE加载时会根据.reloc节中的内容来修正地址
栈的随机化
随机化每个线程栈的基地址,这种随机化是默认启用的。
堆的随机化
当初始的进程堆和后续的堆在用户模式下被创建出来时,ALSR会随机化它们的位置。
内核地址空间中的ALSR
ASLR在内核空间中也有效,32位驱动程序有64个可能的加载地址,而64位驱动程序这有256个。
扩展:Known DLL
- 为缓解dll搜索顺序劫持、使得程序载入伪造的系统DLL,如kernel32, msvcrt.dll,Windows设置了KnownDLLs注册表,指明该表中的DLL只能从System32目录搜寻,搜寻不到则失败
- 对已知DLL的映射顺序随机化,缓解获取了Ntdll.dll地址,其他DLL的地址很容易被计算出来的问题。
扩展:DLL隐式加载(exe导入表)与显示加载(代码load library)
- 隐式加载(exe导入表)
- 显示加载(代码load library)
地址转译
因为 Windows 为每个进程提供了私有地址空间,所以每个进程都有自己的页目录和页表来映射该进程的私有地址空间。但是,描述系统空间的页表在所有进程之间共享(并且会话空间仅在会话中的进程之间共享)。
地址转译快查缓冲区
每次硬件地址转译(是的,地址转译是由硬件内存管理单元MMU完成的)都需要两次查询操作:一次是在页目录中找到正确的PDE,一次是在页表中找到正确的PTE;出于性能考虑,大多数CPU都缓存了地址转译信息,在重复访问同一个地址时从地址转译快查缓冲区TLB中查询物理页面帧编号。
PAE(Physical Address Extension)
处理器引入了一种称为物理地址扩展的内存映射模式,通过适当的芯片集,将32位地址线扩展为36位并改用以下页映射模式,使得32位系统可以访问多达64GB的物理内存。
x64虚拟地址转译
页面错误处理
无效PTE
PTE的最后一位表示有效位,上面的地址转译有效位都为1、要访问的数据在物理内存中;如果有效位为0,则会引发页面错误,MMU会忽略PTE中的其他位,所以操作系统可以利用这些位来保存该页面有关的信息。下面列一些我看得懂的PTE
- 页面文件
期望的页面驻留在一个页面文件中,会激发一个页面换入操作,以便把该页面待会到内存中并使PTE变成有效。
- 虚拟地址描述符
这种格式用于映射文件内存区支撑的页面,换页器找到虚拟地址描述符VAD,并从VAD引用的映射文件激发一个页面换入操作。
- 未知
该PTE为零或者页表尚未存在(原本用来提供页表的物理地址的页目录项为零)。在这两种情况下,换页器都必须检查虚拟地址描述符 (VAD) 以确定该虚拟地址是否已提交。如果是的话,则构建页表来表示新提交的地址空间。如果不是(如果页面被保留或根本没有被定义),则页面错误被报告为访问冲突异常。
虚拟地址描述符VAD
内存管理器使用一个按需换页的算法来计算何时将页面加载到内存中,它要等到有一个线程引用一个地址并且招致一个页面错误时,才从磁盘中获取该页面的数据。
当一个线程提交一块虚拟内存区域的时候,内存管理器也会等到有一个线程引发了一个页面错误时,再为该页面创建一个页表。
内存管理器维护了一组数据结构,称为虚拟地址描述符,以跟踪和记录在进程的地址空间中哪些虚拟地址已经被保留了,哪些虚拟地址尚未被保留。确保一旦这些保留的页面被使用,这些空间是可用的。VAD是从非换页池中分配出来的。
当一个线程第一次访问一个地址时,对应上面无效PTE-未知的情况:内存管理器会找到该地址对应的VAD以创建PTE和页表,加载页面到内存;如果该地址是空闲或保留状态,则产生一个访问违例。
原型PTE
如果一个页面可以在两个进程中共享,这内存管理器依靠一种称为原型PTE的软件结构来映射这些潜在要共享的页面。
内存映射文件
内存映射文件是Windows提供的用于在多个进程之间共享物理内存的机制,它有以下几种应用场景:
- 如果两个进程使用了同样的.exe和.dll,那么只需将引用该.exe和.dll的代码页面加载到物理内存中一次,然后在所有映射了此DLL的进程之间共享这些页面。
- 开发人员可以用内存映射文件来访问磁盘上的大数据文件,因为可以让系统为我们处理所有与文件缓存相关的操作。例如对一个大文件的颠倒内容,直接使用C库函数_tcsrev颠倒,使用内存映射文件把这个文件当作内存中的一个字符串来处理,,由系统为我们处理所有与文件缓存相关的操作。反之,使用ReadFile, WriteFile等常规API都需要自己申请buffer
- 使用内存映射文件在进程间共享数据
Windows中,同一台机器上共享数据的最底层机制就是内存映射文件。这种共享机制是通过让不同进程映射同一个文件映射对象实现的,Windows确保同一文件映射对象的视图中的数据是一致的。如果为了共享数据而必须让应用程序在磁盘上创建数据文件并把数据保存在文件中,那将非常不方便。所以这个应该就是以页面文件为后备存储器的内存映射文件了,它不需要CreateFile打开文件内核对象、在CreateFileMapping中hFile参数指定INVALID_HANDLE_VALUE。
注:内存映射文件是一个Windows 内核对象,section object, file-mapping object的同义词,它并不实际落地一个磁盘文件。当真正要访问其中的数据时,仍然需要加载数据到物理内存中。
写时复制
写时复制是指对于多个进程共享的可写页面,内存管理器将页面拷贝操作推迟到页面被写入数据的时候。利用它可以节约物理内存,不必每个进程都做一份该页面的私有拷贝。
写时复制一个典型应用是调试器:当在程序中设置一个断点时,调试器首先将该进程的断点所在页面保护属性设置为PAGE_EXECUTE_READWRITE,然后写入断点指令,触发写时复制操作。内存管理器会为被调试进程创建一份私有拷贝,其他进程仍然访问的是未被修改的代码。
内存区对象(section object)
也称文件映射对象,内存区对象可以被映射页面文件,也可以映射一个磁盘上的文件。
文件映射对象API
- CreateFile 创建或打开一个文件内核对象,得到hFile
- 创建一个文件映射内核对象(file-mapping kernel object),也称内存区对象
HANDLE CreateFileMapping(HANDLE hFile, PSECURITY_ATTRIBUTES psa, DWORD fdwProtect, DWORD dwMaximumSizeHigh, DWORD dwMaximumSizeLow, PCTSTR pszName) - 把文件映射对象的部分或全部映射到进程的地址空间中
PVOID MapViewOfFile(HANDLE hFileMappingObject, DWORD dwDesiredAccess, DWORD dwFileOffsetHigh, DWORD dwFileOffsetLow, SIZE_T dwNumberOfBytesToMap)
MapViewOfFileEx,允许指定参数pvBaseAddress来要求系统把文件映射对象映射到指定的地址;操作系统映射.exe、.dll时就用的这个函数 - MapViewOfFile执行成功,会返回指向视图的指针,此时就可以像文件已完全载入内存一样操作文件了。
- 告诉系统从进程地址空间中取消对文件映射内核对象的映射
BOOL UnmapViewOfFile(PVOID pvBaseAddress); - 将改动写入磁盘
BOOL FlushViewOfFile(PVOID pvAddress,SIZE_T dwNumberOfBytesToFlush);
系统会自动将修改写入磁盘;也可以调用此函数,强制系统把部分或全部修改过的数据写回到磁盘中。
- 关闭文件映射内核对象,关闭文件内核对象
堆
见软件调试第23章
系统堆(内核池)
大多数内核模式的组件从这两种内存池(或者叫系统堆)中分配系统内存:
- 非换页池,可保证总是驻留在物理内存中
- 换页池,它可以被换入换出
快查表
快查表只包含固定大小的内存快。
为了使多处理器同步的开销降低到最小,几个执行体子系统(比如I/O管理器、缓存管理器,以及对象管理器)针对它们经常访问的数据结构,为每个处理器都创建了快查表。执行体也对小数据的内存分配(256字节或更少),为每个处理器创建了一个通用的换页的快查表和非换页的快查表。
如果一个快查表是空的(当它刚刚被创建的时候就是这样的),则系统必须从换页池或非换页池中分配内存。但是如果它包含一个已释放的块,这内存申请可以很快满足(随着数据被归还回来,此列表也会增长)。负责内存池分配的例程根据一个设备驱动程序或执行体组件从快查表中申请内存的频繁程度,自动调整该列表中存储的已释放的缓冲区的数量。
Win32堆
进程的默认堆默认1MB大小,在进程的用户态初始化阶段被创建:
- 许多Windows函数需要用到临时的内存块,比如ANSI版本的函数需要一块内存保存转换的Unicode字符串
- 旧的16位Windows函数LocalAlloc和GlobalAlloc也会从默认堆中分配内存
- GetProcessHeap() 获取默认堆句柄
堆管理器支持多个线程的并发访问,每次只允许一个线程独占对堆,堆操作函数内部会调用HeapLock和HeapUnlock。
堆API
- 调用HeapCreate在进程中创建自定义堆
- 调用HeapAlloc从堆中分配内存块,此函数会遍历已分配内存链表和闲置内存链表
- HeapReAlloc,调整内存块的大小
- HeapSize,获得内存块的大小
- HeapFree,释放内存块 (仅告诉系统不再需要此内存块,何时撤销调拨的物理存储器是由系统自己决定的)
- HeapDestroy,销毁堆。如果不显示销毁堆,系统会在进程终止时替我们销毁。注意,是进程终止而不是线程终止时。
- 在C++中使用堆建议使用封装好的new, delete
低碎片堆
利用多个预定义的包含不同内存块大小范围的bucket来管理已分配的块,从而避免内存碎片化。
Windows堆管理器实现了一个自动调节算法,它会在特定条件下默认地启用LFH,即便是LFH已经启用的情况下,较少频率分配的内存大小仍可以使用继续使用核心堆来分配内存,而最频繁的分配类型则会从LFH执行。
堆的结构
如果要分析堆相关漏洞,需要了解堆的结构,这部分软件调试有细讲
堆的调试特性
堆有关的全局标志
页堆 pageheap
尾部检查和空闲检查选项能够发现一些内存破坏,但真正检测到问题却有可能是在内存破坏很久以后了,所以另一个称为pageheap的堆调试功能可以被用来将所有或者部分堆调用转到另一个不同的堆管理器上。启用pageheap后,堆管理器把所有的内存分配都放在页面的尾部,并把紧邻的下一个页面保留下来。由于保留的页面是不可访问的,因而若发生缓冲区溢出的话,它可以引发一个访问违例,从而使得更容易地检测到错误代码。
注意,使用pageheap可能会导致用完地址空间,因为它在小内存分配单元上引入了相当大的内存开销。而且,由于对零页面的需求相应地增加、局部性丢失,以及频繁地调用函数来验证堆数据结构从而带来的额外开销,所以性能上会遭受影响。
栈
每当一个线程运行时,必须拥有一个临时的存储位置来保存函数参数、局部变量、函数调用后的返回地址、异常处理链(详情参考软件调试第22、24章)。这部分内存称为栈。在 Windows 上,内存管理器为每个线程提供两个栈,分别是用户栈和内核栈,默认1MB大小。
保护内存
硬件DEP
Data Execution Protection是为了缓解病毒程序利用bug把数据当作代码来执行的问题。
在32位系统上,要支持硬件DEP必须加载PAE内核,因为需要使用页表项第63位用于标记一个页面为不可执行。执行保护仅被应用到线程栈和用户模式页面,而没有应用到换页池和会话池上。
在64位系统上,DEP总是被应用到所有的64位程序和设备驱动程序上,而且只能通过设置AlwaysOff来关掉。执行保护被应用到线程栈(用户模式和内核模式都适用)、未被标记为“可执行”的用户模式页面,内核换页池、以及内核会话池。32位程序的执行保护是否起作用,取决于BCD的nx开关:Windows客户版本,针对32位进程的执行保护默认设置为OptIn,Windows服务器版本默认设置为OptOut。
软件DEP
对于老式的并不支持硬件DEP的处理器,Windows支持有限的软件DEP:
- 增强异常处理机制安全性
1)结构化异常处理复写保护(SEHOP)
如果映像文件未指定/SAFESEH编译选项,当一个线程首先开始用户模式的执行时,一条新的象征性的异常登记记录被添加到栈上。正常的异常登记链将会指向这条记录,当异常发生时,异常分发器会首先遍历异常处理器登记记录,以确保异常链会通往该象征性记录。如果没有,异常链就肯定已经被破坏了,于是异常分发器直接终止该进程,而不会调用栈上描述的任何异常处理handler。
2)SafeSEH
如果映像文件指定了/SAFESEH编译选项,那么在分发一个异常以前,系统首先要验证,该异常的handler已经在映像文件的函数表(由编译器生成)中被注册了。
以上两种缓解机制都是因为x86系统上,异常处理handler需要在栈上做登记(详情参考软件调试第24章);而x64系统中,将异常处理器的描述和登记信息都以表格的形式存储在可执行文件中,当有异常发生时,系统根据异常的发生位置自动在这些表格中寻找匹配的处理函数。
- 栈Cookie
编译器在每个可能被挖掘的函数头部和尾部都加上特殊代码,代码会在进入栈的时候在栈上保存一个cookie,并在要返回栈上保存的调用者之前验证该cookie。cookie的值是在每次启动后,执行第一个用户模式线程时计算的。
- 指针编码
将应用程序或DLL中的静态指针用上述Cookie编码,使用时再解码。如果静态指针值被恶意覆盖,试图解码此指针时会得到一个错误的值,导致应用程序崩溃。
AWE 地址窗口扩展
直接调用AllocateUserPhysicalPages申请物理内存,然后调用VirtualAlloc创建一个虚拟地址空间区域,最后调用MapUserPhysicalPages将物理内存中的一部分映射到虚拟地址空间区域。
用途:
- 为32位进程提供了访问超过它虚拟地址空间的物理内存的唯一途径。AWE对x64或IA64 Windows系统没那么有用,因为它们的物理内存数量总是小于虚拟地址空间。
- AWE内存永远不会被换出到页面文件上,因此可避免有人通过检查页面文件来获取数据