深入分析ETW机制中的信息泄露漏洞

 

在本文中,我们将为读者详细介绍在研究ETW内部运行机制过程中发现的一个信息泄露漏洞:利用该漏洞,任何用户都能获得NonPaged池的大概位置。同时,我们还提供了相应的PoC。

 

ETW的工作原理

关于ETW通知机制,人们通常都有一个很大的误解,那就是它们都是异步的。当然,对于大多数ETW通知来说,它们确实是异步的。然而,在上一篇文章中,我们曾经介绍了一个安全漏洞,这个漏洞就是对ETWP_NOTIFICATION_HEADER结构体中ReplyRequested字段的处理不当所致。这个字段的存在,意味着我们可以响应一个ETW事件。但是从来没有人告诉过你可以响应ETW通知,那怎么可能呢?

正常情况下,ETW的工作方式与我们了解到的一样——所有的Windows provider都是如此,我可以找到的其他ETW provider也是如此。但是,当有人用ReplyRequested = 1通知ETW provider时,就会生成一个“秘密设置”。然后,正如我们在之前的文章中所看到的,通知会被添加到响应队列中,并等待响应。记住,任何时刻队列中只能有4个的通知在等待响应。当这种情况发生时,为该provider注册的任何进程都会收到其注册回调的通知,并有机会使用EtwReplyNotification响应该通知。当有代码响应该通知时,原来的通知会从队列中移除,而响应通知会被添加到响应队列中。

到目前为止,我见过的唯一会向通知发送响应的情况是:在启用GUID后立即向通知发送响应——sechost!EnableTraceEx2 (这是注册provider和启用跟踪的标准方式)会调用ntdll!EtwSendNotification函数,其中EnableNotificationPacket->DataBlockHeader.ReplyRequested被设置为1。这将创建一个EtwRegistration对象,所以,在返回Sechost之前,Ntdll立即将NotificationHeader->NotificationType设置为EtwNotificationTypeNoReply来响应该通知,只以便将其从通知队列中删除。

具体来说,在这种情况下,发生了一些更复杂的事情。尽管Ntdll启用了GUID,但它并不是注册实例的“所有者”,因此没有注册的回调(因为这属于注册provider的代码)。然而,Ntdll仍然需要知道内核何时启用provider,以便将响应通知插入队列——它不能指望调用者知道需要进行这种处理。为此,它使用了一个技巧。

当EtwRegisterProvider函数被调用时,它会调用EtwpRegisterProvider函数。而这个函数第一次被调用时,它会调用EtwpRegisterTpNotificationOnce函数。

关于等待和线程池的内部细节这里就不多说了,简单地说,这个函数的作用是通过回调函数EtwpNotificationThread创建一个事件,然后调用NtTraceControl函数,其Operation值为27——这个值的具体含义,还没有公开的文档可查。从内核的角度来看,给这个值命名并不太难:

我把这个操作命名为EtwAddNotificationEvent函数。

EtwpAddNotificationEvent是一个非常简单的函数:它接收一个事件句柄,抓取事件对象,并将当前进程EPROCESS中的EventDataSource->NotificationEvent设置为事件(如果这是一个WoW64进程,则为NotificationEventWow64)。由于这个字段是一个指针,而不是一个列表,所以,一次只能包含一个事件。如果这个字段没有设置为0,那么该值就不会被设置,调用者将收到STATUS_ALREADY_REGISTERED作为响应状态。

然后,在EtwpQueueNotification中,当一个通知被添加至进程的通知队列中后,会立即发送该事件的信号:

向事件发送信号会导致EtwpNotificationCallback函数被调用,因为它被注册为等待这个事件,所以从某种意义上讲,它是一个ETW通知回调函数,每当进程收到ETW通知时,就会通知它。然而,这个函数并不是一个真正的ETW通知回调函数,所以它无法接收通知并作为其参数,因此,它必须以某种方式设法获取通知才能对其进行响应。幸运的是,它有一个方法可以做到这一点。

EtwpNotificationThread做的第一件事,就是再次调用NtTraceEvent,这次的操作编号为16,对应于EtwReceiveNotification。这个操作会导致调用EtwpReceiveNotification,它为进程选择排在最前面的通知(并匹配进程的WoW64状态)并将其返回。这个操作不需要输入参数——它只是返回排在最前面的通知。这就为EtwpNotificationThread提供了它所需要的所有信息,使它能够安静地响应排在最后面的通知,而不会打扰不知情的调用者,后者只是要求它注册一个provider。在回复之后,该事件又被设置为等待状态,以等待下一个通知的到来。

上面,我们已经介绍了足够的背景知识,下面我们开始考察信息泄露漏洞本身。

 

信息泄露

这个安全问题其实位于上面讨论的最后一部分,即返回排在最后面的通知的过程中。上一篇文章中说过,当一个GUID收到通知,并且通知头部的ReplyRequested == 1时,这就会导致创建一个内核对象,这个对象会被放在通知的ReplyObject字段中,随后被放入通知队列中。而这个结构体与EtwReceiveNotification操作中使用NtTraceControl可以检索到的结构体是一样的……这是否意味着我们通过适当的参数调用NtTraceControl就可以得到一个被释放的内核指针呢?

事实上并非如此。准确地说,通过这种方式得到的只能算是“半个”内核指针。微软并没有完全忽视这样一个事实,即将内核指针重新返回给用户模式调用者是一个坏主意,尽管在其他许多情况下他们就是这么做的。ETWP_NOTIFICATION_HEADER中的ReplyObject字段与ReplyHandle和RegIndex处于同一个联合体中。而在将数据复制到用户模式缓冲区后,他们将设置RegIndex的值,这将覆盖处于同一联合体中的内核指针:

这段代码唯一没有考虑到的事情,就是ReplyObject和RegIndex的类型是不一样的:ReplyObejct是指针类型(在x64上占用8个字节),而RegIndex是ULONG类型(在x64上占用4个字节)。所以,设置RegIndex只是删除指针的后半部分,留下的前半部分将会返回给调用者。

触发这个过程并非难事,具体包括三个步骤:

  1. 注册一个provider。
  2. 将ReplyObject为内核对象的通知插入队列——为此,可以调用NtTraceControl函数,其中,operation == EtwSendDataBlock,而ReplyRequested == TRUE。
  3. 调用函数NtTraceControl,其中operation == EtwReceiveNotification,这样就能得到“半个”内核指针。

的确,内核地址的前半部分提供的信息并不是很多,但它仍然可以让调用者更好地猜测NonPagedPool(那些对象的分配位置)的位置。事实上,由于NonPagedPool的大小是16TB(或0x100000000000字节),因此,这个漏洞能告诉我们NonPaged池的确切位置,我们可以在调试器中进行验证:

!vm 21

...

System RegionBase AddressNumberOfBytes

SecureNonPagedPool: ffff8380000000008000000000

KernelShadowStacks: ffff8880000000008000000000

PagedPool: ffff8a0000000000100000000000

NonPagedPool: ffff9d0000000000100000000000

SystemCache: ffffb00000000000100000000000

SystemPtes: ffffc40000000000100000000000

UltraZero: ffffd40000000000100000000000

Session: ffffe400000000008000000000

PfnDatabase: ffffe78000000000c8000000000

PageTables: fffff400000000008000000000

SystemImages: fffff800000000008000000000

Cfg: fffffaf0ea2331d028000000000

HyperSpace: fffffd000000000010000000000

KernelStacks: fffffe000000000010000000000

实际上,这个漏洞可以被任何用户触发,包括Low IL和AppContainer;由于大多数经典的信息泄露都已经不起作用了,因此,这个漏洞也许能提供一些帮助,即使是有限的帮助。

我相信,当这段代码被引入时,它是完全安全的——这些领域的代码是相当古老的,而且鲜有改变。这段代码可能是在x64之前引入的,当时指针的大小和ULONG的大小是一样的,所以设置RegIndex确实会覆盖整个对象地址。当x64改变了指针的大小后,这段代码就被遗留了下来,但是一直没有做出相应的更新,所以就出现了这个漏洞。

 

结束语

读到这里,读者可能不禁要问,在其他连微软都忘了的古老代码中,是否也存在类似的错误呢?

别猜了,赶紧动手审查代码吧!

如果您想查看触发这个漏洞的相关代码,可以在https://github.com/yardenshafir/CVE-2020-1034/tree/main/pool_address_leak页面找到它们。

Exploiting a “Simple” Vulnerability – Part 1.5 – The Info Leak

 

(完)