[CVE-2023-21554] Windows Message Queuing 远程代码执行漏洞分析
0. 前言
这篇文章介绍了 CVE-2023-21554 漏洞,该漏洞存在于微软的消息队列 (MSMQ) 服务中,由于该服务没有正确对数据包中的数据进行验证,攻击者利用该漏洞可以实现远程命令执行。
由于对 MSMQ 服务并不熟悉,我首先花费了大量篇幅介绍该服务及相关数据结构,之后通过补丁对比确定了漏洞所处位置,然后通过修改官网文档提供的数据包示例尝试进行漏洞触发,最后对漏洞原理及整个漏洞分析过程进行了总结。
1. 背景知识介绍
1.1 MSMQ
Message Queue(消息队列):一种异步的消息传递机制。应用程序可以将消息发送的消息队列,也可以从消息队列中读取消息,消息队列让发送方程序和接受方程序能够彼此异步操作。
微软实现的消息队列服务就叫做 Microsoft Message Queuing (MSMQ),可以在 “启用或关闭 Windows 功能” 中安装该服务。
MSMQ 包含各种协议,用于支持微软实现消息队列服务:
这里主要关注和消息传递有直接关联的 Message Queuing Binary Protocol,这个协议定义了一种在位于不同主机上的两个消息队列之间可靠地传输消息的机制。
根据文档,消息队列系统中传递的消息具有一系列消息属性,包含了和消息有关的各种元数据,以及一个叫做消息体的特殊属性,包含了应用程序要发送的真正信息,消息体的内容没有任何限制。
在进行消息传递时,客户端会首先向服务端建立一个基于 TCP 或者 SPX 的协议会话,其中服务端 TCP 连接使用 1801 端口,SPX 连接使用 876 端口,客户端端口任意。
协议会话通过发送 EstablishConnection 数据包、ConnectionParameter 数据包进行初始化,之后双方可以任意发送 UserMessage 数据包,并通过 SessionAck 数据包、OrderAck 数据包以及 FinalAck 数据包进行确认。
1.2 UserMessage
重点关注 UserMessage 数据包,这个数据包总是包含一个完整的消息,它用于在发送方和接收方之间传递应用程序定义以及管理确认消息。
从结构上说,UserMessage 数据包由一系列头部组成,包括必须头部和可变头部。必须头部必须出现在所有的 UserMessage 数据包中,包括 BaseHeader、UserHeader 和 MessagePropertiesHeader,可变头部在文档中直接列出的包括 TransactionHeader、SecurityHeader、DebugHeader、SoapHeader、MultiQueueFormatHeader 和 SessionHeader,但实际上文档中还介绍了其他头部信息,通过 IDA 进行反编译也可以发现一些头部信息。
对于 UserMessage 的处理位于 mqqm.dll 中的 CQmPacket::CQmPacket
函数中,通过对该函数的大致观察,可以发现它按照固定的顺序线性处理 UserMessage 中的各个头部(在 IDA 中使用 段/section 表示各个头部):
1.3 段结构
虽然不同段具有的字段有差异,但是总体结构具有相似性,都是由几个固定字段 + 几个可变字段组成。
由于 CQmPacket::CQmPacket
是按照固定顺序线性处理 UserMessage
数据包,因此它一定能够使用某种方法定位下一段的位置。大致存在以下几种情况:
- 段中可变字段的长度不固定,存在 Flags 字段标明是否存在某个可变字段,同时存在字段标明了可变字段的长度;
- 段中可变字段的长度固定,存在 Flags 字段标明是否存在某个可变字段;
- 段中不包含可变字段,因此总体长度固定,不存在和长度相关的字段;
举例来说:
SecurityHeader 符合第一种情况,其结构如下,其中 Flags 字段中存在标志位表明 SecurityData 字段是否存在,几个 *Size
字段加在一起就是 SecurityData 字段的大小:
1 2 3
0 7 5 3 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Flags | SenderIdSize |
| EncryptionKeySize | SenderCertSize |
| ProviderInfoSize | |
| SecurityData (variable) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
DebugHeader 符合第二种情况,其结构如下,其中 Flags 字段中存在标志位表明 QueueIdentifier 字段是否存在,该字段固定 16 字节长度:
1 2 3
0 7 5 3 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Flags | Reserved |
| |
| |
| QueueIdentifier (16 bytes, optional) |
| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
SessionHeader 符合第三种情况,该段固定 16 字节大小,其结构如下:
1 2 3
0 7 5 3 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| AckSequenceNumber | RecoverableMsgAckSeqNumber |
| RecoverableMsgAckFlags |
| UserMsgSequenceNumber | RecoverableMsgSeqNumber |
| WindowSize | Reserved |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
2. 补丁对比
通过比较修复前后的 mqqm.dll 文件,发现微软修复了 CQmPacket::CQmPacket
函数以及多个 SectionIsValid
函数:
根据交叉引用,SectionIsValid
函数由 CQmPacket::CQmPacket
调用,主要用于在遍历 UserMessage 的时候检查每个段中的内容是否合法,比如说某个标志位设置该段需要包含某个可变字段,这个函数就会检查该段是否真的包含了该可变字段,以及字段的长度是否符合要求这类问题,除此之外还会检查根据当前段中字段内容计算得到下一段的地址,是否超过了整个数据包的范围。
经过检查,修复代码修改逻辑主要是在获取下一段地址的部分,每个 SectionIsValid
函数的修复方法略有差别,原因在于不同段结构不同,其计算下一段地址的方式也有差别,根据 1.3 小节总结的三种不同类型的段结构,修复方法总结如下:
-
段结构类型一:可变长度不固定,存在字段标明可变字段长度,包括
CPropertyHeader
,CSecurityHeader
,CUserHeader
和CBaseMqfHeader
这类函数增加了对和字段长度相关字段中数值的检查(是否溢出),并在最后调用了一个新增函数GetNextSectionPtrSafe
,且第三个参数varlen
通过字段数值计算得到;注:由于
CBaseMqfHeader
段结构简单,实际并未调用该新增函数,这里作为特殊情况不考虑 -
段结构类型二:可变长度固定,存在字段标明可变字段是否存在,包括
CDebugSection
和CXactHeader
这类通过检查 Flag 字段判断是否存在可变字段,同样在最后调用了新增函数GetNextSectionPtrSafe
,且第三个参数varlen
为 0 或者固定数值(可变字段的固定长度); -
段结构类型三:不包含可变字段,可想而知这种类型的段不涉及此类问题;
-
其他结构类型:包括
CMsgGroupHeader
和CMsgDeadletterHeader
其中CMsgGroupHeader
在文档中没有记录,但是通过分析代码,发现其结构和CMsgDeadletterHeader
类似,两者均包含可变字段,且字段长度不固定,可变字段以\x00
结尾,因此没有使用单独的字段说明可变字段的长度 这类函数增加了\x00
位置是否超过整个数据包结尾的代码,没有调用GetNextSectionPtrSafe
。
其中 GetNextSectionPtrSafe
函数定义如下:
其中 len
和 varlen
参数分别对应一个段中的固定字段长度和可变字段长度,相加得到段的总长度。
经过检查 CQmPacket::CQmPacket
中新增的代码也具有同样的功能,除了文档中已列出以及 symbols 中明确列出的几个头部之外,在 CQmPacket::CQmPacket
函数中还通过 UserHeader 的 Flag 字段的一些标志位索引遍历了其他段,但是在文档对于 Flag 的说明中,相关标志位显示为 Reserved,我怀疑可能是新增加的功能,但是文档并没有及时更新,或者这部分信息就是不对外公开的。在对这部分未公开段的索引遍历中,也增加了数值是否溢出的检查以及 GetNextSectionPtrSafe
的调用。
根据以上总结,修复后的代码增加了对可变字段长度数值的系统性检查,即 GetNextSectionPtrSafe
函数。但是有一点要注意,这并不表示未修复的代码中完全不包含相关检查内容,实际上部分段在未修复之前也有同样的检查内容,只不过分散在各个函数中,我并没有对这部分内容进行整理。
注:一开始我并没有注意到未修复的代码中包含此类检查,是在下面尝试触发漏洞的时候发现的,因此在下面尝试触发漏洞时,走了一些弯路。
3.3 小节发现问题后的补充内容:
主要关注的位置还是应该在 CQmPacket::CQmPacket
函数,在对未公开段进行索引时,并没有对计算得到的下个段位置进行检查验证,以下为部分截图(有多个段的处理没有写入单独的函数,而是将代码直接放在 CQmPacket::CQmPacket
中,其中包含公开段和未公开段,其中部分缺少相应检查,这里只截图了第一个):
修复后代码如下:
3. 漏洞触发
3.1 首次尝试
注:首次尝试是基于我的错误理解上的。
在 1.3 小节 段结构中,我们知道当段中可变字段的长度不固定时,存在 Flags 字段标明是否存在某个可变字段,同时存在字段标明了可变字段的长度,关键问题就在这里,段中的所有字段都是从客户端发过来的,也就是攻击者可控的,CQmPacket::CQmPacket
在对 UserMessage 进行遍历时,会根据攻击者可控字段计算下一段的位置。
而根据上面补丁对比得到的信息,我们知道在未修复之前的代码中,并没有对和可变字段长度有关的字段进行有效检查(实际上部分段是有检查的),也就是说修改类型一的段结构中可变字段长度字段,可能会触发漏洞。
在微软官方文档中,提供了一个MSMQ 数据包示例【2】,我们可以直接利用该数据包,修改其中 SecurityHeader 中可变字段长度字段的数值。
注:因为 UserHeader 结构比较复杂,SecurityHeader 是之后第一个出现的符合要求的段结构,因此一开始选择了它
首先在目标虚拟机(系统版本:Windows 10 专业版 19044.2364)中安装 Microsoft 消息队列服务器,安装后可以看到正在运行的 MSMQ 服务:
并且在监听 1801 端口:
直接根据官方文档发送原始的 Establish Connectiong Request 和 Connection Parameters Request,并修改 User Message 中 SecurityHeader 中的 SenderIdSize。
SenderIdSize 字段一共两个字节,我尝试测试了一些数值,结果没有引起 mqsvc.exe 进程的崩溃,因此需要进一步分析 CQmPacket::CQmPacket
函数后面的行为,确认篡改的可变字段长度数值对后面的代码有什么影响。
注:我测试了小于实际的数值,大于实际的数值,以及负数。但实际上 SecurityHeader 是有对可变字段长度数值进行检查的,它检查了数值是否为负数,加法是否会溢出,以及计算得到的下个段地址是否超出了数据包的前后范围。因此修改 SecurityHeader 中可变字段长度数值只会让计算得到的下个段地址在数据包范围内变动,这种情况下由于下个段的位置不对,错误的数据更大概率会导致系统识别到问题进行错误处理,而不是触发漏洞崩溃。
3.2 CQmPacket::CQmPacket
详细分析
前面提到过,CQmPacket::CQmPacket
函数就是在对 UserMessage 做遍历,其中有一些字段名没有直接在 symbol 中进行注明,经过进一步和文档中的段结构进行对比,我又在 IDA 的代码中发现了几个未在 symbol 注明,但文档中有提及的段结构,但是仍旧有部分段结构没有确定名称,但是这并不影响对漏洞的分析。
经过注释后的 CQmPacket::CQmPacket
函数如下:
为了显示完整,后面对于段的具体操作我都进行了折叠,但是逻辑和上面两个没有折叠的代码是相同的,只是由于段结构的不同,具体细节可能有差异。是否对段进行处理基本上都是根据 BaseHeader 或者 UserHeader 中的标志位决定的,我在注释中也进行了标注。
全部段遍历完之后,函数最后通过对参数进行检查,符合条件的情况下会再次构造 ExtensionHeader、SubqueueHeader、ExtendedAddressHeader。这里的功能我不太确定,但是不影响漏洞的触发。
3.3 如何触发
分析到这里我才发现了自己的问题,漏洞的真正位置在 CQmPacket::CQmPacket
函数的段处理中,我选择下图中的段作为漏洞触发目标,这是第二个存在问题的段,它的结构比较简单,不像第一个段需要计算两次位置信息,接下来使用 TargetSection 引用该段:
在 3.2 小节的第二张截图中,我们可以看到 CQmPacket::CQmPacket
函数在遍历完所有段之后,尝试构造一个 ExtensionHeader,在构造时未经任何检查使用了语句 *nxtSec = 12i64;
。
如果执行流程可以到达 TargetSection 的处理逻辑,就可以控制 nxtSec 的数值,实现任意地址写入固定数值(实际上并不是任意地址,而是受到可变字段长度数值可表示的数值范围影响)。
整个漏洞触发逻辑是这样的:
- 修改 UserHeader 中标志位,使其满足
(userHeaderFlag & 0x4000000) != 0
; - 在数据包最后构造 TargetSection,使其处理逻辑中 nxtSec 的计算可以得到目标写入地址,这里为了触发漏洞,可以设计一个非法地址;
- 修改数据包中其他字段,使数据包合法(例如数据包总长度之类的字段)。
最终构造数据包如下:
10 00 03 00 4C 49 4F 52 00 01 00 00 FF FF FF FF
D1 58 73 55 50 91 95 95 49 97 B6 E6 11 EA 26 C6
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
FF FF FF FF 4C 49 4F 52 EE 08 00 00 00 1C 28 04
1A 00 4F 00 53 00 3A 00 61 00 30 00 34 00 62 00
6D 00 30 00 32 00 5C 00 71 00 00 00 01 00 1C 00
00 00 00 00 00 00 00 00 00 00 00 00 01 05 00 00
00 00 00 05 15 00 00 00 AD 4A 9E BD 36 D9 FA 3D
63 A6 56 DA E8 03 00 00 0F 0F 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
08 00 00 00 00 00 00 00 12 00 00 00 12 00 00 00
00 00 00 00 04 80 00 00 01 68 00 00 00 00 00 00
6D 00 71 00 73 00 65 00 6E 00 64 00 65 00 72 00
20 00 6C 00 61 00 62 00 65 00 6C 00 00 00 61 00
61 00 61 00 61 00 61 00 61 00 61 00 61 00 61 00
11 11 11 11 00 00 00 40 00 00 00 40 00 00 00 00
当系统执行到 *nxtSec = 12i64;
这条语句时,可以在调试器中看到:
2: kd> p
MQQM!CQmPacket::CQmPacket+0x90a:
0033:00007ffd`d37688aa 48c7030c000000 mov qword ptr [rbx],0Ch
2: kd> dd rbx
DBGHELP: SharedUserData - virtual symbol module
00000115`f7f50520 ???????? ???????? ???????? ????????
00000115`f7f50530 ???????? ???????? ???????? ????????
00000115`f7f50540 ???????? ???????? ???????? ????????
00000115`f7f50550 ???????? ???????? ???????? ????????
00000115`f7f50560 ???????? ???????? ???????? ????????
00000115`f7f50570 ???????? ???????? ???????? ????????
00000115`f7f50580 ???????? ???????? ???????? ????????
00000115`f7f50590 ???????? ???????? ???????? ????????
继续执行会导致 mqsvc.exe 进程崩溃:
3.4 数据包详细解释
数据包格式和官方文档相同,包括 BaseHeader、UserHeader、SecurityHeader 和 MessagePropertiesHeader,具体修改字段如下(--
表示没有修改):
-
BaseHeader:
-- -- -- -- -- -- -- -- 00 01 00 00 FF FF FF FF
修改 PacketSize 为0x00000100
,修改 TimeToReachQueue 为0xFFFFFFFF
。 其中 PacketSize 为总数据包大小,需要四字节对齐,如果一开始构造的数据包不符合要求,后面补零即可。 TimeToReachQueue 表示数据包必须要在这个时间范围内到达目标 QM,0xFFFFFFFF 表示无限时间,这里修改只是为了确保数据包不会被丢掉; -
UserHeader:
-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 04 -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
修改了标志位中最后一个字节,为了满足条件
(userHeaderFlag & 0x4000000) != 0
; -
SecurityHeader 没有变化;
-
MessagePropertiesHeader 进行了大幅度的删减,并减小了对应的字段长度数值,和漏洞无关,只是为了让数据包简洁一点;
-
TargetSection:
11 11 11 11 00 00 00 40 00 00 00 40
前四个字节没有作用,可以设置任意数值。涉及到 nxtSec 计算的是后面的两个四字节数据:nxtSec = (char *)nxtSec + ((*((_DWORD *)nxtSec + 2) + 15 + *((_DWORD *)nxtSec + 1)) & 0xFFFFFFFC);
为了能够触发漏洞,我选择了一个大的负数 0x80000000,让这两个四字节数据分别等于 0x40000000,最后计算得到的地址不可访问。
4. 漏洞总结
MSMQ 服务的 UserMessage 数据包由多个段组成,段结构具有不同类型,其中一部分段中包含长度可变的字段,并且使用单独字段说明可变字段的长度,这样在获取下个段位置时,会使用这个单独字段进行计算。
MSMQ 服务器在处理 UserMessage 数据包时,使用 CQmPacket::CQmPacket
函数对数据包中的各个段进行线性遍历,部分段存在官方文档并在 symbol 中存在单独的函数,部分段只在官方文档中进行了介绍或者完全未公开,这部分段没有单独的函数,代码直接写在 CQmPacket::CQmPacket
函数中。
可能是因为未完全公开的缘故,开发人员在处理这部分段时,并没有对数据包中的数据进行严谨的判断,导致获取下个段位置时,计算得到的段位置可能超出了数据包所在内存范围,导致了内存越界写入。
为了方便后续功能扩展,修复后的代码增加了字段检查接口函数,并在每个需要检查的位置添加了该函数的调用。
5. 总结
该漏洞的难点主要在 MSMQ 服务本身,网上的资料只有官方文档,通过 Wireshark 抓包无法解析出数据包结构,因此只能通过文档中的资料恢复数据包中的内容以及 IDA 中的数据结构。
在漏洞分析过程中,由于补丁对比得到的修复位置很多,增加的代码不仅是为了修复漏洞,也是为了支持后续功能,我对漏洞原理的判断产生了一些误差,因此花费了更多的时间进行调试。
目前分析到的代码显示该漏洞只能实现一定地址范围内的写固定值操作,如果想要实现漏洞利用,还需要分析后续代码功能,构造更加精细的数据包,实现内存的任意写。