Windows漏洞利用技巧:滥用用户模式调试器
导语:最近,我一直在研究如何为NtObjectManager添加本地用户模式调试器支持。每当我添加一个新功能时,我都必须进行一些研究和逆向工程工作,以更好的理解其具体的工作方式。
前言
最近,我一直在研究如何为NtObjectManager添加本地用户模式调试器支持。每当我添加一个新功能时,我都必须进行一些研究和逆向工程工作,以更好的理解其具体的工作方式。在这种情况下,我希望知道调试现有的正在运行的进程需要什么级别的访问权限。此外,我还注意到用户模式API暴露的内容与内核实际执行的操作之间存在一些安全性不匹配的问题,这非常值得关注,阅读这些相关文档也非常重要。在这里,我将描述的并不是一个特定的安全漏洞,而是一个通用的漏洞利用技巧,这一技巧对于利用某些类别的漏洞来说非常具有帮助,因此我也将其归纳在我的“漏洞利用技巧”系列文章之中。
就本文而言,我们不打算深入探讨用户模式的调试器是如何工作的,如果各位读者想要了解更多,可以阅读Ionescu在12年前写的3份白皮书报告(1 ,2 ,3),这三份报告是针对Windows XP的,并且从那时开始,Windows系统的内部原理始终没有改变太多。鉴于这一观察结果,当我在Windows 10 1809上测试并记录行为时,我确信这些技术适用于早期版本的Windows。
附加到正在运行的进程
要在现代版本的Windows NT上进行调试,需要基于Debug内核对象,我们可以通过调用NtCreateDebugObject系统调用来创建该对象。该对象将作为分配调试进程的“桥梁”,并等待返回调试事件。Win32 API将会对用户隐藏该对象的创建,每个线程都具有其自己的调试对象,并且存储在其TEB中。
如果要附加到现有进程,可以调用具有原型的Win32 API DebugActiveProcess:
BOOL DebugActiveProcess(_In_ DWORD dwProcessId);
最终,调用了本地API,NtDebugActiveProcess,它具有以下原型:
NTSTATUS NtDebugActiveProcess( _In_ HANDLE ProcessHandle, _In_ HANDLE DebugObjectHandle);
DebugObjectHandle来自TEB,但它需要的是进程句柄,而非PID,这将导致两个API的调用语义不匹配。这意味着,Win32 API负责打开进程的句柄,然后将其传递给本地API。
当我看到这样的代码后,立即想到了一些问题,Win32 API需要什么访问,随后内核API实际执行了什么工作?这不仅仅是一个闲暇时的简单思考,我们知道,安全漏洞往往会出现在接口层之间不匹配的假设之中。我们有一个很好的例子,就是发现NTFS硬链接在使用CreateHardlink从Win32应用程序创建时,需要写入访问权限。但是,内核API并不需要任何访问权限(请参阅我的博客文章中,此处讨论的硬链接问题),允许我们硬链接到可以为任何访问权限打开的任何文件。要了解Win32 API和内核API实施的内容,我们需要查看反汇编程序中的代码,或者查看Alex编写的原始代码,其中包含代码RE’d。DebugActiveProcess中的代码将调用一个帮助程序ProcessIdToHandle,以获取如下所示的进程句柄:
HANDLE ProcessIdToHandle(DWORD dwProcessId) { NTSTATUS Status; OBJECT_ATTRIBUTES ObjectAttributes; CLIENT_ID ClientId; HANDLE ProcessHandle; DWORD DesiredAccess = PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION | PROCESS_SUSPEND_RESUME; ClientId.UniqueProcess = dwProcessId; InitializeObjectAttributes(&ObjectAttributes, NULL, ...) Status = NtOpenProcess(&ProcessHandle, DesiredAccess, &ObjectAttributes, &ClientId); if (NT_SUCCESS(Status)) return ProcessHandle; BaseSetLastNTError(Status); return NULL; }
上面的代码看上去没有太大的问题,其PID使用NtOpenProcess打开一个进程。代码需要调试器所需要的所有访问权限:
1、创建新线程,这是注入初始断点的方式。
2、读取和修改内存的权限,用于写入断点,并检查进程的运行状态。
3、暂停和恢复整个进程。
一旦内核获得句柄,它就需要使用ObReferenceObjectByHandle将其转换回进程对象指针。API采用所需的访问掩码(Access Mask),该掩码针对打开的句柄访问掩码进行检查,并且只有在检查成功通过时才会返回指针。下面是相关的代码片段:
NTSTATUS NtDebugActiveProcess(HANDLE ProcessHandle, HANDLE DebugObjectHandle) { PEPROCESS Process; NTSTATUS status = ObReferenceObjectByHandle( ProcessHandle, PROCESS_SUSPEND_RESUME, PsProcessType, KeGetCurrentThread()->PreviousMode, &Process); // ... }
在这里,出现了严重的不匹配问题。用户模式API需要以比内核API要求更高的访问权限来打开进程。内核仅强制执行挂起和恢复目标进程的权限。从内核的角度来看,这是有意义的(正如Raymond Chen所说,需要透过内核的彩色眼镜来看),因为将进程附加到调试对象的直接影响是暂停进程。我们可以假设,如果没有其他访问权限(例如:VM读取、VM写入),就不会进行太多调试,但从内核角度来看,这是无关紧要的。我们只需要使用内核API,即可暂停/恢复进程(通过NtDebugContinue),并从调试对象中读取事件。从设计角度来看,我们可能会在调试事件的过程中获取无法访问的内存地址,这一事实并不重要。
那么,具体的问题在哪里呢?只有PROCESS_SUSPEND_RESUME访问权限。我们可以调试具有有限访问权限的进程,但如果没有其他访问权限,就无法实现这一操作。但是,如果我们仅仅具有PROCESS_SUSPEND_RESUME访问权限,我们该怎么办?
访问调试事件
要解答这一问题,需要回顾我们在第一次等待调试事件时收到的CREATE_PROCESS_DEBUG_INFO事件。需要注意的是,本地结构与示例相比会略有不同,但足够实现我们的目的。
每当我们连接到活动进程时,都会收到进程创建事件,它允许调试器通过提供一组事件(例如:创建进程、创建线程、加载模块)来同步其状态。如果我们直接在调试器下启动进程,就会收到这些事件。事实上,NtDebugActiveProcess调用DbgkpPostFakeProcessCreateMessages方法,它给出了调试事件的状态。
关于CREATE_PROCESS_DEBUG_INFO,存在一个值得关注的地方,我们会注意到HANDLE函数、hFile、hProcess和hThread对应于进程可执行文件的句柄、被调试进程的句柄以及最后一个初始线程的句柄。DbgkpPostFakeProcessCreateMessages检查中,将会捕获对象,但不会生成句柄。句柄的创建则是在NtWaitForDebugEvent系统调用内部实现的,特别是在DbgkpOpenHandles中。我们在该函数的以下代码片段中发现了问题的所在:
NTSTATUS DbgkpOpenHandles(PDEBUG_EVENT Event, EPROCESS DebugeeProcess, PETHREAD DebugeeThread) { // Handle other event types first... if (Event->DebugEventCode == CREATE_PROCESS_DEBUG_EVENT) { if (ObOpenObjectByPointer(DebugeeThread, 0, NULL, THREAD_ALL_ACCESS, PsThreadType, KernelMode, &Event->CreateProcess.hThread) < 0) { Event->CreateProcess.hThread = NULL; } if (ObOpenObjectByPointer(DebugeeProcess, 0, 0, PROCESS_ALL_ACCESS, PsProcessType, KernelMode, &Event->CreateProcess.hProcess < 0) { Event->CreateProcess.hThread = NULL; } ObDuplicateObject(PsGetCurrentProcess(), Event->CreateProcess.hFile, PsGetCurrentProcess(), &Event->CreateProcess.hFile, 0, 0, DUPLICATE_SAME_ACCESS, KernelMode); } // ... }
这段代码使用ObOpenObjectByPointer API将debugee进程和线程对象转换回句柄,这本身没有问题。但问题在于,调用API时,访问模式被设置为内核模式,这意味着该调用不会执行任何访问检查,这一点上存在一些问题。并且,再加上没有对PROCESS_SUSPEND_RESUME请求额外的权限,共同导致了这一机制存在较大的问题。该代码能够有效地将NtWaitForDebugEvent调用者的所有访问权限授予调试的目标进程。
这种行为的结果将导致一个具有PROCESS_SUSPEND_RESUME的进程句柄,即使在对象未授予其调用者访问权限的情况下,也可以用来获得对进程和初始线程的完全访问权限。但也许有人会说,即使将调试器附加到进程中,那么后面能实现什么样的利用呢?实际上,调用方需要在连接调试器之前打开合适的进程和线程句柄,并使用它们来访问目标。或者,如果内核必须创建新句柄,至少要对它们进行访问检查。这样一来,就引出了我们的第一个漏洞利用技巧。
漏洞利用技巧(1)
如果存在具有PROCESS_SUSPEND_RESUME访问权限的进程句柄,就可以通过调试器API将其转换为对进程具有完全访问权限的句柄。
由于PROCESS_SUSPEND_RESUME会被视为是对进程的写访问权限,因此我们可能很少会遇到此类漏洞。任何会泄漏对特权进程访问权限的地方也往往会泄漏其他写访问,例如PROCESS_CREATE_THREAD或PROCESS_VM_WRITE,这样游戏就结束了。为了证明这一漏洞利用技巧,下面是一个简单的PowerShell脚本,它将接受进程ID,打开PROCESS_SUSPEND_RESUME的进程,将其附加到调试器,然后窃取句柄,并返回完整的访问句柄。
param( [Parameter(Mandatory)] [int]$ProcessId ) # Get a privileged process handle with only PROCESS_SUSPEND_RESUME. Import-Module NtObjectManager Use-NtObject($dbg = New-NtDebug -ProcessId $ProcessId) { Use-NtObject($e = Start-NtDebugWait $dbg -Infinite) { $dbg.Detach($ProcessId) [NtApiDotNet.NtProcess]::DuplicateFrom($e.Process, -1) } }
那么,CREATE_PROCESS_DEBUG_INFO中的第三个句柄,也就是进程可执行文件的句柄会如何呢?它具有不同的行为,而不仅仅是打开它复制现有句柄的原始指针。如果我们查看代码,会发现它似乎与当前调用者的进程重复,并且再次返回。如果它已经在调试器进程中,为什么还需要进行复制呢?关键在于最后一个参数,它会再次传递内核模式,这意味着ObDuplicateObject实际上会复制当前进程的内核句柄。附加到进程时,会打开文件句柄,并使用以下代码:
HANDLE DbgkpSectionToFileHandle(PSECTION Section) { HANDLE FileHandle; UNICODE_STRING Name; OBJECT_ATTRIBUTES ObjectAttributes; IO_STATUS_BLOCK IoStatusBlock; MmGetFileNameForSection(Section, &Name); InitializeObjectAttributes(&ObjectAttributes, &Name, OBJ_CASE_INSENSITIVE | OBJ_FORCE_ACCESS_CHECK | OBJ_KERNEL_HANDLE); ZwOpenFile(&FileHandle, GENERIC_READ | SYNCHRONIZE, &ObjectAttributes, &IoStatusBlock, FILE_SHARE_ALL, FILE_SYNCHRONOUS_IO_NONALERT); return FileHandle; }
该代码会小心地将OBJ_FORCE_ACCESS_CHECK传递给文件打开调用,以确保不会让调试器访问人以文件。并且,会存储文件句柄,以便稍后调用NtWaitForDebugEvent进行回收。由此,我们发现了第二个漏洞利用技巧。
漏洞利用技巧(2)
使用任意内核句柄关闭的漏洞,我们可以窃取内核句柄。
这种漏洞利用技巧背后的基本原理在于,一旦捕获到句柄,就会无限期地存储,至少在进程仍然存在时始终会保存。我们可以在任意时间点对句柄进行检索。这样一来,就为我们提供了一个更大的时间窗口来利用句柄关闭漏洞。这类漏洞的一个例子是我在Novell驱动程序中找到的Issue 274。在这种情况下,驱动程序在编写日志条目时,不会检查ZwOpenFile是否成功,因此在调用ZwClose时会重用存储在栈中的句柄值。这样一来,将会导致人以内核句柄被关闭。要使用调试器来实现Novell漏洞的利用,我们需要执行以下操作:
1、生成日志条目,创建随后关闭的内核句柄,此时栈中的值不会被覆盖。
2、调试进程,以获取已经分配的文件句柄。句柄分配是可以预测的,因此很有可能将相同的句柄值重用为与漏洞一起使用的句柄值。
3、触发处理结束漏洞,在这种情况下,它将关闭现在由调试器分配的栈上的现有值,从而产生悬空的(Dangling)句柄值。
4、在内核中,使用代码以再次重新分配现在未使用的句柄值。例如,通过NtCreateLowBoxToken调用的SepSetTokenCachedHandles可能会复制其他内核句柄。需要说明的是,由于我报告了Issue 483,所以会对使用的句柄进行相当严格的检查。
5、获取调试器以返回句柄。
尽管它们可能非常少见,但句柄关闭的漏洞确实存在。此外,我们在此过程中必须要小心,因为通常情况下,关闭已经关闭的内核句柄,可能会导致漏洞检查。
总结
用户模式调试器的行为,会对原本的功能设计产生意外的后果。我在本文中描述的任何内容都不属于安全漏洞,但这样的行为非常有趣,并且值得关注可以实现漏洞利用的具体情况。
发表评论