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 组件的可视化表示。
漏洞
这个释放后重用(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.sys
char __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.sys
RIOBuffer *__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.sys
RIOBuffer *__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.sys
void __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)漏洞。
漏洞利用
利用此漏洞涉及以下步骤:
-
堆喷射阶段: -
用伪造的 RIOBuffer
结构喷射非分页池 -
在非分页池中创建空洞 -
注册 I/O 缓冲区 -
在之前分配的 RIOBuffer 结构上触发释放后重用漏洞 -
提权
堆喷射阶段
由于易受攻击的缓冲区分配在非分页池中,我们使用的喷射技术利用了命名管道来用任意大小的缓冲区填充非分页池区域。对命名管道堆喷射技术不熟悉的读者可以在这里和这里了解更多信息。目标大小与 RIOBuffer
的大小相同,为 0x20
字节。有许多使用命名管道进行堆喷射的记录技术。在这种情况下,我们使用了无缓冲条目。与有缓冲条目相比,无缓冲条目的优势在于它们没有头部。这使它们非常适合完全匹配 RIOBuffer
的大小。喷射后,非分页池布局如下:
需要注意的是,无缓冲条目的内容完全由漏洞利用程序控制。下一步是通过关闭一些先前分配的命名管道在非分页池内创建空洞。这为 RIOBuffer
结构在无缓冲条目旁边分配留出了空间。为了填充这些空洞,漏洞利用程序必须调用 RegisterBuffer()
函数。
触发漏洞
完成非分页池设置后,我们可以触发释放后重用漏洞。为了触发它,漏洞利用程序必须创建两个并发线程:一个通过发出读/写请求持续使用已注册的缓冲区,另一个遍历所有已注册的缓冲区并尝试注销它们。如果竞争条件成功,afd.sys 将在 CachedArray
中有指向已释放 RIOBuffer
结构的条目。
提权
一旦触发了释放后重用漏洞,一些 RIOBuffer
结构被释放但仍在 afd.sys 缓存中使用。漏洞利用程序必须重新分配它们以获得对 RIOBuffer
结构内容的控制。漏洞利用程序可以再次使用无缓冲条目。如果漏洞利用操作成功,漏洞利用程序现在控制了缓存中仍然存活的一些 RIOBuffer
结构的内容。
为了创建任意读取和任意写入原语,漏洞利用程序利用了 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 权限提升
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论