Windows Sockets 从注册 I/O 到 SYSTEM 权限提升

admin 2025年1月9日22:26:47评论18 views字数 9723阅读32分24秒阅读模式

Windows Sockets From Registered IO to SYSTEM Privileges

概述

本文讨论了 CVE-2024-38193,这是 Windows 驱动程序 afd.sys 中的一个释放后使用(use-after-free)漏洞。具体来说,该漏洞存在于 Windows 套接字的注册 I/O 扩展中。这个漏洞已在2024 年 8 月的补丁星期二中得到修复。本文将详细描述该漏洞的利用过程。

首先,我们将对 Winsock 的注册 I/O 扩展进行概述,描述驱动程序的内部结构。然后分析漏洞并详细说明利用策略。

预备知识

在本节中,我们将概述 Winsock 的注册 I/O 扩展,并描述注册 I/O 扩展的相关结构。

Winsock 注册 I/O 扩展

在 Windows 中,注册 I/O(RIO)扩展可用于套接字编程,以减少用户态程序在发送和接收数据包时发出的系统调用数量。RIO 扩展的工作流程如下:

  • 用户态程序注册大型缓冲区。内核随后为这些缓冲区获取内核映射。
  • 用户态程序通过使用已注册缓冲区的接收和发送缓冲区_切片_来发出发送和接收请求。

如果用户态程序想要使用套接字的注册 I/O 扩展,需要通过 WSASocketA() 函数创建套接字。 该函数的原型如下:

SOCKET WSAAPI WSASocketA([in] int                 af,[in] int                 type,[in] int                 protocol,[in] LPWSAPROTOCOL_INFOA lpProtocolInfo,[in] GROUP               g,[in] DWORD               dwFlags);

用户态程序必须在调用的 dwFlags 参数中指定 WSA_FLAG_REGISTERED_IO 标志。然后程序需要获取 RIO API 的函数表。这个函数表可以通过使用 SIO_GET_MULTIPLE_EXTENSION_FUNCTION_POINTER IOCTL 代码调用 WSAIoctl() 来获取。该调用会返回一个 RIO_EXTENSION_FUNCTION_TABLE 结构。这个表包含了所有 RIO API 的函数指针。该结构的定义如下所示:

typedefstruct_RIO_EXTENSION_FUNCTION_TABLE {  DWORD                         cbSize;  LPFN_RIORECEIVE               RIOReceive;  LPFN_RIORECEIVEEX             RIOReceiveEx;  LPFN_RIOSEND                  RIOSend;  LPFN_RIOSENDEX                RIOSendEx;  LPFN_RIOCLOSECOMPLETIONQUEUE  RIOCloseCompletionQueue;  LPFN_RIOCREATECOMPLETIONQUEUE RIOCreateCompletionQueue;  LPFN_RIOCREATEREQUESTQUEUE    RIOCreateRequestQueue;  LPFN_RIODEQUEUECOMPLETION     RIODequeueCompletion;  LPFN_RIODEREGISTERBUFFER      RIODeregisterBuffer;  LPFN_RIONOTIFY                RIONotify;  LPFN_RIOREGISTERBUFFER        RIORegisterBuffer;  LPFN_RIORESIZECOMPLETIONQUEUE RIOResizeCompletionQueue;  LPFN_RIORESIZEREQUESTQUEUE    RIOResizeRequestQueue;} RIO_EXTENSION_FUNCTION_TABLE, *PRIO_EXTENSION_FUNCTION_TABLE;

获取函数表后,程序需要注册 I/O 缓冲区。这些缓冲区将用于所有后续的 I/O 操作。为此,必须调用 RIORegisterBuffer() 函数。

该函数的原型如下:

RIO_BUFFERID  RIORegisterBuffer(  _In_ PCHAR DataBuffer,  _In_ DWORD DataLength);

DataBuffer 参数是一个指向要使用的缓冲区的指针,而 DataLength 参数是缓冲区的大小。该函数返回一个 RIO_BUFFERID 缓冲区描述符,它是一个不透明的整数,用于在内核中标识已注册的缓冲区。

为了从套接字发送或接收数据,可以使用 RIOSend() 和 RIOReceive() 函数。这些函数接受一个 RIO_BUF 结构,该结构描述了要使用的已注册缓冲区的切片RIO_BUF 结构的定义如下:

typedefstruct_RIO_BUF {  RIO_BUFFERID BufferId;  ULONG        Offset;  ULONG        Length;} RIO_BUF, *PRIO_BUF;

内核结构

结构定义是通过逆向工程获得的,可能与源代码中定义的结构不完全一致。以下是已恢复的已注册缓冲区的结构定义:

struct RIOBuffer {  PMDL AssociatedMDL;  _QWORD VirtualAddressBuffer;  _DWORD LengthBuffer;  _DWORD RefCount;  _DWORD IsInvalid;  _DWORD Unknown;};

这个内核结构被 afd.sys 驱动程序用来跟踪已注册的缓冲区。它包含以下字段:

  • AssociatedMDL:指向描述用户态缓冲区的 MDL 的指针。
  • VirtualAddressBuffer:内核驱动程序可用于对用户态缓冲区进行读写的虚拟内核指针。
  • LengthBuffer:用户态缓冲区的大小。
  • RefCount:用于跟踪此缓冲区引用计数的字段。
  • IsInvalid:用于编码缓冲区状态的字段。0 表示正在使用,1 表示已释放,2 表示需要释放。
  • Unknown:保留字段。

afd.sys 驱动程序将所有 RIOBuffer 结构保存在一个数组中,每个已注册的缓冲区对应一个条目。出于效率考虑,afd.sys 驱动程序还为数组中最近使用的 RIOBuffer 元素创建了一个缓存。缓存中的每个条目具有以下结构:

struct CachedBuffer {  _DWORD IdBuffer;  _DWORD RefCount;  RIOBuffer *BufferPtr;};

它包含以下字段:

  • IdBuffer:与已注册缓冲区关联的整数标识符。
  • RefCount:缓存条目的引用计数。
  • BufferPtr:指向此条目所镜像的 RIOBuffer 结构的指针。

下图是 afd.sys RIO 组件的可视化表示。

Windows Sockets 从注册 I/O 到 SYSTEM 权限提升
afd.sys RIO 组件

漏洞

这个释放后重用(use-after-free)漏洞是由 AfdRioGetAndCacheBuffer() 和 AfdRioDereferenceBuffer() 函数之间的竞争条件引起的。AfdRioGetAndCacheBuffer() 函数需要访问发送/接收操作中涉及的已注册缓冲区,因此它使用 _InterlockedIncrement() 内部函数临时增加相关已注册缓冲区的引用计数。

当用户态应用程序调用 RIODeregister() API 函数时,会调用 AfdRioDereferenceBuffer() 函数。该函数检查用户态应用程序想要注销的已注册缓冲区的引用计数值。如果引用计数值为 1,则函数会继续释放该结构。这些函数之间的竞争条件允许恶意用户强制 AfdRioGetAndCacheBuffer() 函数对已被 AfdRioDereferenceBuffer() 函数释放的已注册缓冲区结构进行操作。

IO 缓冲区注册

当用户态应用程序使用 RIORegisterBuffer() 注册 I/O 缓冲区时,最终会调用 afd.sys 驱动程序中的 AfdRioCreateRegisteredBuffer() 函数。

// Module: afd.sys__int64 __fastcall AfdRioCreateRegisteredBuffer(        struct_FsContext *FsContext,        _MDL *a2,        __int64 VirtualAddr,int Len,        unsigned int *a5,        PMDL **a6){  RioBuffer = 0i64;  memset(&LockHandle, 0, sizeof(LockHandle));  v8 = 1;  v9 = 1;  *a6 = 0i64;  AfdAcquireWriteLock(FsContext->SpinLock, &LockHandle);if ( FsContext->byte88 )  {    v10 = STATUS_INVALID_DEVICE_STATE;goto LABEL_17;  }  LastIdx = FsContext->LastIdx;[1]  ArrayBuffers = &FsContext->ArrayBuffers;  v13 = &FsContext->NumBuffers;[2]while ( 1 )  {if ( !LastIdx )goto LABEL_7;    RioBuffer = (RIOBuffer *)(*ArrayBuffers)[LastIdx];if ( !RioBuffer )break;if ( RioBuffer->IsInvalid == 2 )    {      v8 = 0;goto LABEL_13;    }LABEL_7:    v14 = LastIdx;    v15 = LastIdx + 1;    LastIdx = 0;if ( v14 != *v13 - 1 )      LastIdx = v15;if ( LastIdx == FsContext->LastIdx )goto LABEL_14;  }  v9 = 0;LABEL_13:  FsContext->LastIdx = LastIdx;LABEL_14:if ( v8 )  {[Truncated][3]    RioBuffer = (RIOBuffer *)ExAllocatePool2(97i64, 0x20i64, 'bOIR');    (*ArrayBuffers)[LastIdx] = (__int64)RioBuffer;  }  RioBuffer->AssociatedMDL = a2;  RioBuffer->VirtualAddressBuffer = VirtualAddr;  RioBuffer->LengthBuffer = Len;  RioBuffer->RefCount = 1;  RioBuffer->IsInvalid = 0;  *a5 = LastIdx;  *a6 = &RioBuffer->AssociatedMDL;  KeReleaseInStackQueuedSpinLock(&LockHandle);return 0i64;}

在[1]处,AfdRioCreateRegisteredBuffer()函数获取已注册缓冲区的数组,在[2]处的while循环中,它查找下一个可用的索引 ID 以分配给新缓冲区。一旦找到,在[3]处,为新的RIOBuffer结构分配内存,并将其RefCount初始化为 1。

IO 缓冲区使用

afd.sys内核驱动程序需要查找数组缓冲区时(例如在发送或接收数据包时),它会临时增加特定RIOBuffer结构的引用计数,并将其存储在缓存中。这种行为的示例可以在下面的AfdRioValidateRequestBuffer()函数中看到。

// Module: afd.syschar __fastcall AfdRioValidateRequestBuffer(        _QWORD *a1,        struct _KLOCK_QUEUE_HANDLE *QueueLock,        _BYTE *ReadLockAcquired,        unsigned int *a4,        RIOBuffer **a5){  NumBuffer = *a4;if ( NumBuffer )  {[4]    CachedBuffer = (RIOBuffer *)AfdRioGetCachedBuffer((__int64)a1, QueueLock, ReadLockAcquired, NumBuffer);if ( CachedBuffer )    {      Offset = a4[1];      Length = a4[2];if ( Length + Offset >= Offset && Length + Offset <= CachedBuffer->LengthBuffer )      {        *a5 = CachedBuffer;return1;      }      AFDETW_RIO_TRACE_INVALID_BUFFER_RANGE(*a1, (int)a1, (int)CachedBuffer, Offset, Length);      AfdRioDereferenceCachedBuffer((__int64)a1, *a4, CachedBuffer);      result = 0;    }else    {      AFDETW_RIO_TRACE_INVALID_BUFFERID(*a1, a1, *a4);      result = 0;    }  }else  {    result = 1;  }  *a5 = 0i64;return result;}

当用户态程序使用RIOSend()函数 API 时会调用此函数。AfdRioValidateRequestBuffer()函数负责验证作为函数 API 参数传递的RIO_BUF结构。在[4]处,代码调用AfdRioGetCachedBuffer()函数并传入已注册缓冲区的标识符。如果 ID 有效,该函数返回与该特定标识符对应的CachedBuffer结构,否则返回零。

下面展示了AfdRioGetCachedBuffer()函数的代码。

// Module: afd.sysRIOBuffer *__fastcall AfdRioGetCachedBuffer(        struct_a1 *a1,        struct _KLOCK_QUEUE_HANDLE *a2,        _BYTE *a3,        unsigned int NumBuffer){  CachedBufferArrays = a1->CachedBufferArrays;  v8 = NumBuffer % a1->ModuloCachedBuffers;  BufferPtr = CachedBufferArrays[v8].BufferPtr;[5]if ( BufferPtr && !BufferPtr->IsInvalid && CachedBufferArrays[v8].IdBuffer == NumBuffer )  {    v10 = CachedBufferArrays[v8].RefCount++ == -1;    CachedBufferArrays_1 = a1->CachedBufferArrays;if ( v10 )    {      --CachedBufferArrays_1[v8].RefCount;return 0i64;    }else    {[6]return CachedBufferArrays_1[v8].BufferPtr;    }  }else  {if ( !*a3 )    {      AfdAcquireReadLockAtDpcLevel(a1->FsContext->SpinLock, a2);      *a3 = 1;    }[7]return AfdRioGetAndCacheBuffer(a1, NumBuffer);  }}

AfdRioGetCachedBuffer() 函数会检查请求的缓冲区是否已经被缓存 [5]。如果已缓存,该函数会直接返回结构体而不做进一步处理 [6]。如果请求的缓冲区不在缓存中,AfdRioGetCachedBuffer() 函数需要驱逐缓存中的一个条目来存储请求的缓冲区。这个操作是在下面展示的 AfdRioGetAndCacheBuffer() 函数中完成的 [7]。

// Module: afd.sysRIOBuffer *__fastcall AfdRioGetAndCacheBuffer(struct_a1 *a1, unsigned int NumBuffer){  v2 = a1->FsContext;  v4 = &a1->CachedBufferArrays[NumBuffer % a1->ModuloCachedBuffers];if ( a1->FsContext->byte88 )return 0i64;if ( NumBuffer >= v2->NumBuffers )return 0i64;  _mm_lfence();  v5 = (RIOBuffer *)v2->ArrayBuffers[NumBuffer];if ( !v5 || v5->IsInvalid )return 0i64;[8]  _InterlockedIncrement(&v5->RefCount);if ( AfdRioEvictCachedBuffer(v4) )  {    v4->BufferPtr = v5;    v4->IdBuffer = NumBuffer;    v4->RefCount = 1;  }return v5;}

在 [8] 处,AfdRioGetCachedBuffer() 函数通过调用 _InterlockedIncrement() 内部函数临时增加与特定标识符关联的 RIOBuffer 结构的引用计数。使用这个内部函数只能保证变量的原子递增。然后 AfdRioGetAndCacheBuffer() 函数调用 AfdRioEvictCachedBuffer() 函数来驱逐缓存条目。如果操作成功,缓存条目将被填充新的缓冲区数据。

IO 缓冲区注销

当用户态程序想要注销一个 RIO 缓冲区时,可以通过调用 RIODeregisterBuffer() 函数 API 来实现,该 API 随后会调用 afd.sys 内核驱动程序中的 AfdRioDereferenceBuffer() 函数。

下面展示了 AfdRioDereferenceBuffer() 函数的代码。

// Module: afd.sysvoid __fastcall AfdRioDereferenceBuffer(struct_FsContext *a1, RIOBuffer *a2, unsigned int NumBuffer){  v4 = NumBuffer;[9]  if ( a2->RefCount == 1 || _InterlockedExchangeAdd(&a2->RefCount, -1u) == 1 )  {    SpinLock = a1->SpinLock;    memset(&LockHandle, 0, sizeof(LockHandle));    AfdAcquireWriteLock(SpinLock, &LockHandle);    a1->ArrayBuffers[v4] = 0i64;    KeReleaseInStackQueuedSpinLock(&LockHandle);    AfdRioCleanupBuffer(a2, 1);  }}

在 [9] 处,AfdRioDereferenceBuffer() 函数检查特定 RIOBuffer 结构的引用计数是否设置为 1。如果是,则在 AfdRioCleanupBuffer() 函数中释放该 RIOBuffer 结构。

这里存在一个竞争条件,允许恶意用户在不同的 CPU 上同时调度执行 AfdRioGetAndCacheBuffer() 和 AfdRioDereferenceBuffer() 函数,作用于同一个已注册的缓冲区。如果恶意用户能够赢得这个竞争条件,在 [8] 处,新的缓存缓冲区包含一个 BufferPtr 字段,指向被执行 AfdRioDereferenceBuffer() 函数的线程释放的内存区域,从而导致释放后重用(use-after-free)漏洞。

漏洞利用

利用此漏洞涉及以下步骤:

  1. 堆喷射阶段:
    • 用伪造的 RIOBuffer 结构喷射非分页池
    • 在非分页池中创建空洞
    • 注册 I/O 缓冲区
  2. 在之前分配的 RIOBuffer 结构上触发释放后重用漏洞
  3. 提权

堆喷射阶段

由于易受攻击的缓冲区分配在非分页池中,我们使用的喷射技术利用了命名管道来用任意大小的缓冲区填充非分页池区域。对命名管道堆喷射技术不熟悉的读者可以在这里这里了解更多信息。目标大小与 RIOBuffer 的大小相同,为 0x20 字节。有许多使用命名管道进行堆喷射的记录技术。在这种情况下,我们使用了无缓冲条目。与有缓冲条目相比,无缓冲条目的优势在于它们没有头部。这使它们非常适合完全匹配 RIOBuffer 的大小。喷射后,非分页池布局如下:

Windows Sockets 从注册 I/O 到 SYSTEM 权限提升
喷射后的非分页池

需要注意的是,无缓冲条目的内容完全由漏洞利用程序控制。下一步是通过关闭一些先前分配的命名管道在非分页池内创建空洞。这为 RIOBuffer 结构在无缓冲条目旁边分配留出了空间。为了填充这些空洞,漏洞利用程序必须调用 RegisterBuffer() 函数。

Windows Sockets 从注册 I/O 到 SYSTEM 权限提升
在非分页池中创建空洞

触发漏洞

完成非分页池设置后,我们可以触发释放后重用漏洞。为了触发它,漏洞利用程序必须创建两个并发线程:一个通过发出读/写请求持续使用已注册的缓冲区,另一个遍历所有已注册的缓冲区并尝试注销它们。如果竞争条件成功,afd.sys 将在 CachedArray 中有指向已释放 RIOBuffer 结构的条目。

Windows Sockets 从注册 I/O 到 SYSTEM 权限提升
触发释放后重用漏洞

提权

一旦触发了释放后重用漏洞,一些 RIOBuffer 结构被释放但仍在 afd.sys 缓存中使用。漏洞利用程序必须重新分配它们以获得对 RIOBuffer 结构内容的控制。漏洞利用程序可以再次使用无缓冲条目。如果漏洞利用操作成功,漏洞利用程序现在控制了缓存中仍然存活的一些 RIOBuffer 结构的内容。

Windows Sockets 从注册 I/O 到 SYSTEM 权限提升
漏洞利用使用一些无缓冲条目

为了创建任意读取和任意写入原语,漏洞利用程序利用了 RIOSend() 和 RIOReceive() 函数的内部机制。这两个函数使用 RIOBuffer 结构的 AssociatedMDL 字段来向/从已注册的缓冲区复制数据。由于漏洞利用程序可以控制 AssociatedMDL 字段,它可以在用户态构造一个任意的 MDL,其 MappedSystemVa 字段指向任意地址。

通过发出 RIOSend() 调用,数据从 MappedSystemVa 地址复制到发送到网络的数据包。反之,通过发出 RIOReceive() 调用,数据从网络接收的数据包复制到 MappedSystemVa 地址。在我们的案例中,任意写入原语非常有趣。任意写入原语的一个很好的内核地址候选是漏洞利用进程的 _SEP_TOKEN_PRIVILEGES 结构的地址。通过覆写 _SEP_TOKEN_PRIVILEGES 结构,可以提升到 nt authoritysystem 权限。

结论

在这篇博文中,我们描述了 afd.sys Windows 驱动程序中的一个释放后重用漏洞,该漏洞在 2024 年 8 月的补丁星期二中得到修复。我们还展示了一种可能的利用技术,以实现提升到 nt authoritysystem 权限。我们在 Windows 11 21H2 机器上测试了这个漏洞利用程序,堆喷射例程证明非常稳定。

原文始发于微信公众号(securitainment):Windows Sockets 从注册 I/O 到 SYSTEM 权限提升

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年1月9日22:26:47
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Windows Sockets 从注册 I/O 到 SYSTEM 权限提升https://cn-sec.com/archives/3611769.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息