CVE-2023-21768 内核提权漏洞分析
2023-5-12 02:14:0 Author: xz.aliyun.com(查看原文) 阅读量:17 收藏

前置了解
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。因为在原版的时候

**(_DWORD **)(a3 + 24)
``` 赋值给v20的时候,是没有任何检查机制。
ProbeForWrite 的作用是检查用户模式缓冲区是否实际驻留在地址空间的用户模式部分、是否可写以及是否正确对齐。其参数含义如下
```cpp
void ProbeForWrite(
  [in, out] volatile VOID *Address, //指定用户模式缓冲区的开头
  [in]      SIZE_T        Length, //指定用户模式缓冲区的长
  [in]      ULONG         Alignment //指定用户模式缓冲区开头的所需对齐方式
);

现在通过函数的作用和diff的结果,确认了漏洞点是出现在AfdNotifyRemoveIoCompletion函数里,那么下一步就是要思考如何触发让我们的poc流程能触发到这个函数里边。
那么先来看一下此函数的交叉引用,来分析其调用序列

AfdNotifySock-->AfdNotifyRemoveIoCompletion
AfdNotifySock中调用AfdNotifyRemoveIoCompletion,而v7传入的就是后边a3的值。往上追踪代码发现
在到达函数之前很明显有几处条件判断,首先inputbufferlength要等于0x30,要不就会跳到分支LABEL_45就不会走到AfdNotifyRemoveIoCompletion函数那里。还需要Outputbuffer==0

继续往下边的流程走的话,会发现还经过了ObReferenceObjectByHandle函数,通过gpt的解释理解一下

所以在这里,我们要用NtCreateIoCompletion函数来创建有效的IO完成对象的句柄。而v11这块就是一个句柄HandleIoCompletion,再来让v10是个有效值来过下边的条件判断,这一处再后边的exp代码中也有体现。

while ( v13 < Inputbuffer->dwCounter) ) //这里要满足这个条件让其进入循环,将其counter 设为1
    {
      if ( pre_mode )
      {
        v24 = 0i64;
        v25 = 0i64;
        v15 = v13;
        pData1 = Inputbuffer->pData1;
        if ( v12 )
        {
          v17 = pData1 + 16 * v13; //这里是一个循环.上述 v13 < Counter就会循环,然后累加,这样需要的空间就变大了
          v31 = v17;
          if ( (v17 & 3) != 0 )
            ExRaiseDatatypeMisalignment();
          if ( v17 + 16 > *v14 || v17 + 16 < v17 )
            *(_BYTE *)*v14 = 0;
          *(_QWORD *)&v24 = *(unsigned int *)v17;
          *((_QWORD *)&v24 + 1) = *(unsigned int *)(v17 + 4);
          LOWORD(v25) = *(_WORD *)(v17 + 8);
          BYTE2(v25) = *(_BYTE *)(v17 + 10);
        }

最后Counter为1将pData1设置为一块申请出的空间,流程就会走到了AfdNotifyRemoveIoCompletion函数里

我们再来看 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)要求绕过的条件。

再到下边引用x86matthew的代码NtCreateFile去触发afd驱动

ObjectFilePath.Buffer = (PWSTR)L"\\Device\\Afd\\Endpoint";
    ObjectFilePath.Length = (USHORT)wcslen(ObjectFilePath.Buffer) * sizeof(wchar_t);
    ObjectFilePath.MaximumLength = ObjectFilePath.Length;

    ObjectAttributes.Length = sizeof(ObjectAttributes);
    ObjectAttributes.ObjectName = &ObjectFilePath;
    ObjectAttributes.Attributes = 0x40;

    ret = _NtCreateFile(&hSocket, MAXIMUM_ALLOWED, &ObjectAttributes, &IoStatusBlock, NULL, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, 1, 0, bExtendedAttributes, sizeof(bExtendedAttributes));

这里就是上述分析漏洞时得出的条件,hCompletion 为句柄,data1 data2 为两块内存空间,counter为1
len为1,以此来进行条件判断的绕过。
使用NtDeviceIoControlFile与AFD驱动程序通信,用触发到我们的函数

_NtDeviceIoControlFile(hSocket, hEvent, NULL, NULL, &IoStatusBlock, AFD_NOTIFYSOCK_IOCTL, &Data, 0x30, NULL, 0);
//AFD_NOTIFYSOCK_IOCTL 0x12127

这里的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的结构,而下图是准备提交给内核的提交队列。

这三个图就基本上含括了基本的核心。而当上述的情况发生会有如下的情况

  1. I/O ring->RegBuffers and IoRing->RegBuffersCount 设置为0
  2. 内核验证 Sqe->RegisterBuffers. 缓冲区 和 Sqe->RegisterBuffers. 计数都不为零。
  3. 如果请求来自User model,那么数组就可以验证它是否完全位于用户模式地址空间中。数组的大小也可以达到sizeof(ULONG)
  4. 如果环有一个预注册的缓冲区数组,并且新缓冲区的大小与旧缓冲区的大小相同,则旧缓冲区数组将被放回环中,而新缓冲区将被忽略。
  5. 如果前面的检查通过并且要使用新的缓冲数组,则会进行新的分页池分配,然后会从User model的数组复制数据,指向 I/O ring->RegBuffers。
  6. 如果I/O ring 以前指向过一个已注册的缓冲区数组,那么它将被复制到新的内核数组中。任何新的缓冲区都将添加到相同的分配中,在旧缓冲区之后。
  7. 然后就会去探测从用户模式发送的数组中的每个条目,以验证所请求的缓冲区完全处于用户模式,然后将其复制到内核数组中去。
  8. 旧的内核数组(如果存在的话)被释放,操作完成。
    上述的操作跟之前的漏洞原语其实都有相似之处,都有一部分是将数据从用户模式下copy到内核模式下,而用户模式则是被我们所控制,这样就代表进入内核的部分数据将由用户可操作。
    但是文章又提到数据只从用户模式读取一次,正确地探测和验证这样就避免内核地址的溢出和意外读写。将来对这些缓冲区的任何使用都将从内核缓冲区中获取它们。 这看起来已经扼杀了我们的想法。但是如果我们有一个任意的内核读写漏洞呢。
    IoRing->RegBuffers指向假的、用户控制的数组,我们就可以使用普通的I/O环操作来生成内核读和写到我们想要的地址,通过指定一个索引到我们的假数组作为缓冲区,内核就会将从我们选择的文件读到指定的内核地址,导致任意写入。
    那么最后实现漏洞原语的利用就是下边的步骤:
  9. 使用CreateNamedPipe创建两个命名管道:一个用于内核写入的输入,另一个用于内核读取的输出。至少应该用PIPE_ACCESS_DUPLEX 为标志创建作为输入的管道,以允许读和写。使用PIPE_ACCESS_DUPLEX创建这两个文件
  10. CreateFile打开两个管道的客户端句柄,且具有读和写权限。
  11. 创建 I/O ring: 使用CreateIoRing API
  12. 在堆中分配一个假缓冲区数组: 但是从22H2版本开始,注册的缓冲区数组不再是一个平面数组,而是一个IOP_MC_BUFFER_ENTRY结构的数组
  13. 查找新创建的 I/O ring 对象的地址: 由于 I/O ring 使用新的对象类型IORING_OBJECT,那么就需要使用SystemHandleInformation工具包里的NtQuerySystemInformation 泄漏对象的内核地址,包括我们的新 I/O ring 对象。而IORING_OBJECT 结构位于公共符号表中,所以我们就不需要查找RegBuffers的偏移量。将两者相加就获得了任意写入的目标。
  14. 使用任意写入,用伪用户模式数组的地址覆盖IoRing->RegBuffers。如果之前没有注册一个有效的缓冲区数组,那么还必须覆盖IoRing->RegBuffersCount,使其具有一个非零值。
  15. 用内核指针填充伪缓冲区数组,以便进行读或写操作: ,使用与前面相同的技术来查找内核模块的基地址(NtQuerySystemInformation)或者使用I/O环本身内部可用的指针,这些指针指向分页池中的数据结构。
  16. 通过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


文章来源: https://xz.aliyun.com/t/12517
如有侵权请联系:admin#unsafe.sh