One I/O Ring to Rule Them All: Windows 11 完整读写漏洞利用原语
本文将介绍我在TyphoonCon 2022上分享的后利用技术。对演讲本身感兴趣的读者,可以在此处找到录制视频(待发布)。
这项技术是Windows 11 22H2+独有的后利用原语——不涉及0-day漏洞。相反,它提供了一种方法,可以将Windows内核中的任意写入(甚至任意递增)漏洞转化为对内核内存的完整读写能力。
背景
随着Windows版本的不断更新,内核利用(以及一般利用)变得越来越困难。驱动程序签名增强(DSE)使攻击者难以加载未签名的驱动程序,而后续的HVCI(Hypervisor-Protected Code Integrity)则完全阻止了这一行为,并增加了驱动程序阻止列表,防止攻击者加载已签名的易受攻击驱动程序。SMEP(Supervisor Mode Execution Protection)和KCFG(Kernel Control Flow Guard)缓解了通过函数指针覆盖进行的代码重定向攻击,KCET(Kernel CET)也使ROP(Return-Oriented Programming)变得不可能。其他VBS(Virtualization-Based Security)功能如KDP(Kernel Data Protection)保护内核数据,因此常见目标如g_CiOptions不再能被攻击者修改。此外,还有Patch Guard和Secure Kernel Patch Guard来验证内核及其许多组件的完整性。
在所有这些现有缓解措施下,仅发现用户到内核的漏洞不再保证成功利用。在启用所有缓解措施的Windows 11中,几乎不可能实现Ring 0代码执行。然而,基于数据的攻击仍然是一个可行的解决方案。
一种已知的纯数据攻击技术是在用户模式下创建一个伪造的内核模式结构,然后通过写-写-哪里漏洞(或任何其他可以实现此目的的漏洞类型)诱使内核使用它。内核会将此结构视为有效的内核数据,允许攻击者通过操纵结构中的数据来实现权限提升,从而操纵基于该数据的内核操作。有许多使用此技术的例子,以不同方式应用。例如,J00ru的博客文章演示了使用伪造的令牌表将差一错误转化为任意写入,并随后使用它在ring 0中运行shellcode。许多其他例子利用不同的Win32k对象来实现任意读取、写入或两者兼有。其中一些技术已被Microsoft缓解,其他已被安全产品已知并猎杀,还有一些仍然可用并很可能在野外使用。
在本文中,我想再添加一种技术——使用I/O环预注册缓冲区来创建读写原语,使用1-2次任意内核写入(或递增)。该技术使用一种新的对象类型,目前对安全产品的可见性非常有限,并可能被忽略一段时间。该方法使用起来非常简单——一旦你理解了I/O环的基本机制。
I/O环
我已经写了几篇关于I/O环的博客文章(和一次演讲),因此我将只介绍基本思想和与此技术相关的部分。任何想了解更多信息的人可以阅读之前的文章或观看P99 Conf的演讲。
简而言之,I/O环是一种新的异步I/O机制,允许应用程序排队多达0x10000个I/O操作,并使用单个API调用一次性提交它们。该机制模仿了Linux的io_uring,因此两者的设计非常相似。目前,I/O环还不支持所有可能的I/O操作。Windows 11 22H2中可用的操作包括读取、写入、刷新和取消。请求的操作被写入提交队列(Submission Queue),然后一次性提交。内核处理请求并将状态代码写入完成队列(Completion Queue)——两个队列都在用户模式和内核模式可访问的共享内存区域中,允许共享数据而无需多次系统调用的开销。
除了可用的I/O操作外,应用程序还可以排队两种I/O环独有的操作类型:预注册缓冲区和预注册文件。这些选项允许应用程序提前打开所有文件句柄或创建所有输入/输出缓冲区,注册它们,然后通过索引在通过I/O环排队的I/O操作中引用它们。当内核处理使用预注册文件句柄或缓冲区的条目时,它会从预注册数组中获取请求的句柄/缓冲区,并将其传递给I/O管理器进行正常处理。
对于视觉学习者,这里有一个使用预注册文件句柄和缓冲区的队列条目示例:
准备提交给内核的提交队列可能如下所示:
本文讨论的利用技术利用了预注册缓冲区数组,因此让我们更详细地了解一下:
注册缓冲区
正如我提到的,应用程序可以执行的操作之一是为其未来的I/O操作分配所有缓冲区,然后将它们注册到I/O环。预注册缓冲区通过I/O环对象引用:
|
|
当请求被处理时,会发生以下事情:
- IoRing->RegBuffers和IoRing->RegBuffersCount被设置为零。
- 内核验证Sqe->RegisterBuffers.Buffers和Sqe->RegisterBuffers.Count都不为零。
- 如果请求来自用户模式,则探测数组以验证其完全在用户模式地址空间中。数组大小最多可为sizeof(ULONG)。
- 如果环之前有预注册缓冲区数组且新缓冲区的大小与旧缓冲区相同,则旧缓冲区数组被放回环中,新缓冲区被忽略。
- 如果之前的检查通过且要使用新缓冲区数组,则进行新的分页池分配——这将用于从用户模式数组复制数据,并由IoRing->RegBuffers指向。
- 如果之前有由I/O环指向的注册缓冲区数组,它会被复制到新的内核数组中。任何新缓冲区将在同一分配中添加到旧缓冲区之后。
- 从用户模式发送的数组中的每个条目都会被探测以验证请求的缓冲区完全在用户模式中,然后复制到内核数组。
- 旧的内核数组(如果存在)被释放,操作完成。
整个过程是安全的——数据仅从用户模式读取一次,经过正确探测和验证以避免溢出和意外读取或写入内核地址。任何未来使用这些缓冲区都将从内核缓冲区获取。
但如果我们已经有一个任意内核写入漏洞呢?
在这种情况下,我们可以覆盖单个指针——IoRing->RegBuffers,使其指向一个完全受我们控制的伪造缓冲区。我们可以用内核模式地址填充它,并在I/O操作中将这些地址用作缓冲区。当缓冲区通过索引引用时,它们不会被探测——内核假设如果缓冲区在注册时是安全的,然后复制到内核分配中,那么它们在作为操作的一部分引用时仍然安全。
这意味着通过一次任意写入和一个伪造缓冲区数组,我们可以通过读取和写入操作获得对内核地址空间的完全控制。
原语
一旦IoRing->RegBuffers指向伪造的用户控制数组,我们就可以使用正常的I/O环操作通过指定伪造数组中的索引作为缓冲区来生成对任意地址的内核读取和写入:
- 读取操作 + 内核地址:内核将从我们选择的文件“读取”到指定的内核地址,导致任意写入。
- 写入操作 + 内核地址:内核将指定地址中的数据“写入”到我们选择的文件中,导致任意读取。
最初我的原语依赖于文件进行读写,但Alex建议使用命名管道,这更酷且更不显眼,不会在磁盘上留下痕迹。因此,本文的其余部分和利用代码将使用命名管道。
如你所见,技术本身非常简单。事实上,它甚至不需要使用任何(嗯,几乎)未记录的API或秘密数据结构。它使用Win32 API和ntoskrnl.exe公共符号中可用的结构。利用原语涉及以下步骤:
- 使用CreateNamedPipe创建两个命名管道:一个用于任意内核写入的输入,另一个用于任意内核读取的输出。至少用作输入的管道应使用PIPE_ACCESS_DUPLEX标志创建以允许读写。为方便起见,我选择两者都使用PIPE_ACCESS_DUPLEX创建。
- 使用CreateFile为两个管道打开客户端句柄,两者都具有读写权限。
- 创建I/O环:这可以通过CreateIoRing API完成。
- 在堆中分配伪造缓冲区数组:从官方22H2版本开始,注册缓冲区数组不再是平面数组,而是IOP_MC_BUFFER_ENTRY结构的数组,因此这变得稍微复杂一些。
- 找到新创建的I/O环对象的地址:由于I/O环使用新的对象类型IORING_OBJECT,我们可以通过众所周知的KASLR绕过技术泄漏其地址。使用SystemHandleInformation的NtQuerySystemInformation泄漏对象的内核地址,包括我们的新I/O环对象。幸运的是,IORING_OBJECT的内部结构在公共符号中,因此无需逆向工程结构来找到RegBuffers的偏移量。我们将两者相加以获得任意写入的目标。
- 不幸的是,此API以及许多其他KASLR绕过只能由具有中等IL或更高权限的进程使用,因此低IL进程、沙盒进程和浏览器无法使用它,必须找到其他方法。
- 使用你喜欢的任意写入漏洞将IoRing->RegBuffers覆盖为伪造用户模式数组的地址。请注意,如果你之前没有注册有效的缓冲区数组,你还必须将IoRing->RegBuffersCount覆盖为非零值。
- 用内核指针填充伪造缓冲区数组以进行读取或写入:为此你可能需要其他KASLR绕过来找到目标地址。你可以使用带有SystemModuleInformation类的NtQuerySystemInformation来找到内核模块的基地址,使用与之前相同的技术找到对象的内核地址,或使用I/O环本身可用的指向分页池中数据结构的指针。
- 通过BuildIoRingReadFile和BuildIoRingWriteFile在I/O环中排队读取和写入操作。
使用这种方法,任意读取和写入不受指针大小的限制,就像许多其他方法一样,而是可以大到sizeof(ULONG),同时读取或写入许多页的内核数据。
清理
此技术需要最少的清理:所有需要的是在关闭I/O环对象的句柄之前将IoRing->RegBuffers设置为零。只要指针为零,即使IoRing->RegBuffersCount非零,内核也不会尝试释放任何东西。
如果你选择先注册有效的缓冲区数组然后覆盖I/O环对象中的现有指针,清理会变得稍微复杂——在这种情况下,已经分配了一个内核缓冲区,这也在EPROCESS对象中添加了一个引用计数。在这种情况下,需要在进程退出之前递减EPROCESS RefCount,以避免留下陈旧的进程。幸运的是,使用我们现有的技术再进行一次任意读取+写入很容易做到这一点。
任意递增
几年前,我发布了一系列博客讨论CVE-2020-1034——EtwpNotifyGuid中的一个任意递增漏洞。当时,我专注于利用此漏洞的挑战,并使用它来递增进程的令牌权限——一种非常著名的权限提升技术。此方法有效,尽管可以使用不同的工具实时或回溯检测。安全供应商 well aware of this technique,许多已经检测到它。
该项目使我对利用该特定漏洞类——内核地址的任意递增的其他方法感兴趣,因此我很高兴找到了一个最终适合的后利用技术。使用我在这里介绍的方法,你可以使用任意递增将IoRing->RegBuffers从0递增到用户模式地址,如0x1000000(不需要0x1000000递增,只需将第三个字节递增一次)并将IoRing->RegBuffersCount从0递增到1或0x100(或更多)。这确实需要你触发漏洞两次以创建技术,但我建议无论如何都这样做,以避免覆盖现有指针时所需的额外清理。
取证和检测
此后利用技术的可见性非常低,并且留下的取证痕迹很少:I/O环通过ETW几乎不可见,除了创建时,并且该技术在内存中不留取证痕迹。此技术唯一对安全产品可见的部分是命名管道操作,对使用文件系统过滤器驱动程序的安全产品可见(大多数确实如此)。然而,这些管道是本地的,并且不用于任何看起来太可疑的事情——它们读取和写入少量数据,没有特定格式,因此不太可能被标记为可疑。
便携功能 = 便携利用?
Windows上的I/O环模仿了Linux的io_uring,并共享许多相同的功能,这一点也不例外。Linux io_uring也允许注册缓冲区或文件句柄,并且注册缓冲区的处理方式非常相似,并存储在环的user_bufs字段中。这意味着相同的利用技术也应该在Linux上工作(尽管我个人没有测试过)。
在这种情况下,两个系统之间的主要区别是缓解措施:虽然在Windows上很难缓解此技术,但Linux有一个缓解措施使阻止此技术(至少在其当前形式下)变得简单:SMAP。此缓解措施防止以内核模式权限访问用户模式地址,阻止任何涉及在用户模式下伪造内核结构的利用技术。不幸的是,由于Windows系统的基本设计,SMAP不太可能成为可用的缓解措施,但它自2012年以来已在Linux上可用并使用。
当然,仍有方法绕过SMAP,例如塑造内核池分配以用作伪造缓冲区数组而不是用户模式地址,或编辑包含伪造数组的用户模式页的PTE,但基本的利用原语在支持SMAP的系统上不起作用。
22H2更改
官方22H2版本引入了一个影响此技术的更改,但仅轻微影响。自Windows 11 build 22610(因此比官方22H2版本早几个版本)以来,内核中的缓冲区数组不再是地址和长度的平面数组,而是指向新数据结构IOP_MC_BUFFER_ENTRY的指针数组:
|
|
此数据结构作为在同一版本中添加的MDL缓存功能的一部分使用。它看起来复杂且可怕,但在我们的用例中,这些字段大多从未使用过,可以忽略。我们仍然有相同的Address和Length字段,我们需要这些字段才能使我们的技术工作,并且为了与新功能的要求兼容,我们还需要在字段Type、Size、AccessMode和ReferenceCount中硬编码一些值。
为了使我们的技术适应这一新添加,我们的代码需要进行以下更改:
- 分配一个伪造缓冲区数组,大小为sizeof(PVOID) * NumberOfEntries。
- 为每个伪造缓冲区分配一个IOP_MC_BUFFER_ENTRY结构,并将指针放入伪造缓冲区数组中。将结构清零,然后设置以下字段:
- mcBufferEntry->Address = TargetAddress;
- mcBufferEntry->Length = Length;
- mcBufferEntry->Type = 0xc02;
- mcBufferEntry->Size = 0x80; // 0x20 * (numberOfPagesInBuffer + 3)
- mcBufferEntry->AccessMode = 1;
- mcBufferEntry->ReferenceCount = 1;
PoC
我将我的PoC上传到这里。它从22H2预览版本开始工作(最小支持版本——在此版本之前I/O环还不支持写入操作)直到最新的Windows预览版本(截至今天为25415)。对于我的任意写入/递增漏洞,我使用了HEVD驱动程序,重新编译以支持任意递增。PoC支持两个选项,但如果你使用最新的HEVD版本,只有任意写入选项会工作。
对于任意读取目标,我使用了ntoskrnl.exe数据部分的一页——由于懒惰,该部分的偏移量是硬编码的,因此当该偏移量更改时可能会自发中断。