Windows Sockets:从注册I/O到SYSTEM权限提升
概述
本文讨论CVE-2024-38193,这是afd.sys Windows驱动程序中的一个释放后使用漏洞。具体来说,该漏洞存在于Windows sockets的注册I/O扩展中。该漏洞已在2024年8月的补丁星期二中被修补。本文描述了该漏洞的利用过程。
首先,我们概述了Winsock的注册I/O扩展,描述了驱动程序的内部结构。然后我们分析了漏洞,并详细说明了利用策略。
预备知识
在本节中,我们概述了Winsock的注册I/O扩展,并描述了注册I/O扩展的相关结构。
Winsock注册I/O扩展
在Windows中,注册I/O(RIO)扩展可用于socket编程,以减少用户态程序在发送和接收数据包时发出的系统调用数量。RIO扩展的工作流程如下:
- 用户态程序注册大缓冲区。内核然后获取它们的内核映射。
- 用户态程序通过使用注册缓冲区的接收和发送缓冲区切片来发出发送和接收请求。
如果用户态程序想为sockets使用注册I/O扩展,它必须通过WSASocketA()函数创建一个socket。该函数的原型如下:
1
2
3
4
5
6
7
8
|
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的指针。该结构的定义如下代码清单所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
typedef struct _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()函数。该函数的原型如下:
1
2
3
4
|
RIO_BUFFERID RIORegisterBuffer(
_In_ PCHAR DataBuffer,
_In_ DWORD DataLength
);
|
DataBuffer
参数是指向要使用的缓冲区的指针,DataLength
参数是缓冲区的大小。该函数返回一个RIO_BUFFERID
缓冲区描述符,这是一个不透明的整数,用于在内核中标识注册的缓冲区。
为了从socket发送或接收数据,可以使用RIOSend()
和RIOReceive()
函数。这些函数接受一个RIO_BUF
结构,该结构描述了要使用的注册缓冲区的切片。RIO_BUF
结构的定义如下:
1
2
3
4
5
|
typedef struct _RIO_BUF {
RIO_BUFFERID BufferId;
ULONG Offset;
ULONG Length;
} RIO_BUF, *PRIO_BUF;
|
内核结构
结构定义通过逆向工程获得,可能无法准确反映源代码中定义的结构。恢复的注册缓冲区结构定义如下:
1
2
3
4
5
6
7
8
|
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元素创建了一个缓存。缓存中的每个条目具有以下结构:
1
2
3
4
5
|
struct CachedBuffer {
_DWORD IdBuffer;
_DWORD RefCount;
RIOBuffer *BufferPtr;
};
|
它包含以下字段:
- IdBuffer:与注册缓冲区关联的整数标识符。
- RefCount:缓存条目的引用计数器。
- BufferPtr:指向此条目镜像的RIOBuffer结构的指针。
下图是afd.sys RIO组件的可视化表示。
漏洞
释放后使用漏洞是由AfdRioGetAndCacheBuffer()和AfdRioDereferenceBuffer()函数之间的竞争条件引起的。AfdRioGetAndCacheBuffer()函数需要访问涉及发送/接收操作的注册缓冲区,因此它使用_InterlockedIncrement()内部函数临时增加涉及的注册缓冲区的引用计数器。
AfdRioDereferenceBuffer()函数在用户模式应用程序调用RIODeregister() API函数时被调用。此函数检查用户模式应用程序想要注销的注册缓冲区的引用计数器值。如果引用计数器值为1,则函数继续释放结构。这些函数之间的竞争条件允许恶意用户强制AfdRioGetAndCacheBuffer()函数作用于已被AfdRioDereferenceBuffer()函数释放的注册缓冲区结构。
I/O缓冲区注册
当用户模式应用程序使用RIORegisterBuffer()注册I/O缓冲区时,这最终会调用afd.sys驱动程序中的AfdRioCreateRegisteredBuffer()函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
// 模块: 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。
I/O缓冲区使用
每当afd.sys内核驱动程序需要查找数组缓冲区时(例如,在发送或接收数据包时),它会临时增加该特定RIOBuffer结构的引用计数,并将其存储在缓存中。这种行为的一个例子可以在下面的AfdRioValidateRequestBuffer()函数中看到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
// 模块: 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;
return 1;
}
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()函数如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
// 模块: 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]中完成的,如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// 模块: 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()函数来驱逐缓存条目。如果操作成功,缓存条目将填充新的缓冲区数据。
I/O缓冲区注销
当用户态程序想要注销一个RIO缓冲区时,可以通过调用RIODeregisterBuffer()函数API来实现,该API随后调用afd.sys内核驱动程序中的AfdRioDereferenceBuffer()函数。
AfdRioDereferenceBuffer()函数如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// 模块: 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()函数的线程释放的内存区域,从而导致释放后使用漏洞。
利用
利用此漏洞涉及以下步骤:
- 堆喷洒阶段:
- 用假的RIOBuffer结构喷洒非分页池。
- 在非分页池中创建空洞。
- 注册I/O缓冲区。
- 在先前分配的RIOBuffer结构上触发释放后使用漏洞。
- 权限提升。
堆喷洒阶段
由于易受攻击的缓冲区分配在非分页池中,我们使用的喷洒技术利用命名管道用任意大小的缓冲区填充非分页池区域。不熟悉命名管道堆喷洒技术的读者可以在这里和这里阅读更多关于它们的信息。目标大小与RIOBuffer的大小相同,即0x20字节。有许多记录的使用命名管道进行堆喷洒的技术。在这种情况下,我们使用了无缓冲条目。与缓冲条目相比,无缓冲条目的优点是没有头。这使得它们非常适合完美匹配RIOBuffer的大小。喷洒后,非分页池布局如下:
重要的是要注意,无缓冲条目的内容完全由漏洞利用控制。下一步是通过关闭一些先前分配的命名管道在非分页池中创建空洞。这为RI