One Year to I/O Ring: What Changed?
自I/O Ring首个版本引入Windows已过去一年有余。初始版本随Windows 21H2发布,我曾在此详细记录其实现,并与Linux io_uring进行对比。微软也同步发布了Win32函数文档。自初始版本以来,该功能持续演进并获得了重大更新,值得通过本文完整记录并详细解析。
新增支持的操作
最显著的变化是新增了两个支持的操作:写入(write)和刷新(flush):
这使得I/O Ring能够执行写入和刷新操作。这些新操作的处理方式与首版即支持的读取操作类似,都会被转发到相应的I/O函数。KernelBase.dll新增了包装函数来排队这些操作的请求:BuildIoRingWriteFile
和BuildIoRingFlushFile
,其定义可在ioringapi.h头文件(预览版SDK中提供)中找到:
|
|
与BuildIoRingReadFile
类似,这两个函数都会构建包含请求操作码(OpCode)的提交队列条目(SQE)并将其加入提交队列。显然,新操作需要不同的标志和选项,例如刷新操作的flushMode或写入操作的writeFlags。为此,NT_IORING_SQE结构现在包含一个联合体(union),根据请求的OpCode解释输入数据——新结构可在公共符号及文末获取。
支持写入操作的一个小型内核变更可在IopIoRingReferenceFileObject
中看到:
新增了几个参数,并额外调用了ObReferenceFileObjectForWrite
。不同函数中对各种缓冲区的探测也根据操作类型发生了变化。
用户完成事件
另一个有趣的变更是能够注册用户事件,以便在每个新完成操作时接收通知。与仅在所有操作完成时发出信号的I/O Ring CompletionEvent不同,新的可选用户事件会在每个新完成操作时发出信号,允许应用程序在结果写入完成队列时立即处理。
为支持此功能,创建了新的系统调用NtSetInformationIoRing
:
|
|
与其他NtSetInformation*
例程类似,该函数接收IoRing对象句柄、信息类、长度和数据。目前仅有一个有效信息类:1。遗憾的是IORING_INFORMATION_CLASS结构未包含在公共符号中,因此无法得知其官方名称,但我将其称为IoRingRegisterUserCompletionEventClass
。尽管目前仅支持一个类,但未来可能会支持其他信息类。一个有趣之处在于,该函数使用全局数组IopIoRingSetOperationLength
来检索每个信息类的预期信息长度:
该数组目前仅有两个条目:0(非有效类,返回长度0)和条目1(返回预期大小8)。此长度与函数期望接收事件句柄(HANDLE在x64上为8字节)相符。这可能暗示未来计划支持更多信息类,或仅是不同的编码选择。
完成必要输入检查后,函数会引用通过句柄传递的I/O ring。然后,如果信息类是IoRingRegisterUserCompletionEventClass
,则调用IopIoRingUpdateCompletionUserEvent
并传入提供的事件句柄。IopIoRingUpdateCompletionUserEvent
会引用事件并将指针置于IoRingObject->CompletionUserEvent
中。如果未提供事件句柄,则清除CompletionUserEvent字段:
逆向工程角落
顺带一提,此函数可能看起来较大且略显复杂,但其中大部分是同步代码,用于确保任何时候仅有一个线程能编辑I/O ring的CompletionUserEvent字段,防止竞态条件。实际上,由于编译器展开宏,函数看起来比实际更大。如果我们尝试重构源代码,该函数会简洁许多:
|
|
仅约六行实际代码。但这不是本文重点,让我们回到主题:新的CompletionUserEvent。
回到用户完成事件
下一次遇到CompletionUserEvent是在IoRing条目完成时,在IopCompleteIoRingEntry
中:
正常的I/O ring完成事件仅在所有操作完成时发出信号,而CompletionUserEvent在不同条件下发出信号。查看代码,我们看到以下检查:
每次I/O ring操作完成并写入完成队列时,CompletionQueue->Tail字段增加1(此处引用为newTail)。CompletionQueue->Head字段包含最后写入的完成条目的索引,每次应用程序处理另一个条目时增加(如果使用PopIoRingCompletion
,内部会自动处理,否则需手动递增)。因此,(newTail - Head) % CompletionQueueSize
计算了应用程序尚未处理的已完成条目数量。如果该数量为1,意味着应用程序已处理所有已完成条目,仅剩最新正在完成的条目。此时,函数将引用CompletionUserEvent并调用KeSetEvent
发出信号。
此行为允许应用程序通过创建专用线程来跟踪所有提交操作的完成情况,该线程等待用户事件并在每个新条目完成时立即处理。这确保完成队列的Head和Tail始终一致,因此下一个待完成条目将发出事件信号,线程处理该条目,依此类推。这样应用程序的主线程可继续其他工作,而I/O操作由工作线程尽快处理。
当然,这不是强制性的。应用程序可选择不注册用户事件,仅等待所有事件完成。但两种事件允许不同应用程序选择最适合的方案,创建可调整以满足不同需求的I/O完成机制。
KernelBase.dll中有一个函数用于注册用户完成事件:SetIoRingCompletionEvent
。我们可在ioringapi.h中找到其签名:
|
|
使用此新API并了解新事件的操作方式,我们可以构建如下演示应用程序:
|
|
排空前置操作
用户完成事件是一个非常酷的补充,但它并非I/O ring唯一与等待相关的改进。另一个可通过查看NT_IORING_SQE_FLAGS
枚举发现:
|
|
浏览代码,我们可在IopProcessIoRingEntry
开头找到对NT_IORING_SQE_FLAG_DRAIN_PRECEDING_OPS
的检查:
此检查在任何处理之前进行,检查提交队列条目是否包含NT_IORING_SQE_FLAG_DRAIN_PRECEDING_OPS
标志。如果是,则调用IopIoRingSetupCompletionWait
设置等待参数。函数签名大致如下:
|
|
函数内部有许多技术性强且枯燥的检查和计算,因此我省略详细解释,直接关注关键部分。本质上,如果函数接收-1作为WaitOperations,它将忽略SetupCompletionWait参数,计算已提交和处理但尚未完成的操作数量。该数字置于IoRingObject->CompletionWaitUntil
中。同时设置IoRingObject->SignalCompletionEvent
为TRUE,并在输出参数CompletionWait中返回TRUE。
如果函数成功,IopProcessIoRingEntry
随后调用IopIoRingWaitForCompletionEvent
,等待直到IoRingObject->CompletionEvent
发出信号。现在回到之前在IopCompleteIoRingEntry
中看到的检查:
如果SignalCompletionEvent已设置(因IopIoRingSetupCompletionWait
设置)且已完成事件数量等于IoRingObject->CompletionWaitUntil
,则IoRingObject->CompletionEvent
发出信号,标记待处理事件全部完成。SignalCompletionEvent也被清除,避免在未请求时再次发出信号。
当从IopProcessIoRingEntry
调用时,IopIoRingWaitForCompletionEvent
接收NULL超时,意味着它将无限期等待。在使用NT_IORING_SQE_FLAG_DRAIN_PRECEDING_OPS
标志时应考虑这一点。
总结一下,在提交队列条目中设置NT_IORING_SQE_FLAG_DRAIN_PRECEDING_OPS
标志将确保所有前置操作在此条目处理前完成。这在某些I/O操作依赖较早操作的情况下可能是必需的。
但等待待处理操作还发生在另一种情况:提交I/O ring时。在我去年关于I/O ring的首篇文章中,我将NtSubmitIoRing
签名定义如下:
|
|
我的定义最终不完全准确。第三个参数更准确的名称应为WaitOperations,因此正确签名是:
|
|
这为何重要?因为传入WaitOperations的数字并非用于处理ring条目(它们完全基于SubmissionQueue->Head和SubmissionQueue->Tail处理),而是用于请求等待的操作数量。因此,如果WaitOperations不为0,NtSubmitIoRing
将在进行任何处理前调用IopIoRingSetUpCompletionWait
:
然而,它调用函数时设置SetupCompletionWait=FALSE,因此函数不会实际设置任何等待参数,仅执行健全性检查以确认等待操作数量是否有效。例如,等待操作数量不能高于已提交操作数量。如果检查失败,NtSubmitIoRing
不会处理任何条目并返回错误,通常是STATUS_INVALID_PARAMETER_3。
之后,在处理操作后再次看到这两个函数:
再次调用IopIoRingSetupCompletionWait
重新计算需要等待的操作数量,考虑可能已完成的操作(或如果任何SQE有前述标志则已等待)。然后调用IopIoRingWaitForCompletionEvent
等待IoRingObject->CompletionEvent
,直到所有请求事件完成。
在大多数情况下,应用程序选择将WaitOperations参数设为0或设置为提交操作总数,但可能存在应用程序仅希望等待部分提交操作的情况,因此可选择较低数字等待。
寻找漏洞
比较不同构建中的相同代码是发现已修复漏洞的有趣方式。有时这些是已修补的安全漏洞,有时仅是可能影响代码稳定性或可靠性的常规旧漏洞。过去一年中,内核中的I/O ring代码接收了大量修改,因此这是寻找旧漏洞的好机会。
此处我想关注的一个漏洞相当容易发现和理解,但是一个有趣示例,展示了系统看似完全无关的部分如何以意外方式冲突。这是一个功能性的(非安全)漏洞,阻止了WoW64进程使用某些I/O ring功能。
我们可在查看IopIoRingDispatchRegisterBuffers
和IopIoRingDispatchRegisterFiles
时找到此漏洞的证据。查看新构建时,我们可以看到早期版本中不存在的代码段:
这是在检查注册缓冲区或文件的进程是否是WoW64进程——在64位系统上运行的32位进程。由于Windows现在支持ARM64,此WoW64进程现在可以是x86应用程序或ARM32应用程序。
进一步查看可以显示此信息在此处重要的原因。稍后,我们看到两个检查isWow64的情况:
第一种情况是在计算数组大小时,如果调用者是UserMode,则检查无效大小。
第二种情况发生在迭代输入缓冲区以在将存储在I/O ring对象中的数组中注册缓冲区时。在这种情况下,由于结构处理方式,稍微难以理解我们所查看的内容,但如果我们查看反汇编,可能会更清晰:
左侧块是WoW64情况,右侧块是本机情况。这里我们可以看到在bufferInfo变量(反汇编中的r8)中访问的偏移量差异。为获取上下文,bufferInfo从提交队列条目读取:
|
|
注册缓冲区时,SQE将包含NT_IORING_OP_REGISTER_BUFFERS结构:
|
|
子结构均在公共符号中,因此我不全部列出,但此情况下重点关注的是IORING_BUFFER_INFO:
|
|