CVE-2023-21768 内核提权漏洞分析
前置了解:
what AFD is and what is does?
AFD (Ancillary Function Driver)是 Windows 操作系统中的一个内核模式驱动程序,它也是套接字(Socket) 通信的核心模块之一。
它提供了操作系统与网络协议栈之间的接口,让应用程序能够进行网络通信。支持 WinSock,而 WinSock 是在 Windows 中访问网络服务的编程接口。
afd.sys 实现了套接字的管理,套接字之间的数据传输,监控套接字上的事件,afd.sys 还负责报告和处理网络通信错误。其实 afd.sys 功能基本上都是围绕网络套接字。它是网络上程序之间通信通道的端点。而套接字允许程序通过网络连接发送和接收数据。
通过这张图也可以更好的理解一下,而也让理解到 winsock 是 user model 下,afd.sys 位于 Kernel Mode,所以这种一般就需要一个桥梁将其联系来才能到达。
漏洞原理:
该漏洞存在于 AFD 驱动程序处理用户模式输入 / 输出 (I/O) 操作的方式中。
具体来说,该漏洞允许攻击者向 AFD 驱动程序发送恶意输入 / 输出控制 (IOCTL) 请求,这可能导致以提升的权限执行任意代码。
这里从两个方面来看此漏洞,首先是通过补丁对比,其次再去通过公开的利用代码来进行分析和学习最后的利用过程。
从 Winbindex 拿到打补丁和未打补丁版本
Windows 11 22H2 KB5017389 (+6) x64 10.0.22621.608(未打补丁)
Windows 11 22H2 KB5022303 (+2) x64 10.0.22621.1105(已打补丁)
可以看见基本没有大改,基本都是细小的差距。基本就可以确定不是特别大的逻辑问题需要重写模块。而且看上去基本就一个函数需要去看一下 AfdNotifyRemoveIoCompletion
其实从汇编代码这里就蛮明显可以看见,补丁处明显是增加了一处代码。来看一下 f5 之后的样子


原来是在之前加了一处 if 判断,再通过 ProbeForWrite 来进行检查 a3。因为在原版的时候
(ProbeForWrite 的作用是检查用户模式缓冲区是否实际驻留在地址空间的用户模式部分、是否可写以及是否正确对齐。其参数含义如下
现在通过函数的作用和 diff 的结果,确认了漏洞点是出现在 AfdNotifyRemoveIoCompletion 函数里,那么下一步就是要思考如何触发让我们的 poc 流程能触发到这个函数里边。
那么先来看一下此函数的交叉引用,来分析其调用序列
AfdNotifySock-->AfdNotifyRemoveIoCompletion
AfdNotifySock 中调用 AfdNotifyRemoveIoCompletion,而 v7 传入的就是后边 a3 的值。往上追踪代码发现
在到达函数之前很明显有几处条件判断,首先 inputbufferlength 要等于 0x30, 要不就会跳到分支 LABEL_45 就不会走到 AfdNotifyRemoveIoCompletion 函数那里。还需要 Outputbuffer==0
继续往下边的流程走的话,会发现还经过了 ObReferenceObjectByHandle 函数,通过 gpt 的解释理解一下
所以在这里,我们要用 NtCreateIoCompletion 函数来创建有效的 IO 完成对象的句柄。而 v11 这块就是一个句柄 HandleIoCompletion,再来让 v10 是个有效值来过下边的条件判断,这一处再后边的 exp 代码中也有体现。
我们再来看 AfdNotifyRemoveIoCompletion 函数里还存在一个条件判断:
那就是当我们将 dwLen 设置为 1 的时候,让 IoRemoveIoCompletion 返回 0,if 就会跳到 pdata2 + 24 =v20 那里,而 pData2 是一块申请出的内存。
这里最主要的就是来添加已完成的 I/O 操作,从而使 IoRemoveIoCompletion 正常返回。
使用 NtSetIoCompletion ,此函数用于向 I/O 完成端口的完成队列中添加一个 I/O 完成包,这样就可以让 IoRemoveIoCompletion 返回 0。从而最后走到漏洞触发点了。
总得来说就是 dwLen 为 1,然后 pData2 指向一块可写的内存空间。
然后继续往上跟踪 AfdNotifySock,看看是否还有调用
没有发现对该函数的直接调用,但是发现 AfdNotifySock 的地址在 AfdIrpCallDispatch 的函数指针表上方。
这张表包含了 AFD 驱动程序的调度例程,里面的函数都是 AFD 驱动程序的调度函数。调度例程用于通过调用 DeviceIoControl 来处理来自 Win32 应用程序的请求。每个函数的控制代码在 AfdIoctlTable 中找到。

从 recon2015 逆向 AFD.sys 的 pdf 文章中,可以知道 afd 其实是有两个调度表,还有一个是 AfdImmediateCallDispatch

其实两个表里面的函数都是 AFD 驱动程序的调度函数,言归正传我们要找到怎么触发到 AfdNotifySock 函数,
首先需要通过 AfdIoctlTable 去获取 ioctl_code
那么就需要计算 AfdImmediateCallDispatch 表的起点和指向 AfdNotifySock 的指针存储位置之间的距离
我们可以计算 AfdIoctlTable 的索引 在最后一位最后 查找发现 AfdNotifySock 函数的 ioctl_code 为 12127h

知道了 ioctl_code,就可以在用户层调用 DeviceIoControl 来访问此函数了。
通过外佬 x86matthew 发布的代码能让我们很方便的利用起来。他在文章中提到 NtCreateFile 和 NtDeviceIoControlFile 这两个函数,是 Winsock 库用来与 AFD 驱动通信使用的。

将代码编译运行的时候,下断点到 afd!AfdNotifySock 就会发现能触发到了。比较主要的就是下边两个函数的构造和喂参。
代码通过直接调用 AFD 驱动程序来执行套接字操作,为 TCP 套接字创建句柄,向 AFD 驱动程序发出 IOCTL 请求。这样我们就能通过获得的 ioctl_code 让我们能够触发目标函数。
代码分析:
现在让我们来梳理一下,我们已经知道了怎么触发到 AfdNotifySock 函数,然后又知道了怎么设置参数让其通过 AfdNotifySock 函数里的条件判断走到我们的漏洞函数 AfdNotifyRemoveIoCompletion 里边,在其里边的最后一个条件判断,我们也知道如何进行绕过。然后此时我们也掌握了如何使用代码调用 afd 程序(也就是触发 AfdNotifySock)那么我们就来看看公开的 exp,来分析验证我们的结论。
代码里有两个函数 NtCreateIoCompletion,NtSetIoCompletion 一个是用来创建 IO 完成端口对象并返回其句柄
一个是用来将完成包添加到 I/O 完成端口的完成队列中,正好对上了我们分析的两个函数(IoCompletionObjectType,ObReferenceObjectByHandle)要求绕过的条件。
这里就是上述分析漏洞时得出的条件,hCompletion 为句柄,data1 data2 为两块内存空间,counter 为 1
len 为 1,以此来进行条件判断的绕过。
使用 NtDeviceIoControlFile 与 AFD 驱动程序通信,用触发到我们的函数
这里的 AFD_NOTIFYSOCK_IOCTL 就是上方我们计算得到的 ioctl_code 为 12127,从而完成漏洞的触发。
I/O Ring
然后剩下的就是用于在 Windows 11 22H2 独有的后利用原语 I/O Ring,原作写的非常细节易懂,这里俺就不过多叙述,简单陈述一下过程让整个漏洞利用串联起来。主要是 对 I/O Ring 逆向非常细致,以此他挖掘出一套利用 I/O Ring 的读写机制从而达成漏洞利用原语。 它是一个异步 I/O 机制,该机制是仿照 Linux 的 io_uring
这个东西是一个提交队列,而它是一个环形的结构,正好对应下图的 Submission Queue
图中就是 Submission Queue Entry 的结构,而下图是准备提交给内核的提交队列。
这三个图就基本上含括了基本的核心。而当上述的情况发生会有如下的情况
- I/O ring->RegBuffers and IoRing->RegBuffersCount 设置为 0
- 内核验证 Sqe->RegisterBuffers. 缓冲区 和 Sqe->RegisterBuffers. 计数都不为零。
- 如果请求来自 User model,那么数组就可以验证它是否完全位于用户模式地址空间中。数组的大小也可以达到 sizeof(ULONG)
- 如果环有一个预注册的缓冲区数组,并且新缓冲区的大小与旧缓冲区的大小相同,则旧缓冲区数组将被放回环中,而新缓冲区将被忽略。
- 如果前面的检查通过并且要使用新的缓冲数组,则会进行新的分页池分配,然后会从 User model 的数组复制数据,指向 I/O ring->RegBuffers。
- 如果 I/O ring 以前指向过一个已注册的缓冲区数组,那么它将被复制到新的内核数组中。任何新的缓冲区都将添加到相同的分配中,在旧缓冲区之后。
- 然后就会去探测从用户模式发送的数组中的每个条目,以验证所请求的缓冲区完全处于用户模式,然后将其复制到内核数组中去。
- 旧的内核数组 (如果存在的话) 被释放,操作完成。上述的操作跟之前的漏洞原语其实都有相似之处,都有一部分是将数据从用户模式下 copy 到内核模式下,而用户模式则是被我们所控制,这样就代表进入内核的部分数据将由用户可操作。
但是文章又提到数据只从用户模式读取一次,正确地探测和验证这样就避免内核地址的溢出和意外读写。将来对这些缓冲区的任何使用都将从内核缓冲区中获取它们。 这看起来已经扼杀了我们的想法。但是如果我们有一个任意的内核读写漏洞呢。
IoRing->RegBuffers 指向假的、用户控制的数组,我们就可以使用普通的 I/O 环操作来生成内核读和写到我们想要的地址,通过指定一个索引到我们的假数组作为缓冲区,内核就会将从我们选择的文件读到指定的内核地址,导致任意写入。
那么最后实现漏洞原语的利用就是下边的步骤: - 使用 CreateNamedPipe 创建两个命名管道 : 一个用于内核写入的输入,另一个用于内核读取的输出。至少应该用 PIPE_ACCESS_DUPLEX 为标志创建作为输入的管道,以允许读和写。使用 PIPE_ACCESS_DUPLEX 创建这两个文件
- CreateFile 打开两个管道的客户端句柄,且具有读和写权限。
- 创建 I/O ring: 使用 CreateIoRing API
- 在堆中分配一个假缓冲区数组: 但是从 22H2 版本开始,注册的缓冲区数组不再是一个平面数组,而是一个 IOP_MC_BUFFER_ENTRY 结构的数组
- 查找新创建的 I/O ring 对象的地址: 由于 I/O ring 使用新的对象类型 IORING_OBJECT,那么就需要使用 SystemHandleInformation 工具包里的 NtQuerySystemInformation 泄漏对象的内核地址,包括我们的新 I/O ring 对象。而 IORING_OBJECT 结构位于公共符号表中,所以我们就不需要查找 RegBuffers 的偏移量。将两者相加就获得了任意写入的目标。
- 使用任意写入,用伪用户模式数组的地址覆盖 IoRing->RegBuffers。如果之前没有注册一个有效的缓冲区数组,那么还必须覆盖 IoRing->RegBuffersCount,使其具有一个非零值。
- 用内核指针填充伪缓冲区数组,以便进行读或写操作: ,使用与前面相同的技术来查找内核模块的基地址(NtQuerySystemInformation)或者使用 I/O 环本身内部可用的指针,这些指针指向分页池中的数据结构。
- 通过 BuildIoRingReadFile 和 BuildIoRingWriteFile 对 I/O ring 中的读写操作进行排队。
- 这里说的只是一个大概和我自己觉得能读明白的地方,,,建议要搞懂还是去自己看一下原文理解一下,Yarden Shafir 也提供了源代码,跟着调试一下效果会更好。
- 这个漏洞利用原语给我的感觉就跟之前 Windows Notification Facility(WNF) 来实现任意内存读写原语有异曲同工之妙。
最后漏洞复现截图
通过 IoRing 的任意地址读写 获取 system token 和 本进程 token,从而进行替换操作即可。
PS: 从看雪师傅那里的文章发现有一些细节的问题(如怎么完成这个函数的需求条件)问 chatgpt 也是很好的选择,但如果网上资料较少的 gpt 就很容易胡言乱语导致我理解错的一个东西,,,还是要多方面参考好一点
然后因为此漏洞影响的型号有限,tmd 期间拿 Windows 11 装的 VS 2022 生成项目,出现的错误真是一堆屎一样。避坑! 先后改了无数个项目配置,
先是死活 CreateIoRing(IORING_VERSION_3, ioRingFlags, 0x10000, 0x20000, &hIoRing); 这句代码有红色浪线,最后把 IntelliSense 禁用
又是 error C2065: “IORING_VERSION_3”: 未声明的标识符。明明头文件都引入了,最后我把 <ioringapi.h> 这个头文件放第一行竟然就好了。
error no target architecture winnt.h 然后又爆这个错误,查一下最后为为预处理器定义添加宏,最后竟然又回去了!!!神经病啊我曹。然后折腾了半小时突然感觉是不是傻逼 win11 的问题,毕竟 win11 有一些奇怪 bug 不止一次了,然后拿 win10 重新了安装了一下 vs2022 编译一下就好了。。。
只想说一句,去你*的 win11
参考:
x86matthew
i-o-ring
exp
kanxue
patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock