Windows 11 I/O Ring 漏洞利用原语:实现内核任意读写

本文详细介绍了在Windows 11 22H2+中利用I/O Ring的预注册缓冲区功能,通过任意内核写入或增量漏洞实现内核内存的完全读写操作的技术细节和利用步骤。

One I/O Ring to Rule Them All: Windows 11 上的完整读写漏洞利用原语

本文将介绍我在 TyphoonCon 2022 上分享的后渗透技术。对于对演讲本身感兴趣的读者,我会在录制可用后在此处链接。

这项技术是 Windows 11 22H2+ 独有的后渗透原语——这里没有 0-day 漏洞。相反,这是一种方法,可以将 Windows 内核中的任意写入甚至任意增量漏洞转化为内核内存的完全读写。

背景

随着每个新版本的发布,Windows 上的内核漏洞利用(以及一般漏洞利用)变得越来越困难。驱动程序签名强制执行(Driver Signature Enforcement)使攻击者更难加载未签名的驱动程序,而后来 HVCI 使其完全不可能——再加上驱动程序阻止列表的额外困难,阻止攻击者加载签名的易受攻击驱动程序。SMEP 和 KCFG 通过函数指针覆盖来缓解代码重定向,KCET 也使 ROP 变得不可能。其他 VBS 功能(如 KDP)保护内核数据,因此常见目标(如 g_CiOptions)不再能被攻击者修改。除此之外,还有 Patch Guard 和 Secure Kernel Patch Guard,它们验证内核及其许多组件的完整性。

有了所有这些现有的缓解措施,仅仅找到一个用户到内核的漏洞不再保证成功的利用。在启用所有缓解措施的 Windows 11 中,几乎不可能实现 Ring 0 代码执行。然而,基于数据的攻击仍然是一个可行的解决方案。

一种已知的纯数据攻击技术是在用户模式下创建一个伪造的内核模式结构,然后通过写-哪里漏洞(或任何其他可以实现该目标的漏洞类型)诱使内核使用它。内核会将此结构视为有效的内核数据,允许攻击者通过操纵结构中的数据来实现权限提升,从而操纵基于该数据完成的内核操作。有许多这种技术的例子,以不同的方式使用。例如,J00ru 的这篇博客文章演示了使用伪造的令牌表将一个 off-by-one 漏洞转化为任意写入,然后使用该漏洞在 ring 0 中运行 shellcode。许多其他例子利用不同的 Win32k 对象来实现任意读取、写入或两者兼而有之。其中一些技术已经被微软缓解,其他一些已经被安全产品已知并追捕,还有一些仍然可用,并且很可能在野外使用。

在这篇文章中,我想再添加一种技术——使用 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 中可用的操作是读取、写入、刷新和取消。请求的操作被写入提交队列,然后一起提交。内核处理请求并将状态代码写入完成队列——两个队列都在用户模式和内核模式都可访问的共享内存区域中,允许共享数据而无需多次系统调用的开销。

除了可用的 I/O 操作外,应用程序还可以排队两种 I/O 环独有的操作类型:预注册缓冲区和预注册文件。这些选项允许应用程序提前打开所有文件句柄或创建所有输入/输出缓冲区,注册它们,然后通过索引在通过 I/O 环排队的 I/O 操作中引用它们。当内核处理使用预注册文件句柄或缓冲区的条目时,它会从预注册数组中获取请求的句柄/缓冲区,并将其传递给 I/O 管理器,在那里正常处理。

对于视觉学习者,这里有一个使用预注册文件句柄和缓冲区的队列条目示例:

准备提交给内核的提交队列可能看起来像这样:

这里讨论的利用技术利用了预注册缓冲区数组,所以让我们更详细地了解一下:

注册缓冲区

正如我提到的,应用程序可以做的操作之一是为其未来的 I/O 操作分配所有缓冲区,然后将它们注册到 I/O 环。预注册缓冲区通过 I/O 环对象引用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef struct _IORING_OBJECT
{
    USHORT Type;
    USHORT Size;
    NT_IORING_INFO UserInfo;
    PVOID Section;
    PNT_IORING_SUBMISSION_QUEUE SubmissionQueue;
    PMDL CompletionQueueMdl;
    PNT_IORING_COMPLETION_QUEUE CompletionQueue;
    ULONG64 ViewSize;
    ULONG InSubmit;
    ULONG64 CompletionLock;
    ULONG64 SubmitCount;
    ULONG64 CompletionCount;
    ULONG64 CompletionWaitUntil;
    KEVENT CompletionEvent;
    UCHAR SignalCompletionEvent;
    PKEVENT CompletionUserEvent;
    ULONG RegBuffersCount;
    PVOID RegBuffers;
    ULONG RegFilesCount;
    PVOID* RegFiles;
} IORING_OBJECT, *PIORING_OBJECT;

当请求被处理时,会发生以下事情:

  1. IoRing->RegBuffers 和 IoRing->RegBuffersCount 被设置为零。
  2. 内核验证 Sqe->RegisterBuffers.Buffers 和 Sqe->RegisterBuffers.Count 都不为零。
  3. 如果请求来自用户模式,则探测数组以验证它完全在用户模式地址空间中。数组大小最多可以为 sizeof(ULONG)。
  4. 如果环之前有一个预注册缓冲区数组且新缓冲区的大小与旧缓冲区的大小相同,则旧缓冲区数组被放回环中,新缓冲区被忽略。
  5. 如果之前的检查通过且要使用新缓冲区数组,则进行新的分页池分配——这将用于从用户模式数组复制数据,并由 IoRing->RegBuffers 指向。
  6. 如果之前有一个由 I/O 环指向的注册缓冲区数组,它会被复制到新的内核数组中。任何新缓冲区将在同一分配中添加在旧缓冲区之后。
  7. 从用户模式发送的数组中的每个条目都会被探测以验证请求的缓冲区完全在用户模式中,然后被复制到内核数组。
  8. 旧的内核数组(如果存在)被释放,操作完成。

整个过程是安全的——数据只从用户模式读取一次,被正确探测和验证以避免溢出和意外读取或写入内核地址。任何将来使用这些缓冲区都将从内核缓冲区获取它们。

但是,如果我们已经有一个任意的内核写入漏洞呢?

在这种情况下,我们可以覆盖单个指针——IoRing->RegBuffers,使其指向一个完全在我们控制下的伪造缓冲区。我们可以用内核模式地址填充它,并在 I/O 操作中将这些地址用作缓冲区。当缓冲区通过索引引用时,它们不会被探测——内核假设如果缓冲区在注册时是安全的,然后被复制到内核分配中,那么它们在作为操作的一部分被引用时仍然是安全的。

这意味着,通过一次任意写入和一个伪造的缓冲区数组,我们可以通过读取和写入操作获得对内核地址空间的完全控制。

原语

一旦 IoRing->RegBuffers 指向伪造的、用户控制的数组,我们就可以使用正常的 I/O 环操作通过指定我们伪造数组中的索引作为缓冲区来生成对我们想要的任何地址的内核读取和写入:

  • 读取操作 + 内核地址:内核将从我们选择的文件“读取”到指定的内核地址,导致任意写入。
  • 写入操作 + 内核地址:内核将指定地址中的数据“写入”到我们选择的文件中,导致任意读取。

最初我的原语依赖于文件进行读取和写入,但 Alex 建议使用命名管道,这更酷且更不显眼,不会在磁盘上留下痕迹。因此,文章的其余部分 + 漏洞利用代码将使用命名管道。

正如你所看到的,技术本身非常简单。如此简单,以至于甚至不需要使用任何(嗯,几乎)未记录的 API 或秘密数据结构。它使用 Win32 API 和 ntoskrnl.exe 公共符号中可用的结构。漏洞利用原语涉及以下步骤:

  1. 使用 CreateNamedPipe 创建两个命名管道:一个将用于任意内核写入的输入,另一个用于任意内核读取的输出。至少用于输入的管道应使用标志 PIPE_ACCESS_DUPLEX 创建以允许读取和写入。为方便起见,我选择两者都使用 PIPE_ACCESS_DUPLEX 创建。
  2. 使用 CreateFile 为两个管道打开客户端句柄,两者都具有读取和写入权限。
  3. 创建一个 I/O 环:这可以通过 CreateIoRing API 完成。
  4. 在堆中分配一个伪造的缓冲区数组:从官方 22H2 版本开始,注册缓冲区数组不再是一个平面数组,而是一个 IOP_MC_BUFFER_ENTRY 结构数组,因此这变得稍微复杂一些。
  5. 找到新创建的 I/O 环对象的地址:由于 I/O 环使用新的对象类型 IORING_OBJECT,我们可以通过众所周知的 KASLR 绕过技术泄漏其地址。使用 SystemHandleInformation 的 NtQuerySystemInformation 泄漏对象的内核地址,包括我们的新 I/O 环对象。幸运的是,IORING_OBJECT 的内部结构在公共符号中,因此无需反向工程结构来找到 RegBuffers 的偏移量。我们将两者相加以获得我们任意写入的目标。
  6. 不幸的是,此 API 以及许多其他 KASLR 绕过只能由具有中等 IL 或更高权限的进程使用,因此低 IL 进程、沙盒进程和浏览器无法使用它,必须找到不同的方法。
  7. 使用你首选的任意写入漏洞将 IoRing->RegBuffers 覆盖为伪造的用户模式数组的地址。请注意,如果你之前没有注册有效的缓冲区数组,你还必须覆盖 IoRing->RegBuffersCount 以具有非零值。
  8. 用内核指针填充伪造的缓冲区数组以读取或写入:为此,你可能需要其他 KASLR 绕过来找到你的目标地址。你可以使用带有 SystemModuleInformation 类的 NtQuerySystemInformation 来查找内核模块的基地址,使用与之前相同的技术来查找对象的内核地址,或使用 I/O 环本身内部可用的指针,这些指针指向分页池中的数据结构。
  9. 通过 BuildIoRingReadFile 和 BuildIoRingWriteFile 在 I/O 环中排队读取和写入操作。

使用这种方法,任意读取和写入不像许多其他方法那样受指针大小限制,而是可以大到 sizeof(ULONG),同时读取或写入许多页的内核数据。

清理

这种技术需要最少的清理:所有需要的是在关闭 I/O 环对象的句柄之前将 IoRing->RegBuffers 设置为零。只要指针为零,即使 IoRing->RegBuffersCount 非零,内核也不会尝试释放任何东西。

如果你选择先注册一个有效的缓冲区数组,然后覆盖 I/O 环对象中的现有指针,清理会变得稍微复杂一些——在这种情况下,已经有一个分配的内核缓冲区,这也会在 EPROCESS 对象中添加一个引用计数。在这种情况下,EPROCESS RefCount 需要在进程退出之前递减,以避免留下陈旧的进程。幸运的是,使用我们现有的技术再进行一次任意读取 + 写入很容易做到。

任意增量

几年前,我发表了一系列博客讨论 CVE-2020-1034——EtwpNotifyGuid 中的一个任意增量漏洞。当时,我专注于利用此漏洞的挑战,并使用它来增加进程的令牌权限——一种非常著名的权限提升技术。这种方法有效,尽管可以使用不同的工具实时或追溯检测。安全供应商很清楚这种技术,许多已经检测到它。

那个项目使我对利用该特定漏洞类的其他方式感兴趣——内核地址的任意增量,因此我很高兴找到了一个最终适合的后渗透技术。使用我在这里介绍的方法,你可以使用任意增量将 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 构建 22610(因此是官方 22H2 版本之前的几个构建)以来,内核中的缓冲区数组不再是一个地址和长度的平面数组,而是一个指向新数据结构的指针数组:IOP_MC_BUFFER_ENTRY:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
typedef struct _IOP_MC_BUFFER_ENTRY
{
    USHORT Type;
    USHORT Reserved;
    ULONG Size;
    ULONG ReferenceCount;
    ULONG Flags;
    LIST_ENTRY GlobalDataLink;
    PVOID Address;
    ULONG Length;
    CHAR AccessMode;
    ULONG MdlRef;
    PMDL Mdl;
    KEVENT MdlRundownEvent;
    PULONG64 PfnArray;
    IOP_MC_BE_PAGE_NODE PageNodes[1];
} IOP_MC_BUFFER_ENTRY, *PIOP_MC_BUFFER_ENTRY;

该数据结构是同一构建中添加的 MDL 缓存功能的一部分。它看起来复杂且可怕,但在我们的用例中,这些字段大多从未使用过,可以忽略。我们仍然有相同的 Address 和 Length 字段,我们需要这些字段才能使我们的技术工作,并且为了兼容新功能的要求,我们还需要在字段 Type、Size、AccessMode 和 ReferenceCount 中硬编码一些值。

为了使我们的技术适应这一新添加,以下是我们代码中需要的更改:

  1. 分配一个伪造的缓冲区数组,大小为 sizeof(PVOID) * NumberOfEntries。
  2. 为每个伪造的缓冲区分配一个 IOP_MC_BUFFER_ENTRY 结构并将指针放入伪造的缓冲区数组中。将结构清零,然后设置以下字段:
1
2
3
4
5
6
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 数据部分的一个页面——该部分的偏移量由于懒惰而硬编码,因此当该偏移量更改时可能会自发中断。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计