小时候,奶奶总是告诉我,什么复杂协议的解析、文件格式的解析,这种地方最容易写错代码了。长大后我开始学习内存漏洞,发现这类漏洞不仅容易被写出来,而且攻击者也容易 fuzz 出来,属实是内存安全的重灾区。今天我们就来看看协议解析的实现中容易出现的两类问题。

2023 年,新加坡 STAR Labs 研究员 Nguyễn Hoàng Thạch(@hi_im_d4rkn3ss)在 Pwn2Own Vancouver 通过一条 VMware Workstation Escape 利用链拿下了 八万美刀和 8 个 Master of PWN 点数。他所用到的两个漏洞 CVE-2023-20869、CVE-2023-20870 非常适合拿来作为解析类漏洞的例子,let’s dive in。

协议的禁区:非法构造

CVE-2023-20869 发生在 VMware 的虚拟蓝牙模块,为此首先需要介绍这个模块是干啥的。

VMware 与蓝牙

蓝牙作为一种无线通信协议,它的整个协议栈架构由三层组成:Application、Host、Controller。App 只管用就行了;Host 层包括蓝牙最常用的那些概念,比如功能发现、配对等;Controller 层则负责链路层和物理层,通常是由硬件实现的。

image.png

在 Host 层和 Controller 层之间有着软件和硬件的分水岭,换句话说通常都是由软件去提供 Host 侧的功能,然后通过设备 IO 向硬件发送指令,由硬件完成 Controller 的功能。在 Host 和 Controller 之间的接口格式也由蓝牙协议规定,就叫 HCI(Host Controller Interface)。诸如“开始扫描“、”建立连接”、“连接成功”以及数据的传输都遵循 HCI 格式。HCI 数据包需要通过真实的物理连接(CPU 和蓝牙芯片之间的连接)进行传输,这可以是嵌入式里比较常见的 UART、也可以是 USB,这就无所谓了。

对蓝牙协议栈感兴趣可以看看 xuanxuanblingbling 师傅写的 用西湖论剑 IoT 闯关赛蓝牙赛题理解蓝牙协议

VMware 为了使虚拟机用上主机的蓝牙芯片可谓煞费苦心。首先需要知道一个前提:同一个设备通常来说不能被多套驱动一起使用。举个例子:在我安装了 Windows 和 Fedora Linux 双系统的电脑上,如果我直接从 Windows “重启”到 Linux,蓝牙耳机功能就会完全爆炸。几个月后我才发现 Windows 的“重启”不会完全地对设备进行关闭,只有“关机”才会。所以解决办法是每次都确保 Windows 彻底关机,再开机进入 Linux。

类似地,在主机蓝牙还在工作的时候,虚拟机上的软件显然不能向蓝牙设备直接发送 HCI 请求。所以 VMware 作为中间人实现了一个蓝牙代理:在虚拟机侧提供一个模拟出来的蓝牙芯片,对其接受到的所有 HCI 数据包进行解析,然后在主机侧代为执行。

以具体例子说明,当虚拟机和一个经典蓝牙设备建立连接时,它会向目标设备发送“连接”、“配对”的 HCI 请求。VMware 在接受到并解析这些指令,理解了“虚拟机需要做这些操作”后,自行调用主机蓝牙 API 和目标设备完成实际的连接与配对。在进行 PIN 配对时,提示输入 PIN 码的对话框会出现在主机侧而非虚拟机侧,这就表明实际的蓝牙连接和配对在设备和 Host 之间发生。VMware 通过不断地解析和构造 HCI 请求或回应,给虚拟机维持一个蓝牙设备的假象。

出于安全考虑,VMware 会限制虚拟机的蓝牙能力,比如在 Sharing Bluetooth Devices with a Virtual Machine 文档中有提到:

  • 只允许向外发起连接(outgoing connections)
  • 远端设备看不到虚拟机试图通知/发布的服务(如果虚拟机想当服务器、对外暴露服务记录,远端是不可见的)
  • 主机完全控制蓝牙适配器的名称/可发现性、以及配对过程/PIN 展示
  • 主机蓝牙设备上的供应商特定功能不会传给虚拟机,虚拟机看到的是通用 VMware 虚拟蓝牙设备

从第二条限制可以发现,VMware 不仅需要解析 HCI 层面的数据,也同样需要解析 Host 层协议的数据,才能做到这种功能。这对于一个复杂的协议栈来说可不是好事。

CVE-2023–20869 栈溢出

SDP(Service Discovery Protocol)是经典蓝牙中的服务发现协议,功能类似老黄页目录,允许设备相互告知能提供的服务类型及其属性。

image.png

SDP 协议定义了一套自己的序列化机制,在 SDP 规范 中有详细的定义。下图展示了几种数据被表示成规范中 Data Element 的方式,我们可以看到这个规范是经典的 TLV 结构(Type-length-value,在通信协议的编码或者说序列化的领域中非常常见,类似的还有 Linux 内核的 Netlink 接口,见 官方文档,或是 TLS Extension 的编码)。

image.png

CVE-2023-20869 发生在 VMware 对 SDP 数据包进行反序列化的代码中。下面这个函数的功能是解析 uint 类型的 Data Element。逻辑很简单,先把目标数据用 copy_out 复制到 buf 中,然后进行大小端转换并放到参数提供的 out 指针中。

char __fastcall de_read_uint(
        unsigned __int64 **data,
        unsigned int len,
        unsigned __int64 *out_little,
        unsigned __int64 *out_large)
{
  char result; // al
  unsigned __int64 buf[4]; // [rsp+20h] [rbp-58h] BYREF

  result = copy_out(*data, &buf[2], len);
  if ( result )
  {
    memcpy((char *)&buf[2] - len, &buf[2], len);
    *out_little = ((unsigned int)(HIWORD(buf[1]) | HIDWORD(buf[1]) & 0xFF0000) >> 8)
                | (((HIDWORD(buf[1]) << 16) | WORD2(buf[1]) & 0xFF00u) << 8)
                | ((buf[1] & 0xFF000000
                  | ((buf[1] & 0xFF0000 | ((buf[1] & 0xFF00 | (LODWORD(buf[1]) << 16)) << 8 << 8)) << 16)) << 8); // 这里是大小端转换
    if ( out_large )
      *out_large = ((unsigned int)(HIWORD(buf[0]) | HIDWORD(buf[0]) & 0xFF0000) >> 8)
                 | ((WORD2(buf[0]) & 0xFF00u | (HIDWORD(buf[0]) << 16)) << 8)
                 | ((buf[0] & 0xFF000000
                   | ((buf[0] & 0xFF0000 | ((buf[0] & 0xFF00 | (LODWORD(buf[0]) << 16)) << 8 << 8)) << 16)) << 8); // 这里也是大小端转换
    return sub_14083BB60(data, len);
  }
  return result;
}

规范中允许 uint 类型有 5 种不同长度,也就是下表中的 0, 1, 2, 3, 4 五种长度描述符:

image.png

长度描述符的具体含义见下表,0 到 5 分别对应 1 字节到 16 字节。注意,这里的 5 到 7 描述符是很灵活的,比如 5 就允许我们用一个 8 bits 的数来表示实际的大小。

image.png

程序调用 de_read_uint 前,会用通用的长度描述符解析函数计算出 Data Element 的 Size,然后作为 len 传给这个函数。然而程序并没有对 “Valid Size Descriptor Values” 做校验,导致恶意的 SDP 包构建者可以对 uint 使用 5 到 7 号描述符,构造一个拥有巨大 len 的 uint。

于是 de_read_uint 中就会发生栈溢出:

char __fastcall de_read_uint(
        unsigned __int64 **data,
        unsigned int len,
        unsigned __int64 *out_little,
        unsigned __int64 *out_large)
{
  char result; // al
  unsigned __int64 buf[4]; // [rsp+20h] [rbp-58h] BYREF

  result = copy_out(*data, &buf[2], len); // OVERFLOW
  if ( result ) // 一定会继续
  {
    memcpy((char *)&buf[2] - len, &buf[2], len); // ANOTHER OVERFLOW

第一次溢出是从 buf 开始的;第二次溢出更夸张,是从 buf-len 开始的,会直接把 memcpy 的返回地址给劫持掉。

不过这个漏洞有着触发条件的限制:主机显然要有蓝牙功能。VMware 虚拟机会默认开启共用主机蓝牙的功能,所以这个漏洞还是相对很危险的!

除此以外在 nccgroup 的复现报告 中还提到,主机附近必须存在可以建立连接的蓝牙设备。SDP 协议基于 L2CAP(Logical Link Control and Adaptation Protocol)协议,后者是蓝牙中的多路复用 + 封包 + 适配层,类似于 TCP。TCP 和 L2CAP 都是有状态的协议,需要先握手建立信道后才能传数据。因此,这个攻击似乎会要求虚拟机首先能够和某个蓝牙设备建立 L2CAP 连接,然后才能发送 SDP 包触发解析和栈溢出漏洞,否则一个单独的 SDP/L2CAP 包可能会直接被 VMware 扔掉。这个限制是否具体存在我并没有验证!有可能不需要真的建立连接,直接发一个包 VMware 也会对它做解析。

程序员在实现协议的解析时,功能性大于安全性。当他看到文档里的 “valid size descriptor”时,发生的思维活动很可能是:“嗯,uint 字段可以是这些”,而不是“看来我需要限制 uint 字段的长度”。

对于这种非法的构造,通常软件开发者应该在测试阶段就将其拦截下来,这就要求测试工程师不仅仅测试正确性,还要测试安全性和鲁棒性。虽然使用模糊测试的方法很容易就能发现这种问题,但在实际软件开发中,往往还要考虑到赶工、激励机制(员工不会因为产品更安全得到绩效)、技能水平(好像未来水平会更低!)等问题,最终导致漏洞的发生。

协议的留白:未定义行为

CVE-2023-20870 信息泄露

这个漏洞位于 VMware 蓝牙设备处理 URB(USB Request Block,即 USB 数据包)的代码中。能够触发漏洞的是一个怪怪的 URB:

libusb_control_transfer(bluetooth_handle, LIBUSB_REQUEST_TYPE_CLASS | LIBUSB_ENDPOINT_IN, 0, 0, 0, dataOutput, 0x80, 1000);

按照字段解析,首先是 bmRequestType = LIBUSB_REQUEST_TYPE_CLASS | LIBUSB_ENDPOINT_INLIBUSB_REQUEST_TYPE_CLASS 表示 class-specific,意思是这个包的具体语义由设备所属的特定 USB Class 规范定义,与其相对的是 LIBUSB_REQUEST_TYPE_STANDARD。另外,LIBUSB_ENDPOINT_IN 表示这个包由主机发出,尝试读取设备信息。(这里的“主机”是相对设备而言的,实际上是指 VMware 中运行的虚拟机)

然后是第二个参数:bRequest = 0。上面提到,这个包使用 class-specific 规则,此时 bRequest = 0 的实际语义由 USB 蓝牙设备所属的 Wireless Controller Class(0xE0)规范决定,但问题来了,Wireless Controller Class 规范根本没有定义bRequest = 0 时的行为,甚至不管 bRequest 是几都没有定义,这种情况下设备应该如何回应也属于未定义行为。通常来说,这种情况下设备应该返回一个异常,拒绝这个 URB。

我们继续看,wValue = 0wIndex = 0 这两个没什么好说的。dataOutput 是会被用来存放 response 数据的 buffer,之前提到了这是一个“尝试读取设备信息”的 URB。

然后是 wLength = 0x80,这表示主机期望设备的回应长度为 0x80 字节。当然设备可以回应的少一点也没关系,但 VMware 人真的怪好的,说 0x80 字节就回了 0x80 字节了。最后的 1000 表示 1000 毫秒的超时限制。

综上,这是一个主机发向虚拟蓝牙设备的请求包,想拿到的数据是未定义的,并且主机想要读取 0x80 字节的数据。

VMware 的虚拟蓝牙设备对于这个请求的处理方式:它直接用 malloc 申请了一块 wLength = 0x80 大小的空间作为 response,然后就把它发回了主机。此时 response 中的数据都是堆上未经初始化为 0 的遗留数据,从而产生信息泄露漏洞,攻击者可以通过在里面读取指针来计算出程序在虚拟地址空间中的加载位置,从而攻破 ASLR。

对于这种协议的留白,显式的拒绝才是最好的做法,在代码里多加一些 assert 总归是没错的!当然也有着例外,比如 Linux 的 libxml2 库就因为对 xml 解析过于严格,导致这么多年来大家都无法用 wine 安装 Adobe Photoshop(Adobe Photoshop 2025 Installer Now Working On Linux With Patched Wine)。反观 Windows 的 msxml3 解析器就非常宽容。问题发现者 PhialsBasement 在一月已经给 wine 提交了补丁,专门处理 Adobe 的神秘 xml 文件。在 wine 11.2 版本中,将会拥有丝滑的 Adobe Photoshop / Adobe Creative Cloud 体验(Wine-Staging 11.2 Brings More Patches To Help Adobe Photoshop On Linux)。

当然在这种情况下,究竟是应该夸赞 msxml3 的宽容,还是应该辱骂 Adobe 乱写 xml,空间留给读者。

这种信息泄露类型的漏洞其实也可以通过模糊测试找出来,可以先 hook 住主机上的堆内存分配器,将内容初始化为 deadbeef 之类的东西,然后从攻击者视角检查有没有此类数据被泄露出来。

总结

在这篇水文里,我们看到了不管是协议规定的非法构造,还是协议留白的未定义行为,都很容易导向实现中的漏洞。或许在下一篇博客中,我们将具体复现这两个漏洞,顺便带领读者(其实是笔者)领略一下 Windows PWN 的神秘领域。