系统范围内存管理 Part2 完!
1. 系统范围实验
1.1 注意事项
MemTests程序带有一个名为KrnlAllocs.sys的内核模式驱动,可以测试一些与内存有关的DDI。它也有一个在用户指定的系统范围地址进行读写的功能。这个函数在0环执行,访问指定的地址。
进行的所有驱动测试都有潜在的危险,很有可能会导致蓝屏。
该驱动将信息写入内核调试器的控制台,所以只有在连接到调试器的机器上使用它才有意义。
KrnlAllocs.sys没有驱动签名,因此需要注意关闭DSE。
为了确认驱动程序被成功加载,在DbgView中寻找以下信息。
[Log]: driver, compiled Aug 24 2022 15:14:48
[Log]: driver successfully loaded.
1.2 系统范围测试菜单
MemTests主菜单的System range tests 选项打开了这些测试的子菜单。
"驱动程序控制 "的菜单部分包含一个加载驱动程序的选项和另一个卸载驱动程序的选项。当驱动程序被加载时,它的服务定义被创建,在驱动程序卸载时,它将被删除。
测试部分的选项大多是不言自明的,就是调用一些DDI。
1.3 访问一个系统范围
在上一章中,我们分析了在各个系统区域中是如何处理页面错误的。我们可以使用MemTests来引起这种错误,以便用WinDbg来分析MmAccessFault。
在下面的例子中,我们将访问系统PTE区域中的一个无效地址,并进入MmAccessFault。当我们恢复执行时,系统将因为无效的访问而崩溃。
我们首先进入系统范围测试子菜单,选择加载内核分配的驱动程序。
为了找到一个无效的地址,我们可以转储区域起始地址的PxEs,我们使用 !pte扩展。
0: kd> !pte fffff880`00000000
VA fffff88000000000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E200000 PTE at FFFFF6FC40000000
contains 0000000135004863 contains 0000000135003863 contains 0000000135002863 contains 0000000135011863
pfn 135004 ---DA--KWEV pfn 135003 ---DA--KWEV pfn 135002 ---DA--KWEV pfn 135011 ---DA--KWEV
然后知道该区域是以1个PDE为单位分配的,我们可以转储第一个PDE之后的PDE,寻找设置为0的条目,这就是无效的PDE。
0: kd> dq FFFFF6FB7E200000
fffff6fb`7e200000 00000001`35002863 00000001`34f01863
fffff6fb`7e200010 00000001`35108863 00000001`35107863
fffff6fb`7e200020 00000001`35010863 00000001`35014863
fffff6fb`7e200030 00000001`35017863 00000000`04c4e863
fffff6fb`7e200040 00000000`04ee7863 00000000`04fc3863
fffff6fb`7e200050 00000000`05272863 00000000`053f4863
fffff6fb`7e200060 00000000`053f3863 00000000`056fe863
fffff6fb`7e200070 00000000`056fd863 00000000`9fa6c863
0: kd> dq
fffff6fb`7e200080 00000000`9f76b863 00000000`9d045863
fffff6fb`7e200090 00000000`9f07a863 00000000`7ec3e863
fffff6fb`7e2000a0 00000000`9e32d863 00000000`7d415863
fffff6fb`7e2000b0 00000000`9ddef863 00000000`9bcbe863
fffff6fb`7e2000c0 00000000`9951d863 00000000`96444863
fffff6fb`7e2000d0 00000000`95e5d863 00000000`93d73863
fffff6fb`7e2000e0 00000000`93eb8863 00000000`92051863
fffff6fb`7e2000f0 00000000`9173c863 00000000`8ec59863
0: kd> dq
fffff6fb`7e200100 00000000`8c44a863 00000000`a76c7863
fffff6fb`7e200110 00000000`a8774863 00000000`a87ff863
fffff6fb`7e200120 00000000`a8502863 00000000`8b72c863
fffff6fb`7e200130 00000000`a833e863 00000001`2c094863
fffff6fb`7e200140 00000000`a3ddc863 00000000`a749d863
fffff6fb`7e200150 00000000`a690c863 00000000`a06ba863
fffff6fb`7e200160 00000000`a041c863 00000000`a0480863
fffff6fb`7e200170 00000001`26be0863 00000000`a017c863
0: kd> dq
fffff6fb`7e200180 00000000`87cf3863 00000000`871f2863
fffff6fb`7e200190 00000000`871f9863 00000000`873f8863
fffff6fb`7e2001a0 00000000`86735863 00000000`858f6863
fffff6fb`7e2001b0 00000000`859f5863 00000000`82243863
fffff6fb`7e2001c0 00000000`81c42863 00000000`8194a863
fffff6fb`7e2001d0 00000000`82349863 00000000`7a3dd863
fffff6fb`7e2001e0 00000000`7a8dc863 00000000`7b1ec863
fffff6fb`7e2001f0 00000000`7b2eb863 00000000`7be67863
0: kd> dq
fffff6fb`7e200200 00000000`7c166863 00000000`7b477863
fffff6fb`7e200210 00000000`7b076863 00000000`88a85863
fffff6fb`7e200220 00000000`88b84863 00000000`88483863
fffff6fb`7e200230 00000000`00000000 00000000`00000000
fffff6fb`7e200240 00000000`79fa6863 00000000`797a5863
fffff6fb`7e200250 00000000`00000000 00000000`00000000
fffff6fb`7e200260 00000000`00000000 00000000`00000000
fffff6fb`7e200270 00000000`00000000 00000000`00000000
0: kd> d
fffff6fb`7e200280 00000000`00000000 00000000`00000000
fffff6fb`7e200290 00000000`00000000 00000000`00000000
fffff6fb`7e2002a0 00000000`00000000 00000000`00000000
fffff6fb`7e2002b0 00000000`00000000 00000000`00000000
fffff6fb`7e2002c0 00000000`00000000 00000000`00000000
fffff6fb`7e2002d0 00000000`00000000 00000000`00000000
fffff6fb`7e2002e0 00000000`00000000 00000000`00000000
fffff6fb`7e2002f0 00000000`00000000 00000001`01ff5863
后面的几个条目是无效的。为了知道第一个条目所映射的地址,我们必须将其地址向左移动18位。这就把自动条目索引从PML4和PDPT索引槽中踢出来,并把实际地址的索引对准它们的槽。我们还必须将最左边的16位设置为1,以使地址成为规范化。
0: kd> ?? (0xfffff6fb`7e200280<<18) | ((int64)0xffff<<48)
unsigned int64 0xfffff880`0a000000
0xfffff8800a000000
然后我们继续执行,在MemTests子菜单中,选择Memory touch测试选项。程序提示我们起始地址,我们使用上面计算出的地址(我们必须注意去除该值中的撇号)。我们可以接受访问区域的长度和访问类型的默认值(即0x1000字节和读访问)。
当程序显示关于调用驱动程序信息后的提示时,我们必须选择b,以便进入调试器。我们需要这样做来在MmAccessFault上设置断点,否则该函数将完全执行,使系统崩溃。
在选择了b之后,我们必须切换到调试器,在这里系统被停止在即将调用KrnlAllocs.sys的线程中。这是一个将发生页面错误的线程,所以我们在MmAccessFault上设置一个断点,只对这个线程有效,命令如下
1: kd> bp /t @$thread nt!MmAccessFault
只为这个线程指定一个断点是非常重要的,因为MmAccessFault在系统中被很多线程持续调用。
现在我们可以继续执行,然后是断点命中。
1: kd> g
Breakpoint 0 hit
nt!MmAccessFault:
fffff800`045ca240 48895c2420 mov qword ptr [rsp+20h],rbx
在这里,MmAccessFault即将发现地址是无效的。如果我们继续执行,我们就会到达对KeBugCheckEx的调用,这就使一切都结束了。
1: kd> kL
# Child-SP RetAddr Call Site
00 fffff880`0277bd48 fffff800`045ad7d2 nt!RtlpBreakWithStatusInstruction
01 fffff880`0277bd50 fffff800`045ae5c2 nt!KiBugCheckDebugBreak+0x12
02 fffff880`0277bdb0 fffff800`044f2ca4 nt!KeBugCheck2+0x722
03 fffff880`0277c480 fffff800`045caa1d nt!KeBugCheckEx+0x104
04 fffff880`0277c4c0 fffff800`044fec96 nt!MmAccessFault+0x7dd
05 fffff880`0277c610 fffff880`03bbaa53 nt!KiPageFault+0x356
06 fffff880`0277c7a0 fffff880`03bba1b5 krnlAllocs!KmemTouchTest+0x163
07 fffff880`0277c800 fffff800`04751d9a krnlAllocs!DriverDeviceControl+0x135
08 fffff880`0277c850 fffff800`04917831 nt!IopSynchronousServiceTail+0xfa
09 fffff880`0277c8c0 fffff800`047a95d6 nt!IopXxxControlFile+0xc51
0a fffff880`0277ca00 fffff800`04500bd3 nt!NtDeviceIoControlFile+0x56
0b fffff880`0277ca70 00000000`772a98fa nt!KiSystemServiceCopyEnd+0x13
0c 00000000`0022f558 000007fe`fd4db0c9 ntdll!ZwDeviceIoControlFile+0xa
0d 00000000`0022f560 00000000`77035a1f KernelBase!DeviceIoControl+0x75
0e 00000000`0022f5d0 00000001`3f7b48fe kernel32!DeviceIoControlImplementation+0x7f
0f 00000000`0022f620 00000000`00000003 0x00000001`3f7b48fe
10 00000000`0022f628 000007fe`f9483400 0x3
11 00000000`0022f630 00000000`0022f659 0x000007fe`f9483400
12 00000000`0022f638 00000000`00000000 0x22f659
1.4 访问无效的用户范围分页结构
对一个不存在的分页结构(我们将使用一个页表)的访问将映射一个用户范围内的地址,被 "解析 "为一个物理页。然而当拥有分页结构的进程终止时,系统会以代码0x50进行错误检查。
访问的是MemTests程序本身的页表。
在启动MemTests并加载驱动程序后,我们进入调试器,用以下命令寻找MemTests的地址。
kd> !process 0 1 memtests.exe
PROCESS fffffa8001c6eb30
SessionId: 1 Cid: 0650 Peb: 7fffffdf000 ParentCid: 09e8
DirBase: 4a2c7000 ObjectTable: fffff8a001bcaac0 HandleCount: 30.
Image: MemTests.exe
VadRoot fffffa8003589a30 Vads 43 Clone 0 Private 142. Modified 0. Locked 0.
DeviceMap fffff8a001305a00
Token fffff8a0021f8a90
ElapsedTime 00:00:28.080
UserTime 00:00:00.000
KernelTime 00:00:00.000
QuotaPoolUsage[PagedPool] 22712
QuotaPoolUsage[NonPagedPool] 5168
Working Set Sizes (now,min,max) (657, 50, 345) (2628KB, 200KB, 1380KB)
PeakWorkingSetSize 657
VirtualSize 12 Mb
PeakVirtualSize 12 Mb
PageFaultCount 654
MemoryPriority FOREGROUND
BasePriority 8
CommitCharge 164
我们通过向.process元命令输入上述地址,将调试器切换到MemTests的内存上下文
kd> .process /P fffffa8001c6eb30
Implicit process is now fffffa80`01c6eb30
.cache forcedecodeptes done
我们必须小心,不要忘记/P选项。接下来,我们检查VAD树,找到一个未分配的PDE
kd> !vad fffffa8003589a30
VAD Level Start End Commit
fffffa8001c3bb80 5 10 1f 0 Mapped READWRITE Pagefile section, shared commit 0x10
fffffa80035a58f0 4 20 2f 0 Mapped READWRITE Pagefile section, shared commit 0x10
fffffa80033471b0 5 30 33 0 Mapped READONLY Pagefile section, shared commit 0x4
fffffa8003014480 3 40 40 0 Mapped READONLY Pagefile section, shared commit 0x1
fffffa8003788c20 5 50 50 1 Private READWRITE
fffffa80030e8290 4 60 c6 0 Mapped READONLY \Windows\System32\locale.nls
fffffa8001c766a0 6 d0 1cf 14 Private READWRITE
fffffa8001cb1d10 5 1d0 1df 6 Private READWRITE
fffffa800304de60 2 210 30f 6 Private READWRITE
fffffa80034db7c0 5 470 56f 42 Private READWRITE
fffffa800334b420 4 6d0 7cf 4 Private READWRITE
fffffa800346d620 5 77310 7742e 4 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\kernel32.dll
fffffa80030f64c0 3 77530 776d8 12 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\ntdll.dll
fffffa800332ecc0 5 7efe0 7f0df 0 Mapped READONLY Pagefile section, shared commit 0x5
fffffa8002fd3010 4 7f0e0 7ffdf 0 Private READONLY
fffffa8002fe6790 1 7ffe0 7ffef -1 Private READONLY
fffffa80034fe4a0 4 13ff80 13ff93 2 Mapped Exe EXECUTE_WRITECOPY \Users\31231\Desktop\MemTests.exe
fffffa800336b800 3 7fef7030 7fef704a 2 Mapped Exe EXECUTE_WRITECOPY \Users\31231\Desktop\vcruntime140.dll
fffffa800378af00 5 7fef9280 7fef9282 0 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\api-ms-win-crt-conio-l1-1-0.dll
...
我们可以看到在6d0-7cf范围之后的用户区域有一个缺口。每个PDE映射了一个2MB的范围,也就是十六进制的0x200000字节的大小。我们从VAD树上看到,0x600000 - 0x7FFFFF范围的PDE被分配了,因为这个范围的一部分也被分配了。0x800000 - 0x9FFFFF范围的PDE没有被分配。我们可以用 !pte获得这个范围内的第一个页表的地址。
kd> !pte 800000
VA 0000000000800000
PXE at FFFFF6FB7DBED000 PPE at FFFFF6FB7DA00000 PDE at FFFFF6FB40000020 PTE at FFFFF68000004000
contains 02D000004A27E867 contains 014000004A202867 contains 0000000000000000
pfn 4a27e ---DA--UWEV pfn 4a202 ---DA--UWEV not valid
输出显示PDE被设置为0,PTE地址为0xFFFFF68000004000。当然,这个地址没有被映射。
kd> db 0xFFFFF68000004000
fffff680`00004000 ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
fffff680`00004010 ?? ?? ?? ?? ?? ?? ?? ??-?? ?? ?? ?? ?? ?? ?? ?? ????????????????
现在我们可以恢复执行并使用MemTests来触摸这个地址。即在内存访问发生之前进入调试器,在nt!MmAccessFault上设置一个断点,只对当前线程有效。
注意:当我们通过选择b进入调试器时,最好再次检查PDE,因为它可能在MemTests运行时被映射了。如果发生了这种情况,我们可以再次使用!vad来找到另一个无效的PDE,然后用g恢复执行,因为我们在这次运行中指定的PTE地址现在是有效的,不会发生页面错误。从MemTests菜单中,我们可以用新的PTE地址重复测试。
kd> g
Break instruction exception - code 80000003 (first chance)
0033:000007fe`fd812a42 cc int 3
kd> !pte 800000
VA 0000000000800000
PXE at FFFFF6FB7DBED000 PPE at FFFFF6FB7DA00000 PDE at FFFFF6FB40000020 PTE at FFFFF68000004000
contains 02D000004A27E867 contains 014000004A202867 contains 29B000004A150867 contains 0000000000000000
pfn 4a27e ---DA--UWEV pfn 4a202 ---DA--UWEV pfn 4a150 ---DA--UWEV not valid
kd> !pte A00000
VA 0000000000a00000
PXE at FFFFF6FB7DBED000 PPE at FFFFF6FB7DA00000 PDE at FFFFF6FB40000028 PTE at FFFFF68000005000
contains 02D000004A27E867 contains 014000004A202867 contains 0000000000000000
pfn 4a27e ---DA--UWEV pfn 4a202 ---DA--UWEV not valid
当断点被击中时,我们可以再次检查我们所选PDE的内容。
kd> g
Break instruction exception - code 80000003 (first chance)
0033:000007fe`fd812a42 cc int 3
kd> dq FFFFF6FB40000028 l1
fffff6fb`40000028 00000000`00000000
PDE仍然是0。下好条件断点
kd> bp /t @$thread nt!MmAccessFault
放行,然后我们用bc*删除我们的断点(清除所有的断点),因为MmAccessFault被持续调用,断点本身没有停止其他线程的执行,但却使系统变慢,以至于无法使用。然后我们让执行继续进行,直到MmAccessFault用g @$ra命令返回。
kd> g
Breakpoint 0 hit
nt!MmAccessFault:
fffff800`03e9e620 48895c2420 mov qword ptr [rsp+20h],rbx
kd> bc*
kd> g @$ra
nt!KiPageFault+0x16e:
fffff800`03e8f76e 85c0 test eax,eax
我们停在KiPageFault里面,它是MmAccessFault的调用者。我们现在可以看一下我们的PDE,看看MmAccessFault做了什么。
kd> dq FFFFF6FB40000028 l1
fffff6fb`40000028 1f100000`5411d867
PDE被设置为有效的映射(位0被设置),这意味着PT现在被映射了。
kd> db FFFFF68000005000
fffff680`00005000 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
fffff680`00005010 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
fffff680`00005020 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
如果我们恢复执行,系统似乎在实验中幸存下来。然而当MemTests关闭时,它通常会崩溃。
kd> g
*** Fatal System Error: 0x00000050
(0xFFFFFA7FFFFFFFEA,0x0000000000000000,0xFFFFF80003EC797F,0x0000000000000007)
Break instruction exception - code 80000003 (first chance)
A fatal system error has occurred.
Debugger entered on first try; Bugcheck callbacks have not been invoked.
A fatal system error has occurred.
nt!RtlpBreakWithStatusInstruction:
fffff800`03e89490 cc int 3
这告诉我们,这样的页面故障是不应该发生的,而且是一个特别讨厌的故障,因为它破坏了VMM的数据结构,导致在无效访问之后很长时间内发生崩溃。要追踪这种崩溃的原因是相当困难的。
2 内核模式镜像文件加载
2.1 在会话空间外加载的镜像
本节分析了初始加载器映射和系统PTE区域中的镜像加载。
2.1.1 不会使用内存Sections
内核模式映像文件是在系统范围内加载的可执行文件,在0环处执行。 内核映像和hal属于这一类,还有系统加载的所有内核模式驱动,通常存储在以.sys为扩展名的文件中。
这些文件的加载方式与用户模式的不同,因为不使用内存段。这意味着所有构成内存段的数据结构(段对象、段、子段、原型PTEs)都没有被创建。当我们想到对于用户模式的图像来说,sections是用来完成两件事的时候,这就有了意义:
- 在所有进程之间共享不能修改的文件部分,例如代码和只读数据文件部分。
- 对可以修改的sections实施写时拷贝,即在进程之间共享镜像文件的原始拷贝,并在每个进程写入时给它一个私有拷贝。
在系统范围内,这些都是不必要的:范围内容已经被所有进程共享,这也适用于镜像文件的读/写数据部分:如果一个内核模式的驱动程序使用一个静态变量,它并不期望根据哪个进程是当前的而看到它的不同副本。
原则上就是VMM只是将映像文件的内容加载到相关区域的某处映射的内存中。现在我们不打算讨论内核模式驱动的可分页sections。对于不可分页sections,映射是固定的:物理页不是任何工作集的一部分,所以它从不被重新使用。
我们可以通过查看内核模式驱动程序的映射来验证这一行为,例如ntfs.sys,NTFS的文件系统驱动程序。首先我们找出驱动程序的地址。
kd> lm m ntfs
Browse full module list
start end module name
fffff880`0123a000 fffff880`013dd000 Ntfs (deferred)
Unable to enumerate user-mode unloaded modules, Win32 error 0n30
然后我们看一下PTE和_mmpfn的其中一个页面,例如在+0x3000或从其起始地址开始的+3个页面。
kd> !pte fffff880`0123a000+3000
VA fffff8800123d000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E200048 PTE at FFFFF6FC400091E8
contains 000000007BD84863 contains 000000007BD83863 contains 0000000004850863 contains 0000000004CBD121
pfn 7bd84 ---DA--KWEV pfn 7bd83 ---DA--KWEV pfn 4850 ---DA--KWEV pfn 4cbd -G--A--KREV
kd> !pfn 4cbd
PFN 00004CBD at address FFFFFA80000E6370
flink 00000000 blink / share count 00000001 pteaddress FFFFF6FC400091E8
reference count 0001 used entry count 0000 Cached color 0 Priority 0
restore pte 00000060 containing page 004850 Active M
Modified
我们可以看到物理页是如何固定的,因为它没有工作集索引。此外,我们必须记住,当一个页面是内存section的一部分时, pteaddress (_mmpfn.PteAddress)存储了指向它的原型PTE的地址,而 containing page (_mmpfn.u4.PteFrame) 存储了原型PTE的物理页的PFN。这允许VMM在重新使用该页时更新原型PTE。然而,上面的!pfn输出显示,pteaddress指向硬件PTE,并且包含页与上面!pte扩展报告的硬件页表的PFN相匹配。
这些都清楚地表明,这个物理页面不是任何可共享内存section的一部分,而是固定地映射了一个VA来加载镜像文件的内容。
对其他地址和其他内核模式映像,包括内核映像本身,重复这一分析,可以得到相同的结果。
2.1.2 内核模式镜像的可分页sections
装载器和VMM允许内核模式的驱动程序指定它的一些文件sections可以被分页。这可以通过给文件section一个以 "PAGE "开头的名字来实现。在Visual C编译器中,这是通过#pragrra alloc_text指令来实现的,该指令接收一个函数名和一个section名,并将函数的代码放在一个具有指定名称的section中。由于某些函数是在Dispatch IRQL或更高的IRQL被调用的,所以并不是驱动程序的所有section都可以被做成可分页。
我们知道对于用户模式的镜像文件,当一个页面被重新使用时,它的内容不会被保存到分页文件中,因为它已经在镜像文件本身中可用。这是由底层文件支持的内存section实现的,它将section内容与镜像文件的偏移量联系起来。
由于内存区不是为内核模式的镜像文件建立的,所以没有办法将可分页的虚拟页与文件的偏移量联系起来,所以这样的页在重新使用时被保存到分页文件中。
一般来说,加载的映像中的可分页虚拟页与系统范围内的其他可分页(如分页池页)一样被对待:它是一个工作集(系统PTE之一)的一部分,可以被带入过渡期,被分页和重新使用。当它被再次需要时,它就会从分页文件中faulted back回来。
我们可以用krnlallocs.sys驱动来验证这一行为,它有一个名为PageableFunction的可分页函数。如果我们在加载驱动程序后立即查看PxEs映射其地址,我们会发现这样的情况。
kd> !pte krnlallocs!PageableFunction
VA fffff88003b8d000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E2000E8 PTE at FFFFF6FC4001DC68
contains 000000007BD84863 contains 000000007BD83863 contains 00000000122C3863 contains 000000007C16F860
pfn 7bd84 ---DA--KWEV pfn 7bd83 ---DA--KWEV pfn 122c3 ---DA--KWEV not valid
Transition: 7c16f
Protect: 3 - ExecuteRead
我们看到PTE处于过渡期,并且该页在Modified列表中。因此似乎一个可分页的section被读入一个页面,但没有立即成为工作集的一部分。
我们可以在MemTests的子菜单中选择Call pageable function test选项,使该函数被调用。这个函数只向调试器控制台写入以下信息:
[Log]: Device opened
[Log]: PageableFunction called
[Log]: Device closed
然而简单的调用会导致VMM对页面进行软错误处理。这就是调用后的情况。
kd> !pte krnlallocs!PageableFunction
VA fffff88003b8d000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E2000E8 PTE at FFFFF6FC4001DC68
contains 000000007BD84863 contains 000000007BD83863 contains 00000000122C3863 contains 70F000007C16F121
pfn 7bd84 ---DA--KWEV pfn 7bd83 ---DA--KWEV pfn 122c3 ---DA--KWEV pfn 7c16f -G--A--KREV
kd> !pfn 7c16f
PFN 0007C16F at address FFFFFA80017444D0
flink 00000F0F blink / share count 00000001 pteaddress FFFFF6FC4001DC68
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 00000060 containing page 0122C3 Active M
Modified
之前在Modified列表上的同一个页面现在是活动的,它仍然是脏的,因为在分页文件中不存在它的副本(restore pte是0x60,所以_mmpte.u. Soft.PageFileHigh是0)。我们还可以确认,在flink中发现的值0xF0F(_mmpte.u1)是系统PTE工作集中这个VPN的工作集列表索引。
kd> ?? ((nt!_MMSUPPORT*) @@(nt!MmSystemPtesWs))->VmWorkingSetList->Wsle[0xf0f].u1.e1
struct _MMWSLENTRY
+0x000 Valid : 0y1
+0x000 Spare : 0y0
+0x000 Hashed : 0y0
+0x000 Direct : 0y1
+0x000 Protection : 0y00000 (0)
+0x000 Age : 0y000
+0x000 VirtualPageNumber : 0y1111111111111111111110001000000000000011101110001101 (0xfffff88003b8d)
最后如果我们用尽了物理内存(例如,通过创建和映射一个长度等于90%物理内存的文件,并写入映射的视图),我们可以强迫VMM修剪、淘汰和重新利用页面。这就是我们之后看到的情况。
7333 3333
7400 0000
kd> !pte krnlallocs!PageableFunction
VA fffff88003b8d000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E2000E8 PTE at FFFFF6FC4001DC68
contains 000000007BD84863 contains 000000007BD83863 contains 00000000122C3863 contains 00010A3100000060
pfn 7bd84 ---DA--KWEV pfn 7bd83 ---DA--KWEV pfn 122c3 ---DA--KWEV not valid
PageFile: 0
Offset: 10a31
Protect: 3 - ExecuteRead
这些发现证实了驱动代码被分页到分页文件。
我们可以对PageableFunction进行的最后一个实验是将其锁定在内存中。要做到这一点,我们必须选择MemTests中的Lock pageable Driver测试选项,它将调用MmLockPagableCodeSection DDL 这是调用后的情况。
kd> !pte krnlallocs!PageableFunction
VA fffff88003b8d000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E2000E8 PTE at FFFFF6FC4001DC68
contains 000000007BD84863 contains 000000007BD83863 contains 00000000122C3863 contains 2AF0000038EDC121
pfn 7bd84 ---DA--KWEV pfn 7bd83 ---DA--KWEV pfn 122c3 ---DA--KWEV pfn 38edc -G--A--KREV
kd> !pfn 38edc
PFN 00038EDC at address FFFFFA8000AAC940
flink 000002AF blink / share count 00000001 pteaddress FFFFF6FC4001DC68
reference count 0002 used entry count 0000 Cached color 0 Priority 5
restore pte 10A3100000060 containing page 0122C3 Active
kd> ?? ((nt!_mmsupport*) @@(nt!MmSystemPtesWs))->VmWorkingSetList->Wsle[0x2af].u1.e1
struct _MMWSLENTRY
+0x000 Valid : 0y1
+0x000 Spare : 0y0
+0x000 Hashed : 0y0
+0x000 Direct : 0y1
+0x000 Protection : 0y00000 (0)
+0x000 Age : 0y000
+0x000 VirtualPageNumber : 0y1111111111111111111110001000000000000011101110001101 (0xfffff88003b8d)
VA被映射到一个物理页上,它的引用计数为2,这将阻止它被移到一个过渡列表中。该页目前是系统PTE WS的一部分。如果它后来被移出,共享计数下降到0,引用计数下降到1,并且该页仍然处于活动状态。
内核模式映像加载方式的一个有趣的现象是,它们可以在加载时从文件系统中删除。文件没有以任何方式被锁定,这是有道理的,因为VMM在加载其内容后不需要再次读取它。即使镜像有可分页的section,这些也会在加载时被读取并放入Modified列表的页面中。最终它们可能会被写入分页文件,并在以后的时间里从那里重新加载,但镜像文件将不再被需要。这可以通过加载krnlallocs.sys并删除该文件来验证。不仅删除操作成功了,而且驱动仍然被加载并工作,通过MemTests调用它可以看到其正常工作。
2.1.3 大页的使用
如果系统上有足够的物理内存,内核和hal就会用大页进行映射。例如在一个拥有1GB内存的虚拟机上(这导致Windows看到的内存小于1GB),使用小页面。在一个拥有8GB内存的物理机上,会发现大页面。这就是我们在后一种情况下看到的情况。
kd> lm m nt
Browse full module list
start end module name
fffff800`03e5e000 fffff800`04448000 nt (pdb symbols) D:\WinDbg\x64\sym\ntkrnlmp.pdb\3844DBB920174967BE7AA4A2C20430FA2\ntkrnlmp.pdb
Unable to enumerate user-mode unloaded modules, Win32 error 0n30
kd> !pte fffff800`03e5e000
VA fffff80003e5e000
PXE at FFFFF6FB7DBEDF80 PPE at FFFFF6FB7DBF0000 PDE at FFFFF6FB7E0000F8 PTE at FFFFF6FC0001F2F0
contains 0000000000199063 contains 0000000000198063 contains 0000000003E009E3 contains 0000000000000000
pfn 199 ---DA--KWEV pfn 198 ---DA--KWEV pfn 3e00 -GLDA--KWEV LARGE PAGE pfn 3e1e
kd> lm m hal
Browse full module list
start end module name
fffff800`03e15000 fffff800`03e5e000 hal (deferred)
Unable to enumerate user-mode unloaded modules, Win32 error 0n30
kd> !pte fffff800`03e15000
VA fffff80003e15000
PXE at FFFFF6FB7DBEDF80 PPE at FFFFF6FB7DBF0000 PDE at FFFFF6FB7E0000F8 PTE at FFFFF6FC0001F0A8
contains 0000000000199063 contains 0000000000198063 contains 0000000003E009E3 contains 0000000000000000
pfn 199 ---DA--KWEV pfn 198 ---DA--KWEV pfn 3e00 -GLDA--KWEV LARGE PAGE pfn 3e15
像往常一样,使用大页的好处是,需要较少的TLB项来缓存这些镜像的VA范围。然而这也是有代价的:一个PDE映射了一个2MB的范围,定义了它的保护。内核镜像有几个文件section,它们被加载到小于2MB的范围内,并且有不同的保护要求:代码section应该是只读和可执行的;数据部分应该是读/写。对于大页面,单一的PDE必须配置限制性最小的保护,例如读/写/执行,因为它将所有这些子范围映射在一起。这意味着一个有问题的内核模式组件可以通过一个游离的指针覆盖内核镜像的代码。
系统可以通过在注册表键中添加一个名为LargePageDrivers的多字符串值来配置为其他内核模式镜像使用大页面
HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management。这个multistring必须被设置为镜像文件名的列表
2.2 会话空间的镜像加载
2.2.1 内存Sections重新开始发挥作用
我们可以通过考虑两个属于不同会话的进程,并记住每个会话空间区域都有一个不同的PML4E来开始我们的分析。因此内核面临着以下情况:
- 这个区域的所有分页结构层次是由不同的结构组成的:每个进程都有自己的PDPT,其条目指向不同的PD,而PD又指向不同的PT,等等。
- 从这个区域的镜像中加载的代码必须在两个这样的地址空间中共享,以避免不必要的重复而浪费物理内存。
- 每个进程都应该有一个单独的镜像读/写数据部分的副本。更好的是,应该实现写时拷贝,这样两个这样的地址空间共享同一个拷贝,直到一个写了它。
这与内核在多个进程中加载的用户模式映像(例如DLLs)所要解决的问题完全相同,所以它的解决方式也是一样的:通过建立一个内存section。我们知道,在会话空间之外不使用内存section,因为在它之外的系统VA范围是简单地共享的,因为在所有的地址空间都有相同的PML4E,不需要写时的拷贝行为:代码和数据都是完全共享的。在会话空间中,情况就不同了,所以sections又开始发挥作用了。
2.2.2 不再是用户模式
然而在会话空间中使用的sections与在用户模式中使用的sections之间有一些区别。
看起来,前者是作为分页文件支持的section建立的。这与其他内核模式映像的情况类似:一旦映像文件被加载,内存内容就不会与之挂钩。
找到win32k.sys的内存section
下面我们看到在一个测试系统上发现的会话空间的图像列表。
fffff960`000d0000 fffff960`003e0000 win32k (deferred)
fffff960`004d0000 fffff960`004da000 TSDDD (deferred)
fffff960`00720000 fffff960`00747000 cdd (deferred)
我们现在要深入研究win32k.sys的映射。在这样做之前,我们应该记住,当我们闯入调试器时,System是当前进程,它的PML4没有会话空间的有效条目,所以如果我们检查win32k.sys的PxEs,我们会看到这个
0: kd> !pte fffff960`000d0000
VA fffff960000d0000
PXE at FFFFF6FB7DBEDF90 PPE at FFFFF6FB7DBF2C00 PDE at FFFFF6FB7E580000 PTE at FFFFF6FCB0000680
contains 0000000000000000
contains 0000000000000000
not valid
为了有一个有效的会话空间,本分析的其余部分是在explorer.exe的地址空间上进行的。在这里win32k.sys的第一页看起来有所不同。
0: kd> !process 0 0 explorer.exe
PROCESS fffffa8032f1eb00
SessionId: 1 Cid: 0a8c Peb: 7fffffde000 ParentCid: 0a64
DirBase: 88a46000 ObjectTable: fffff8a001224880 HandleCount: 659.
Image: explorer.exe
0: kd> .process /i fffffa8032f1eb00
You need to continue execution (press 'g' <enter>) for the context
to be switched. When the debugger breaks in again, you will be in
the new process context.
0: kd> g
Break instruction exception - code 80000003 (first chance)
nt!RtlpBreakWithStatusInstruction:
fffff800`044b5400 cc int 3
0: kd> !pte fffff960`000d0000
VA fffff960000d0000
PXE at FFFFF6FB7DBEDF90 PPE at FFFFF6FB7DBF2C00 PDE at FFFFF6FB7E580000 PTE at FFFFF6FCB0000680
contains 000000009F028863 contains 000000009EF48863 contains 000000009EF47863 contains 80000000A067D221
pfn 9f028 ---DA--KWEV pfn 9ef48 ---DA--KWEV pfn 9ef47 ---DA--KWEV pfn a067d C---A--KR-V
0: kd> !pfn a067d
PFN 000A067D at address FFFFFA8001E13770
flink 00000000 blink / share count 00000002 pteaddress FFFFF8A000C4C048
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 2000000000A0 containing page 0A6278 Active MP
Modified Shared
pfn输出显示,该页是共享的,并且 pteaddress 在非分页池区域,而不是分页结构区域,所以它指向一个原型 PTE。
为了进入段数据结构,我们必须进行一些猜测。通常情况下,对于分页文件支持的section,原型PTEs数组就位于段结构的末端,在结构地址的+0x48处。我们可以尝试将位于 pteaddress - 0x48 的内存解释为一个段实例,看看会发生什么。
0: kd> ?? (nt!_SEGMENT*) ((char*)0xFFFFF8A000C4C048-0x48)
struct _SEGMENT * 0xfffff8a0`00c4c000
+0x000 ControlArea : 0xfffffa80`32584010 _CONTROL_AREA
+0x008 TotalNumberOfPtes : 0x310
+0x00c SegmentFlags : _SEGMENT_FLAGS
+0x010 NumberOfCommittedPages : 0x310
+0x018 SizeOfSegment : 0x310000
+0x020 ExtendInfo : 0xfffff97f`ff000000 _MMEXTEND_INFO
+0x020 BasedAddress : 0xfffff97f`ff000000 Void
+0x028 SegmentLock : _EX_PUSH_LOCK
+0x030 u1 : <unnamed-tag>
+0x038 u2 : <unnamed-tag>
+0x040 PrototypePte : 0xfffff8a0`00c4c048 _MMPTE
+0x048 ThePtes : [1] _MMPTE
如果这真的是一个段,ControlArea应该指向一个_control_area的实例,其中的Segment成员指向我们计算的地址。
0: kd> ?? ((nt!_SEGMENT*) ((char*)0xFFFFF8A000C4C048-0x48))->ControlArea->Segment
struct _SEGMENT * 0xfffff8a0`00c4c000
+0x000 ControlArea : 0xfffffa80`32584010 _CONTROL_AREA
+0x008 TotalNumberOfPtes : 0x310
+0x00c SegmentFlags : _SEGMENT_FLAGS
+0x010 NumberOfCommittedPages : 0x310
+0x018 SizeOfSegment : 0x310000
+0x020 ExtendInfo : 0xfffff97f`ff000000 _MMEXTEND_INFO
+0x020 BasedAddress : 0xfffff97f`ff000000 Void
+0x028 SegmentLock : _EX_PUSH_LOCK
+0x030 u1 : <unnamed-tag>
+0x038 u2 : <unnamed-tag>
+0x040 PrototypePte : 0xfffff8a0`00c4c048 _MMPTE
+0x048 ThePtes : [1] _MMPTE
控制区指向该段,所以事情看起来和期望的一样。现在尝试将控制区的地址输入到!ca扩展命令中:
0: kd> !ca @@(((nt!_SEGMENT*) ((char*)0xFFFFF8A000C4C048-0x48))->ControlArea)
ControlArea @ fffffa8032584010
Segment fffff8a000c4c000 Flink 0000000000000000 Blink 0000000000000000
Section Ref 1 Pfn Ref 0 Mapped Views 0
User Ref 1 WaitForDel 0 Flush Count 0
File Object 0000000000000000 ModWriteCount 0 System Views 0
WritableRefs 0 PartitionId 0
Flags (2000) Commit
Pagefile-backed section
Segment @ fffff8a000c4c000
ControlArea fffffa8032584010 ExtendInfo fffff97fff000000
Total Ptes 310
Segment Size 310000 Committed 310
CreatingProcess 26 FirstMappedVa 0
ProtoPtes fffff8a000c4c048
Flags (c0000) ProtectionMask
Subsection 1 @ fffffa8032584090
ControlArea fffffa8032584010 Starting Sector 0 Number Of Sectors 0
Base Pte fffff8a000c4c048 Ptes In Subsect 310 Unused Ptes 0
Flags c Sector Offset 0 Protection 6
有两个事实表明我们确实看到的是一个控制区:
- Segment地址0xFFFFF8A0'00102000是我们计算出来的,即来自于pteaddress-0x48。
- subsection显示了一个Base Pte值,等于我们开始时的PTE地址。
过一会儿,我们将原型PTEs与win32k.sys镜像进行比较,并找到进一步的证据,证明这是一个充满了文件内容的section。
如我们从上面的输出中可以看到的,这是一个分页文件支持的section,而且就像这些sections通常一样,它由一个小节组成。这种行为与观察到的用户模式镜像文件的行为不同,在这种情况下,每个文件section都会创建单独的子段。我们现在要更详细地分析一下这个subsection。
section原型PTE vs win32k.sys镜像
win32k.sys是由不同的文件section组成的,它们应该以不同的保护方式载入。对于用户模式的镜像,这是通过为每个文件section创建一个具有必要的页级保护的不同subsection来完成的。在会话空间中,我们看到单个分页文件支持的section的单个原型PTE被设置为所需的保护。
下面是win32k.sys的dumpbin /headers的输出摘录
Microsoft (R) COFF/PE Dumper Version 14.29.30133.0
Copyright (C) Microsoft Corporation. All rights reserved.
Dump of file win32k.sys
...
SECTION HEADER #1
.text name
29F8AB virtual size
1000 virtual address (FFFFF97FFF001000 to FFFFF97FFF2A08AA)
29FA00 size of raw data
400 file pointer to raw data (00000400 to 0029FDFF)
0 file pointer to relocation table
0 file pointer to line numbers
0 number of relocations
0 number of line numbers
68000020 flags
Code
Not Paged
Execute Read
Debug Directories
Time Type Size RVA Pointer
-------- ------- -------- -------- --------
4CE79A73 cv 23 002A0888 29FC88 Format: RSDS, {21E2778D-D295-4987-A9B7-212463FFDC5E}, 2, win32k.pdb
4CE79A73 ( A) 4 002A0884 29FC84 BB03197E
我们看到,第一个文件section必须在section偏移量0x1000处被映射,也就是说,它必须被数组中的第二个原型PTE所映射,而且它的保护是执行/读取。
1: kd> !pte FFFFF8A000C4C048+8 1
VA fffff8a000c4c050
PXE at FFFFF8A000C4C050 PPE at FFFFF8A000C4C050 PDE at FFFFF8A000C4C050 PTE at FFFFF8A000C4C050
contains 00000000A077E021 contains 00000000A077E021 contains 00000000A077E021 contains 00000000A077E021
pfn a077e ----A--KREV pfn a077e ----A--KREV pfn a077e ----A--KREV pfn a077e ----A--KREV
1: kd> !pte FFFFF8A000C4C048+8+8 1
VA fffff8a000c4c058
PXE at FFFFF8A000C4C058 PPE at FFFFF8A000C4C058 PDE at FFFFF8A000C4C058 PTE at FFFFF8A000C4C058
contains 000000009FF7F021 contains 000000009FF7F021 contains 000000009FF7F021 contains 000000009FF7F021
pfn 9ff7f ----A--KREV pfn 9ff7f ----A--KREV pfn 9ff7f ----A--KREV pfn 9ff7f ----A--KREV
原型PTE的状态与进入win32k.sys的地址+0x1000的硬件PTE的状态一致。
1: kd> !pte 0xfffff960`000d0000+0x1000
VA fffff960000d1000
PXE at FFFFF6FB7DBEDF90 PPE at FFFFF6FB7DBF2C00 PDE at FFFFF6FB7E580000 PTE at FFFFF6FCB0000688
contains 000000009F028863 contains 000000009EF48863 contains 000000009EF47863 contains 00000000A077E021
pfn 9f028 ---DA--KWEV pfn 9ef48 ---DA--KWEV pfn 9ef47 ---DA--KWEV pfn a077e ----A--KREV
1: kd> !pte 0xfffff960`000d0000+0x2000
VA fffff960000d2000
PXE at FFFFF6FB7DBEDF90 PPE at FFFFF6FB7DBF2C00 PDE at FFFFF6FB7E580000 PTE at FFFFF6FCB0000690
contains 000000009F028863 contains 000000009EF48863 contains 000000009EF47863 contains 000000009FF7F021
pfn 9f028 ---DA--KWEV pfn 9ef48 ---DA--KWEV pfn 9ef47 ---DA--KWEV pfn 9ff7f ----A--KREV
可以看到这里没有出现书上的分页情况。故不做复现。
3 内核模式栈
3.1 为什么内核模式栈很特殊
内核模式堆栈是一个非常重要的资源,因为它被处理器本身用来处理中断和异常。当这些事件之一发生时,处理器将一些寄存器(ss, rsp, rflags, cs, rip)的内容保存在堆栈中,对于一些异常情况,会有一个错误代码,然后将控制转移到IDT中的处理函数地址。
从上面的概述中可以看出,处理器需要一个工作的堆栈来成功地将控制权转移给处理程序。如果中断发生在用户模式下,处理器会切换到内核模式,并在写到堆栈之前从TSS加载CPL0 rsp。如果事件发生在内核模式下,处理器只是使用rsp的当前值。在这两种情况下,都需要一个工作的0环堆栈。
但是如果rsp指向一个无效的地址,例如指向一个没有映射的VA呢?这对处理器来说是一个相当困难的情况,它会产生一个新的异常:双重错误异常(向量#8)。请注意,在这个阶段,处理器的寄存器仍然被设置成第一个异常发生时的样子。
通过产生另一个异常来处理这个问题,起初似乎不是一个好主意,因为处理它也需要一个工作栈。然而诀窍在于,双重错误的中断门在IST(中断堆栈表)字段中指定了一个非零的索引。这告诉处理器在写入堆栈之前从IST(TSS的一部分)加载rsp。IST最多可以存储7个指针,加载到rsp中。如果一个中断发生了,它的IDT描述符有一个非零的IST索引,rsp就会从IST的相应条目中加载。这样如果 "常规 "的0环堆栈不可用,中断仍然可以被处理器成功处理。
Windows用非零的IST索引和KiDoubleFaultAbort的地址来设置双重错误的中断门。这个函数以错误检查代码0x7F(UNEXPECTED_KERNEL_MODE_TRAP)使系统崩溃。因此即使正常的0号环形堆栈没有了,系统也能以一种可控的方式关闭。
为了完整起见,我们应该考虑当处理器试图产生双重故障时,rsp仍然指向一个无效地址的情况。如果双重故障描述符的第1个索引被设置为0,或者第1个条目中的地址是无效的,这就可能发生。在这种(末日)情况下,处理器进入关机模式,这意味着它停止执行指令。处理器只能通过少数几个信号被带出关机模式,包括一个硬复位的信号。当进入关机状态时,处理器会发出信号说明其外部引脚的情况,通常,芯片组的反应是激活复位信号并导致完全重新启动。这是Windows系统在没有先进行错误检查的情况下重启的一种情况。
!idt扩展显示了IST 索引为非零的向量的rsp
虚拟机win7:
kd> !idt
Dumping IDT: fffff80000b93000
...
01: fffff800045d2180 nt!KiDebugTrapOrFaultShadow Stack = 0xFFFFF80000B969E0
02: fffff800045d2200 nt!KiNmiInterruptShadow Stack = 0xFFFFF80000B967E0
...
08: fffff800045d2500 nt!KiDoubleFaultAbortShadow Stack = 0xFFFFF80000B963E0
...
12: fffff800045d2980 nt!KiMcheckAbortShadow Stack = 0xFFFFF80000B965E0
真机win10:
01: fffff80564403640 nt!KiDebugTrapOrFault Stack = 0xFFFFF80562BA4000
02: fffff80564403b40 nt!KiNmiInterrupt Stack = 0xFFFFF80562B96000
...
08: fffff805644053c0 nt!KiDoubleFaultAbort Stack = 0xFFFFF80562B8F000
...
12: fffff80564407440 nt!KiMcheckAbort Stack = 0xFFFFF80562B9D000
虚拟机win10:
1: kd> !idt
Dumping IDT: ffff800035b2d000
01: fffff80413812180 nt!KiDebugTrapOrFaultShadow Stack = 0xFFFF800035B319D0
02: fffff80413812240 nt!KiNmiInterruptShadow Stack = 0xFFFF800035B317D0
...
08: fffff80413812540 nt!KiDoubleFaultAbortShadow Stack = 0xFFFF800035B313D0
...
12: fffff804138129c0 nt!KiMcheckAbortShadow Stack = 0xFFFF800035B315D0
我们可以看到,只有4个向量被设置为使用IST:调试陷阱或错误,不可屏蔽的中断、双重故障和机器检查。这些都是严重的中断/异常,通常不会发生。
3.2 线程内核栈
3.2.1 线程栈 vs 上下文切换
每个线程都有自己的内核栈。系统中的每个处理器总是在某个线程的上下文中执行,当它从3环转到0环时,rsp被加载了当前线程的初始内核栈。
3环-0环的转换是由Windows代码执行的(通常是执行一个syscall指令),内核堆栈是由内核代码设置的,控制权被转移到该代码。当转换是由于中断而发生时,处理器会自动从TSS中加载0环堆栈。
然而处理器对线程一无所知:它只是从TSS加载CPL0 rsp(我们在此不考虑使用IST的向量),所以线程的实现由Windows决定。
当一个线程运行时,CPL0 rsp的TSS条目被设置为分配给该线程的内核栈的初始地址。这个值也由!thread扩展打印为
1: kd> !thread @$thread
THREAD fffff8800450f0c0 Cid 0000.0000 Teb: 0000000000000000 Win32Thread: 0000000000000000 RUNNING on processor 1
...
Win32 Start Address nt!KiIdleLoop (0xfffff800044980c0)
Stack Init fffff8800452fc70 Current fffff8800452fc00
...
这可以通过转储TSS的内容来确认,并告诉我们,改变线程上下文的调度器代码会更新TSS,作为使线程活跃的一部分。因此当一个中断发生时,当前线程的0环堆栈已经在TSS里面了。
3.2.2 分配, 分页, _MMPFNs
内核堆栈在创建线程时被分配到系统PTE区域。由于内核堆栈对处理器来说是必不可少的,它的VA范围不能被工作集管理器所忽略。系统不能在由不存在的堆栈页引起的0环页故障中存活下来:处理器将试图使用相同的堆栈来压入要保存的寄存器,最终导致一个双重故障异常。(然而请注意,这些堆栈在某些条件下可以被换出,但这是由一个专门的VMM组件处理的,我们将在后面进行描述)。
因此我们不希望映射堆栈的物理页成为任何工作集的一部分,这通常会转化为将_MMPFN.u1.Wslndex设置为0。事实证明这个成员对线程堆栈页有不同的意义。
让我们先来看看一个具有初始堆栈指针的线程:
1: kd> !thread @$thread
THREAD fffffa8032e938b0 Cid 0b90.0ba4 Teb: 000007fffffd5000 Win32Thread: fffff900c07ce010 RUNNING on processor 1
Not impersonating
DeviceMap fffff8a00151c9a0
Owning Process fffffa8032e84060 Image: WmiPrvSE.exe
Attached Process fffffa8032c44b00 Image: explorer.exe
Wait Start TickCount 5197 Ticks: 9 (0:00:00:00.140)
Context Switch Count 1414 IdealProcessor: 0 LargeStack
UserTime 00:00:00.280
KernelTime 00:00:04.758
Win32 Start Address ntdll!TppWorkerThread (0x00000000776a93e0)
Stack Init fffff880023cfc70 Current fffff880023cf560
Base fffff880023d0000 Limit fffff880023c7000 Call 0000000000000000
...
现在让我们来看看第1个堆栈页的物理页面:
1: kd> !pte fffff880023cfc70
VA fffff880023cfc70
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E200088 PTE at FFFFF6FC40011E78
contains 0000000135004863 contains 0000000135003863 contains 0000000087413863 contains 8000000085EF8863
pfn 135004 ---DA--KWEV pfn 135003 ---DA--KWEV pfn 87413 ---DA--KWEV pfn 85ef8 ---DA--KW-V
1: kd> !pfn 85ef8
PFN 00085EF8 at address FFFFFA800191CE80
flink FFFFFA8032E938B1 blink / share count 00000001 pteaddress FFFFF6FC40011E78
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 2000000003E0 containing page 087413 Active M
Modified
标有flink的_MMPFN.ul的内容是线程的_ethread的地址。实际上它等于地址+1,但这可能是由于_ethread实例是以某个偶数的倍数对齐的,所以位0在实际地址中已知为0,并在_mmpfn中作为一个标志使用。_mmpfn.u1是一个union,这种用法与_MMPFN.ul.KernelStackOwner一致。
我们可以确认接下来的堆栈页都显示了同样的结果(记住对于堆栈,"下一个 "意味着更低的地址)。
1: kd> !pte fffff880023ce000
VA fffff880023ce000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E200088 PTE at FFFFF6FC40011E70
contains 0000000135004863 contains 0000000135003863 contains 0000000087413863 contains 80000000861F9863
pfn 135004 ---DA--KWEV pfn 135003 ---DA--KWEV pfn 87413 ---DA--KWEV pfn 861f9 ---DA--KW-V
1: kd> !pfn 861f9
PFN 000861F9 at address FFFFFA8001925EB0
flink FFFFFA8032E938B1 blink / share count 00000001 pteaddress FFFFF6FC40011E70
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 2000000003E0 containing page 087413 Active M
Modified
1: kd> !pte fffff880023ca000
VA fffff880023ca000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E200088 PTE at FFFFF6FC40011E50
contains 0000000135004863 contains 0000000135003863 contains 0000000087413863 contains 80000000852FD863
pfn 135004 ---DA--KWEV pfn 135003 ---DA--KWEV pfn 87413 ---DA--KWEV pfn 852fd ---DA--KW-V
1: kd> !pfn 852fd
PFN 000852FD at address FFFFFA80018F8F70
flink FFFFFA8032E938B1 blink / share count 00000001 pteaddress FFFFF6FC40011E50
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 2000000003E0 containing page 087413 Active M
Modified
下面是最后一个(最低地址)堆栈页的转储,在!thread输出中标记为Limit,显示了一致的结果
1: kd> !pte fffff880023c7000
VA fffff880023c7000
PXE at FFFFF6FB7DBEDF88 PPE at FFFFF6FB7DBF1000 PDE at FFFFF6FB7E200088 PTE at FFFFF6FC40011E38
contains 0000000135004863 contains 0000000135003863 contains 0000000087413863 contains 8000000085701863
pfn 135004 ---DA--KWEV pfn 135003 ---DA--KWEV pfn 87413 ---DA--KWEV pfn 85701 ---DA--KW-V
1: kd> !pfn 85701
PFN 00085701 at address FFFFFA8001905030
flink FFFFFA8032E938B1 blink / share count 00000001 pteaddress FFFFF6FC40011E38
reference count 0001 used entry count 0000 Cached color 0 Priority 5
restore pte 2000000003E0 containing page 087413 Active M
Modified
3.2.3 Guard页面
VMM在内核模式线程堆栈的最低页下面分配了一个所谓的保护页。我们已经在用户模式中遇到过这个术语,但在这里它的含义有所不同。这个守护页实际上是一个无效的PTE(发现它被设置为0),所以在堆栈下面有一个无效的虚拟页。如果线程使用了所有的堆栈空间,并且rsp被移到了最后一个有效地址下面,那么堆栈上就会出现一个页面故障,正如我们现在所知道的,这将以双重故障和系统崩溃告终。
这与用户模式保护页的使用完全不同:它们也会在堆栈上引起一个页面故障,但是在ring3是无害的,因为处理器进入CPL 0,在试图在堆栈上写任何东西之前,从TSS加载相应的rsp。因此内核页故障处理程序被调用,就像其他页故障一样,检测到异常原因是保护页,提交另一个堆栈页并恢复执行。
对于内核模式的线程堆栈,防护页是一种极端的措施,以避免堆栈下溢覆盖系统PTE区域的其他内核模式数据。与其让这种情况发生并导致难以诊断的损坏,不如让系统立即崩溃,从而更容易找到罪魁祸首。
3.2.4 栈跳转
一些Windows组件需要比常规分配给线程的更多的内核堆栈空间。一个例子是由win32k.sys处理的图形系统调用。这个模块在内核模式下执行,在返回给调用者之前可能会调用用户模式的回调。在执行回调代码时,处理器可以再次进入内核模式,例如因为一个硬件中断,但也可能是因为回调本身所调用的另一个系统调用。在这种情况下,内核栈上有两个嵌套的上下文,如果在返回之前发生其他模式转换,还可以增加更多的上下文。这种堆栈的使用会导致堆栈下溢。
为了避免这种情况,图形子系统使用了一个VMM设施,它分配了一个额外的内核模式堆栈来代替原来的堆栈,并 "链接 "这两个堆栈,从而给嵌套的上下文更多的堆栈空间。当嵌套的上下文返回时,每一个额外的堆栈都被释放,前一个堆栈被恢复为当前的。驱动程序开发者可以通过调用KeExpandKernelStackAndCalloutEx DDI来获得这个功能。
_mmpfn.u1有一个叫做NextstackPfn
的成员,类型是_single_list_entry;这个名字和类型都表明它可能被用来把不同的堆栈连接在一起,也许是作为KeExpandKernelStackAndCalloutEx
实现的一部分。
3.2.5 Swapper
内核栈的换出
堆栈虚拟页的转换不能被工作集管理器取消映射。然而如果总是保持每个内核堆栈的驻留,这将是对宝贵的物理内存的浪费。只要一个线程不运行,它的堆栈就不需要:没有代码会接触到那个特定的VA范围。因此如果一个线程等待了很长时间,例如等待用户输入,那么换出它的堆栈和回收物理页是有意义的。
交换器是一个系统线程,它执行KeSwapProcessOrStack,释放那些不活动超过一定最长时间的线程的内核模式栈所使用的物理页。下面是Swapper在回收页面过程中的状态。
可以通过检查内核模式堆栈的_mmpfns,并在_mmpfn.OriginalPte.上设置数据访问断点来拦截Swapper。
事实证明,Swapper是相当积极的,通常在不到1分钟的不活动后就会解除内核堆栈的映射。检查被Swapper "照顾 "过的线程栈是很有趣的。
内核栈的换入
3.3 DPC栈
在处理DPC时,Windows会切换到一个不同的内核堆栈。系统中的每个处理器都有一个单独的堆栈,并用于此目的,以进一步将DPC触发的代码与在常规线程上下文中执行的其他代码隔离。
4.一些晦涩难懂的VMM概念
本节是关于在研究本书的材料时遇到的一些VMM概念
4.1 Process Metapages
在管理进程工作集的直接哈希的代码中可以观察到Process Metapages。当必须添加一个哈希表项时,MiUpdateWsleHash
被调用。当工作集使用直接哈希表时,这个函数可能必须在直接哈希范围内映射一个虚拟地址。在这个范围内,只有实际需要存储表项的虚拟页被映射到物理页上,所以如果被添加的条目落在一个尚未被映射的页上,则通过调用MiMapProcessMetaPage
来设置转换。添加的页面被计入两个计数器中。
_MMSUPPORT.WorkingSetSizeOverhead
和静态的MiWsOverheadPages
。这些变量的使用环境和它们的名字表明,它们的目的是跟踪用于管理工作集列表的页面,但它们并不是工作集本身的一部分。由于工作集列表管理页面,这些是 "页面上的页面 "或元页面。当试图说明整体物理内存的使用情况时,所观察到的计数器是有用的。
4.2 常驻可用内存
4.2.1 基本概念
为了解释这个概念,让我们考虑创建一个线程的问题。我们知道,一个线程必须有一个内核模式堆栈,当线程运行时,它必须常驻在物理内存中。我们也知道,当线程暂停时,堆栈可以被换出,但线程不能运行,直到它的堆栈被完全带回内存中。
鉴于此,在创建线程之前检查是否有足够的可用物理内存用于堆栈是有意义的,因为创建一个线程然后不能运行它是毫无意义的。
这就是常驻可用内存的意义:它是一个由VMM维护的计数器,用于检查是否有足够的可用物理内存来执行某些操作,这些操作至少需要最低数量的内存。
另一个例子是创建一个新的进程。每个进程都需要一个最小的工作集来运行,所以如果系统不能保证有这么多的物理内存,创建一个进程的尝试可能会失败。
但是物理内存是可用的,这意味着什么?通常情况下,可用页的数量被计算为Zeroed、空闲和Standby列表上的页数,因为这些是准备被重新使用的页数(Zeroed和空闲的不被使用,Standby准备被重新利用)。我们可以认为,这是检查是否有足够的内存用于新的堆栈或进程的值。
然而这种方法是不正确的。考虑一下Modified列表中的页面:它们不被算作是可用的,但是它们可以通过将其内容冲到磁盘上而变得可用。另外,一个目前属于工作集的页面可以通过修剪后者而变得可用,如果需要的话,最终将页面内容缓存到磁盘上。这告诉我们,如果通过检查当前可用页面的数量而导致创建线程失败,那就太有局限性了。
相反我们需要检查的数值是如果所有的东西都被压缩到最小,我们将有多少可用的物理内存:所有的进程工作集被裁减到最小,分页池被换出,可分页的内核模式代码被换出,等等。如果我们确定,即使在这些条件下,我们仍然缺乏足够的内存来进行尝试的操作,那么我们必须返回失败。
我们感兴趣的数值可以通过从安装的总物理内存中减去不能换出的内容所使用的内存量来计算:非分页池、非分页驱动程序和内核代码等。其结果就是常驻可用内存。
回到创建线程的例子,如果操作成功,内核堆栈的大小必须从常驻可用数中减去,因为该数量的内存变得不可分页。
这又增加了一个转折:我们知道,事实上内核栈可以被交换器线程交换出去,但这在常驻可用计算中没有被考虑。如果我们认为,为了这个目的,系统表现得好像内核堆栈总是驻留的,这就更容易理解了。这就保证了所有现有的线程都能有自己的堆栈驻留,如果有需要的话,比如说没有一个线程被暂停的话。如果一个新的线程的堆栈不能和所有其他的堆栈(以及所有其他不可分页的内容)一起驻留在物理内存中,系统就不允许创建一个新的线程。也就是说,如果一个线程挂起足够长的时间,它的堆栈就会被交换出来,释放出物理内存,即使不被算作常驻可用,也可以被用来做一些有用的事情,例如添加到工作集等。
这同样适用于进程:如果一个进程的所有线程都被换出,那么该进程的整个工作集也会被换出,其工作集大小下降到0,即低于工作集的最小大小。然而如果要运行线程,与最小尺寸相对应的最小工作集必须被带回内存,所以最小尺寸被认为是被锁定的,并在创建进程时从常驻可用计数器中减去。
考虑到它的计算方式,常驻可用内存的数量经常被发现大于当前的可用内存。后者是当前所有工作集的大小,而前者是如果每个工作集都是最小值(但换出的进程和堆栈被算作常驻)时的可用量。
Resident Available Memory的当前值包括在!vm WinDbg扩展的输出中
kd> !vm
Page File: \??\C:\pagefile.sys
Current: 8388088 Kb Free Space: 8388084 Kb
Minimum: 8388088 Kb Maximum: 25164264 Kb
Physical Memory: 2097022 ( 8388088 Kb)
Available Pages: 1894040 ( 7576160 Kb)
ResAvail Pages: 1970392 ( 7881568 Kb)
Locked IO Pages: 0 ( 0 Kb)
Free System PTEs: 33494700 ( 133978800 Kb)
...
通常情况下,由 !vm 打印的值被发现等于静态变量 MmResidentAvailablePages
kd> dq nt!MmResidentAvailablePages
fffff800`04059c00 00000000`001e10d8 00000000`00000000
kd> ? 1e10d8
Evaluate expression: 1970392 = 00000000`001e10d8
当常驻可用数变得太低时,系统开始出现故障,因为各种基本操作,如线程创建会失败。
4.2.2 每个处理器的缓存计数器
4.2.3 测试锁定内存的影响
!vm
dq nt!MmResidentAvailablePages l1
dq gs:[20] l1
?? ((nt!_kprcb*) Oxfffff800`02a08e80)->CachedResidentAvailable
? 0n201124+6a+7f
g @$ra
? 3128d-30f8c
!sysptes
4.2.4 测试消耗更多大于当前可用内存的常驻可用内存
对于后面的一些内容,我采取了留白方式,因为本书最大的益处是帮助我理解了Windows x64虚拟内存管理,并教会了我很多的windbg命令和使用技巧。有些章节以后再看吧!
5 与缓冲管理器交互
5.1 缓冲读取概述
5.2 缓存物理内存使用
5.3 预取
5.4 Modified No-Write 链表
6 内存对象性能计数
在这一章中,我们将研究一些通过性能监视器提供的与内存有关的计数器的含义。这并不是一个完整的列表:我们只分析那些可以通过本书介绍的概念来阐明其含义的计数器。
对于一些计数器,我们还将参考Sysinternals的Process Explorer报告的数值,其中使用了15.11版本。
6.1 可用字节数
Process Explorer 中显示的名字:Available,在物理内存section中(单位:kb)。
内核变量:未知
空闲、归零和待机列表的综合大小,单位是字节。这个值是以下计数器的总和。
- Free & Zero List Bytes
- Standby Cache Core Bytes
- Standby Cache Normal Priority Bytes
- Standby Cache Reserve Bytes
鉴于VMM的整体架构,这是可用的物理内存量。
6.2 Cache Bytes
Process Explorer 中显示的名字:CacheWS,在物理内存section中(单位:KB)
内核变量: MmSystemCacheWs.WorkingSetSize
文件系统缓存工作集的大小,单位是字节。
在Windows 7中,这个计数器的值与系统缓存常驻字节计数器相同。
6.3 Cache Faults/sec
每秒由访问文件系统缓存内容引起的页面故障的数量。这包括软故障和硬故障,是故障的计数,而不是页面的计数。
6.4 Commit Limit
Process Explorer 中显示的名字: Limit, in the Commit Charge section (in kB)
Kernel variable: 未知
Commit charge的最大可能值,计算为物理内存大小和分页文件大小之和。关于提交费用的更多细节,请参见已提交字节计数器。
由于在commit charge中计算的页面必须存储在RAM或分页文件中,它们的大小之和就是提交限额。
附录A
!pfn扩展命令
本附录描述了大部分由!pfn扩展名显示的信息,该扩展名用于格式化_mmpfn的内容。
下面是一个脏页的输出
我们现在要解释一下所打印的信息。
at address : 这个标签后面的表示_MMPFN实例的地址。我们可用dt nt!_MMPFN
来验证。
flink 这个是_MMPFN.u1的值。这个成员的含义取决于页面状态。
- 对于一个active页面,他存储的是WS项索引。(_MMPFN.u1.WsIndex)
- 如果页面在一个PFN链起来的页面上,那么它存储的是前面的一个link,也就是_MMPFN.u1.Flink。对于其他页面,这个值存储_MMPFN在PFN数据库中的索引,如果这个页面是链表的最后一项,那么这个成员设置成0xFFFFFFFFFFFFFFF。
- 如果这个页面在单向链表上,他存储下一个虚拟地址的_MMPFN实例。
- 当这个页面处于读取状态时,它存储事件的地址,也就是_MMPFN.u1.Event
blink/ share count: 这个值的含义也取决于页面状态
- 对于active页面,这个值表示共享计数,对于私有页面,这个值是0或者1。对于分页结构的页面或者共享内存,它的值才可能大于1.
- 对于在PFN链起来的页面,他存储了前一个页面的PFN。如果他是第一个的话,那么他设置为0xffffffffffffffff。
pteaddress: _MMPFN.PteAddress 也就是映射这个页面的PTE地址。
reference count: _MMPFN.u3.ReferenceCount,页面的引用计数。
used entry count: _MMPFN.UsedPageTableEntries的值
Cache/NonCahced/WriteComb: 也就是used entry count值后面的内容,这个标签对应了_MMPFN.u3.e1.CacheAttribute的值。
下表是值和标签之间的对应关系。
Priority: _MMPFN.u3.e1.Priority ,代表页面的内存优先级
restore pte: _MMPTF.OriginalPte的内容。也就是当PTE被重新使用时,分配给PTE映射页面的值。
containing page: 这是_MMPFN.u4.PteFrame的值,存储映射该页的PTE的分页结构的PFN。
Zeroed/Free/,,, 在包含页面的值的右边是一个标签,对应于_mmpfn.u3.e1.PageLocation的值,它代表页面状态。下表显示了标签和成员值之间的关系。
Flags: 在页面状态的右边是一组一个字符的标签,每个标签对应于_mmpfn中的一个标志位。如果相应的位被设置,该标签就会被打印出来,同时在下面一行打印出一个长的、更易读的标签。在上面的例子中,我们看到短标签M和长标签Modified。它们被打印出来是因为这个页面有_mmpf.u3.e1.Modified = 1。下表显示了每个标志位与短标签和长标签的关系。
https://www.notion.so
)
备注
- 这个标签不应该与页面在 "Modified "列表中时打印的相同标签相混淆。下面是一个显示两种标签的样本!pfn输出。一个是标志位,一个是处于什么链表上。
Enrico Martignetti于1993年毕业于都灵理工大学的电子工程专业。他从1990年开始从事软件开发工作,从90年代末开始对Windows架构产生了浓厚的兴趣,当时他很快决定研究Windows 98的内核。2001年,他转而研究Windows NT家族的内核,并从那时起一直试图弄清它的意义。
这是一本针对求知欲强的读者的书。
它试图回答 "它是如何工作的 "这一基本问题。
因此它没有解释如何调用文档中的APIs和DDIs来实现某个具体的目标。关于这些问题有很多信息,包括msdn库 ,wdk文档和一些优秀的书籍。
相反它的目的是分析虚拟内存管理器是如何工作的,仅仅是因为这个知识是十分有趣,并值得去了解的。
尽管本书对虚拟内存管理器做了相当详细的描述,但它并不是专门为有经验的内核级程序员准备的。第一部分和第二部分提供的信息
提供了关于x64处理器的信息和关于内核模式代码执行的足够细节
以帮助第一次接触这些主题的读者。
本书描述了Windows 7 x64实现
虚拟内存管理器的实现。所有的分析和实验都是
都是在这个特定的版本上进行的。