Paint it blue: Attacking the bluetooth stack
蓝牙一直是攻击者有吸引力的目标,因为它几乎无处不在(电视、汽车充电器、智能冰箱等)。在移动设备上尤其如此,它在其中以特权进程运行,可以潜在访问麦克风、通讯录等。 2023年9月和10月,Android发布了关于其蓝牙栈(氟化物)中关键漏洞的安全公告,这些漏洞可能导致远程代码执行。CVE-2023-40129是GATT协议中的一个整数下溢漏洞,无需用户交互或认证即可访问。其利用被证明非常困难,因为它会导致64 KB的堆溢出,如同海啸般摧毁沿途的一切,使蓝牙进程几乎必死无疑。 在这篇博客文章中,我们详细介绍了如何在Android的两种原生分配器:Scudo和Jemalloc上利用此漏洞。
蓝牙栈
上图说明了蓝牙栈。它分为两个主要部分:控制器栈位于蓝牙芯片中,而主机栈由操作系统实现。HCI(主机控制器接口)接口允许两个组件之间的通信。控制器主要管理物理和逻辑传输。我们的利用依赖于ACL,即路由数据帧的异步传输。在Android上,主机栈——称为氟化物——作为守护进程在用户空间运行。一旦ACL链路建立,可以发起L2CAP(逻辑链路控制和适配协议)连接以访问各种蓝牙服务(BNEP、HID、AVCTP等),这些服务提供众所周知的功能,如网络共享、视频流等。每个服务由唯一的协议服务复用器标识:
| 服务 | PSM |
|---|---|
| SDP(服务发现协议) | 0x0001 |
| RFCOMM(无线电频率通信) | 0x0003 |
| BNEP(蓝牙网络封装协议) | 0x000F |
| HID(人机接口设备) | 0x0011(控制),0x0013(中断) |
| AVCTP(音频/视频控制传输协议) | 0x0017(控制),0x001B(浏览) |
| AVDTP(音频/视频数据传输协议) | 0x0019 |
| GATT(通用属性协议) | 0x001F |
| GAP(通用访问配置文件) | 0x1001, 0x1003, 0x1005, 0x1007 |
每个服务的相关代码位于system/stack/目录中。每个服务通过以下API注册:
|
|
参数sec_level定义访问服务的安全级别。大多数服务要求连接经过身份验证和加密。
只有极少数服务无需身份验证即可访问——包括SDP、RFCOMM和GATT。但即使连接开始时未经身份验证,某些操作(如写入GATT属性)可能随后要求身份验证,从而进一步减少攻击面。
BlueBlue 框架
基于BlueBorne项目的L2CAP测试框架,我们开发了自己的名为BlueBlue的框架。它使用Scapy构建和解析HCI帧。该框架允许建立ACL链路并打开L2CAP连接。 它还支持蓝牙规范中的多种功能,如L2CAP分段和ERTM传输模式。它实现了我们使用的所有主机栈功能,这使我们有很大的自由探索新的想法。 只需几行代码,我们就可以建立ACL连接,连接到L2CAP服务,发送命令并接收响应:
|
|
漏洞
CVE-2023-40129是GATT服务器中的一个漏洞。GATT协议用于公开简单的键值类型属性。键是16位的句柄,而值是简单的原始数据。操作码GATT_REQ_READ_MULTI_VAR允许一次读取多个属性。
请求由操作码GATT_REQ_READ_MULTI_VAR后跟GATT句柄列表组成:
![请求结构示意图]
响应由操作码GATT_RSP_READ_MULTI_VAR后跟每个请求属性的长度和值组成:
![响应结构示意图]
请求在函数gatt_process_read_multi_req()中处理,该函数负责获取请求属性的值:
|
|
函数gatt_sr_process_app_rsp()为每个属性调用。它将检索到的属性值(封装在变量p_msg中)传递给process_read_multi_rsp()函数,该函数将其复制到新分配的结构中,然后放入队列:
|
|
漏洞位于函数build_read_multi_rsp()中,该函数负责构建响应消息:
|
|
在函数顶部[0],我们可以看到分配了包含响应缓冲区的结构(p_buf)。分配的缓冲区大小取决于MTU,可以在打开L2CAP通道时配置。
接下来的代码段遍历GATT属性列表[1],并检查它们是否可以放入响应消息中。也就是说,对于每个属性,函数计算消息的预期总长度([2.1]和[2.2]),并检查它是否超过MTU。如果没有足够的空间来存储属性,则计算可以复制到缓冲区中的数据最大大小,如[3]所示。然而,len的计算是错误的,因为它没有考虑[2.2]中的加法。这个整数下溢导致[4]处发生基于堆的溢出(正如is_overflow = true指令所讽刺地预测的那样)。
以下代码片段触发了该漏洞。它连接到GATT通道并将MTU配置为55。然后,它请求属性9(16字节)4次:
|
|
溢出发生在尝试插入最后一个属性时。具体来说,在[3]处,p_buf->len的值为55(1+ 3*(16+2)),total_len为73。因此,len将下溢到-2(0xfffe),导致响应缓冲区中约64 KB的溢出。
最近,在OffensiveCon 2025上,发现该漏洞的Google Android红队演示了针对类似漏洞(CVE-2023-35673)在Pixel设备上的PoC利用。然而,他们的利用假设ASLR被禁用且攻击者已与目标设备配对。在接下来的章节中,我们详细介绍了在不依赖这些假设的情况下利用氟化物的利用策略。
Just Works, Still Works
2017年,BlueBorne文章披露了多个影响BlueZ(Linux栈)和氟化物(Android栈)的关键蓝牙漏洞。该文档描述了蓝牙规范中一种“晦涩”的身份验证方法:Just Works。Just Works身份验证模式允许无需用户交互的临时配对。它用于简单安全配对(Secure Simple Pairing - SSP),与没有键盘或显示屏的设备配对。在这种情况下,身份验证发生在没有PIN码验证的情况下。
我们在BlueBlue框架中实现了Just Works身份验证模式,并确认它在Android 13上仍然有效。 Just Works身份验证有一些限制。首先,氟化物将连接视为容易受到中间人(MITM)攻击,这阻止了对某些功能的访问,如读取或写入受保护的GATT属性。其次,使用Just Works会破坏与共享相同BDADDR地址的设备之间任何现有的配对。尽管有其局限性,这种身份验证模式仍然允许我们建立到各种蓝牙服务(如GAP、BNEP和AVCTP)的L2CAP连接。虽然触发该漏洞不需要预先身份验证,但我们利用它的方式需要连接到多个L2CAP通道。这就是Just Works模式发挥作用的地方。
利用原语
持久数据分配
利用此漏洞需要非常精确的堆塑造策略,以避免蓝牙守护进程因堆状态损坏而崩溃。
我们审计了氟化物的源代码,并识别了可以被滥用以强制进行大小可控、数据可控的分配并使这些分配持久化的功能。例如,在配置L2CAP通道时,如果配对设备不识别配置选项,它将发送被拒绝选项的精确副本(CONFIG REJ消息)。配置选项由类型(1字节字段)、长度(1字节字段)和大小任意的实际值组成,其内容完全可控。包含被拒绝选项的响应的分配在以下函数中进行:
|
|
一旦它被返回给发起连接的配对设备,分配就会被释放。但是,我们可以通过拥塞使其持久化。
拥塞
蓝牙规范在ACL层提供了流控制功能。如果其ACL接收缓冲区已满,蓝牙控制器可以清除其发送的ACL数据包头中的FLOW位,以防止配对设备在接收缓冲区被处理时发送更多数据包。此功能通常不暴露给主机,但我们可以通过修改控制器的固件来操纵它。幸运的是,Cypress控制器甚至有一个专有的HCI命令来启用它,因此模拟ACL拥塞相当简单。在这种状态下,一个配对设备(被声明为拥塞)仍然可以向远程设备发送数据包,但无法接收响应。远程设备将处理这些数据包,但无法响应。氟化物栈适当地处理拥塞。因此,如果我们的控制器声明ACL拥塞时发送无效的配置请求,氟化物将不会发回响应,而是将它们保存在队列中,直到拥塞停止。
值得注意的是,拥塞受配额限制。一旦达到配额,额外的消息将被丢弃而不是排队。但是,L2CAP信令通道不受此限制,这意味着我们可以分配几乎无限数量的CONFIG REJ响应消息。我们可以通过关闭相关的ACL连接来释放所有这些分配。
同样重要的是要注意,拥塞在氟化物栈级别被延迟,第一批响应将在发送到控制器后立即释放。以下函数检查数据包是否可以发送到控制器:
|
|
检查基于变量controller_xmit_window,该变量在函数l2c_link_send_to_lower_br_edr()中每次数据包传输到底层控制器时递减。其值在l2c_packets_completed中按确认的数据包数量递增。
ERTM传输模式
ERTM是建立在L2CAP之上的额外传输层,为其增加了可靠性:序列号、确认和重传。我们可以以两种不同的方式滥用此模式来强制进行持久分配:
- 发送具有意外序列号的L2CAP片段,例如
seq_tx = 1。只要序列号为seq_tx = 0的消息尚未发送,远程配对设备将保留所有后续消息在内存中。此行为很有用,因为它允许我们分配大小和内容受控的消息。 - 强制氟化物发送一个ERTM片段,但故意不确认它。该片段将保留在内存中,只要我们未确认它,我们可以在任何时候请求重传。
这两种技术中的每一种都允许每个L2CAP连接分配最多10个持久消息(这就是为什么我们不能依赖ERTM进行喷洒)。只有少数L2CAP通道(如GAP和AVCTP)支持ERTM模式,并且所有这些都需要与配对设备进行身份验证。
相对读原语
BT_HDR结构是一个有趣的目标。它广泛用于蓝牙代码库中,以表示各种数据,如L2CAP消息和ERTM片段:
|
|
BT_HDR结构具有可变长度。字段len表示数据缓冲区的长度。它还包括一个offset字段,指示数据在数据字段中的起始位置。为了在堆中构建相对读原语,我们可以重写待发送队列中待处理的ERTM片段的len字段并增加其大小,从而泄漏进程com.android.bluetooth的堆内容。
AVCTP浏览通道是构建读原语的良好候选。它使用ERTM,我们可以强制它传输大小受控的响应。GET_FOLDER_ITEMS请求允许我们请求音乐播放列表的元数据(例如,艺术家、歌曲名称、专辑名称)。通过发送带有精心选择属性的GET_FOLDER_ITEMS请求,我们可以使响应分配落入与易受攻击缓冲区相同的bin类中。如果我们修改与GET_FOLDER_ITEMS响应相关的BT_HDR结构,我们可以通过请求重传修改后的消息来泄漏信息。
相对写原语
ERTM支持分段。消息在函数do_sar_reassembly()中重新组装。在接收到第一个片段时,该函数使用初始片段中指定的大小分配一个BT_HDR结构:
|
|
后续片段使用BT_HDR结构的len和offset字段进行复制:
|
|
因此,通过破坏offset字段,然后发送带有数据的第二个片段,我们获得了一个相对写原语。
ASLR绕过和PC控制
氟化物栈使用libchrome的callback对象来处理各种事件。这个对象对于构建利用原语很有用,因为它有一个在callback触发时被调用的函数指针,以及传递给它的一些参数。因此,泄漏此对象将揭示libbluetooth的基地址,重写它将使我们能够控制执行流。
SDP Discovery Callback特别有趣,因为我们控制其分配,并且可以随时触发callback:
- callback对象在打开AVRCP通道时,在函数
SdpLookup中分配:1 2 3 4 5 6 7 8 9 10 11bool ConnectionHandler::SdpLookup(const RawAddress& bdaddr, SdpCallback cb, bool retry) { /* ... */ return avrc_->FindService(UUID_SERVCLASS_AV_REMOTE_CONTROL, bdaddr, &db_params, base::Bind(&ConnectionHandler::SdpCb, weak_ptr_factory_.GetWeakPtr(), bdaddr, cb, disc_db, retry)) == AVRC_SUCCESS; }Bind()方法负责分配callback对象(0x60字节)。callback结构填充了函数指针SdpCb及其参数:1 2 3void ConnectionHandler::SdpCb(RawAddress bdaddr, SdpCallback cb, tSDP_DISCOVERY_DB* disc_db, bool retry, uint16_t status) - callback在函数
avrc_sdp_cback()中被调用: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/****************************************************************************** * * Function avrc_sdp_cback * * Description This is the SDP callback function used by A2DP_FindService. * This function will be executed by SDP when the service * search is completed. If the search is successful, it * finds the first record in the database that matches the * UUID of the search. Then retrieves various parameters * from the record. When it is finished it calls the * application callback function. * * Returns Nothing. * *****************************************************************************/ static void avrc_sdp_cback(tSDP_STATUS status) { AVRC_TRACE_API("%s status: %d", __func__, status); /* reset service_uuid, so can start another find service */ avrc_cb.service_uuid = 0; /* return info from sdp record in app callback function */ avrc_cb.find_cback.Run(status); return; }
重写callback对象允许触发具有完全受控参数的任意函数调用。我们可以通过断开SDP通道的连接来触发callback,该通道是在连接到AVRCP浏览通道时由远程设备建立的。
在运行Jemalloc的设备上执行代码
利用场景
为了在使用Jemalloc的设备上获得代码执行,我们采用了以下策略:
- 塑造堆以重写两个
BT_HDR对象。第一个引用待发送队列中待处理的ERTM消息(reader),而第二个对应于待接收队列中待处理的ERTM片段(writer)。 - 触发溢出并破坏reader和writer对象。
- 分配callback对象(executor)。
- 请求重传修改后的数据包。
- 检索callback对象的内容。
- 使用相对写原语重写callback对象的内容。
- 触发callback。
堆塑造
第一步是塑造堆,以便用受控数据重写reader和writer对象。我们依赖于前面章节中描述的功能,例如拥塞和ERTM传输模式。具体来说,我们采用了以下策略来控制溢出的来源,并在目标bin中排列对象。
- 启用ACL拥塞。
- 喷洒多个CONFIG REJ消息。
- 在喷洒过程中交错分配ERTM消息,以
seq_tx > 0开始序列。ERTM分配用于在堆中创建“空洞”。 - 禁用ACL拥塞。CONFIG REJ分配被释放。
- 通过例如关闭相关连接来释放ERTM分配。ERTM分配被与GATT相关的对象在缓冲区溢出时重用。
下图说明了控制溢出源的堆状态。首先,我们喷洒几十个CONFIG REJ消息,以强制蓝牙栈级别的拥塞。然后,我们交替分配ERTM消息和CONFIG REJ消息,使得每个ERTM消息后面跟着受控数据。释放后,ERTM分配将被GATT对象(t_GATTS_RSP)重用,这些对象包含将被复制到易受攻击对象中的属性值。
![堆状态示意图]
现在我们已经有了控制溢出源所需的堆状态,让我们看看如何在同一bin中排列对象(reader、writer和executor)。作为参考,易受攻击对象的大小取决于MTU大小,计算如下:
|
|
我们决定以用于分配callback对象(executor)的相同bin为目标。通过应用与塑造源相同的策略,我们获得了所需的堆状态。在下图中,executor(callback)对象在溢出之后分配。 ![堆布局示意图]
ASLR泄漏
通过破坏reader对象的len字段,我们可以泄漏高达64 KB的数据,其中包括executor对象的内容。它包含多个函数指针,可用于推断libbluetooth库的基地址。通过分析泄漏的数据,我们注意到在某些情况下,art::Thread对象存在其中。它包含libart、libm和libc库中的多个函数指针。由于此对象很少出现在泄漏中,我们决定不在利用中使用它。
代码执行
通过重写SDP Discovery Callback对象获得代码执行。我们可以通过修改Run或SdpCb函数指针来实现。Run函数的唯一目的是准备并分派对实际callback SdpCb的调用。然而,这些指针都不实用,因为我们对参数没有精细的控制。
为了完全控制参数,我们决定重写Run函数指针以调用以下函数:
|
|
这个函数(gadget函数)允许我们调用任意函数,同时控制5个参数,其中前三个是QWORD。目标函数及其参数都从传递给gadget的对象中提取。
现在我们控制了参数,让我们看看如何调用多个函数。
函数list_clear接受一个list_t结构作为输入,并为列表中的每个节点调用list_free_node()函数:
|
|
通过注入一个具有多个节点的假list结构,我们可以调用任意多个函数。由于我们只需要调用两个函数,我们使用了更简单的方法:通过list->free_cb()进行第一次调用,通过list->allocator->free()进行第二次调用。这些调用足以调用mprotect()——使我们的shellcode页面可执行——然后跳转到shellcode。
拼图中唯一缺失的部分是将任意数据放置在已知地址:shellcode和所有执行它所需的结构(假的list和node对象)。
callback对象为我们提供了一个指向0x1010字节堆缓冲区的指针。通过在分配callback对象之后立即喷洒相同大小的对象(带有受控数据),它们很有可能在内存中连续放置。这使我们能够推断出受控数据驻留的地址。
下图说明了如何劫持执行流以执行我们的shellcode,并总结如下:
- 通过重写callback对象以调用
gadget()函数获得代码执行。 gadget函数使用假list对象(黄色)调用list_clear函数。list->free_cb(node->data)指令再次调用gadget函数以准备调用mprotect()(粉色)。list->allocator->free(node)指令通过调用带有假node对象(绿色)作为参数的gadget函数来执行shellcode。 ![代码执行流程示意图]
在运行Scudo的设备上执行代码
关于Scudo分配器的说明
Scudo是一种内存分配器,设计时注重效率和安全性。以下部分重点介绍服务于小分配(< 0x10000字节)的主分配器。
Scudo将内存组织成区域,每个区域专用于特定大小类别(类ID)的分配。在这些区域内,内存被分成块。一个块由16字节的元数据后跟一个块——调用malloc()时返回给程序的实际内存单元组成。
当一个线程请求内存时,分配器首先检查线程本地缓存中是否有合适大小类别的可用块。如果找到块,则立即返回。如果缓存为空,Scudo会尝试从全局空闲块列表(global freelist)中检索一个TransferBatch——一组预分配的块——以填充缓存。如果没有可用的批次,Scudo会从专用于该大小类别的区域中分配内存,将其分割成单独的块,随机化它们的顺序以减轻利用,并将它们分组为一个或多个TransferBatches。其中一个批次返回给请求线程,而其他批次则存储在全局缓存中以供将来使用。
有关Scudo分配器的更多信息,我们建议您阅读Kevin Denis之前的一篇博客文章。
Scudo具有安全措施,使得难以复制为Jemalloc采用的相同攻击场景:
- 内存块以校验和(checksum)为前缀,在释放块时进行检查。也就是说,如果我们破坏块的元数据然后释放它,程序将中止。
- 内存块被洗牌。在这种情况下,难以建立相对写原语,这假设callback对象可以从固定偏移量访问。
为了克服第一个问题,一种方法是塑造堆,使得只破坏空闲块或受控且持久的块。 关于洗牌机制,它是按批次内存块而不是一次性应用于整个区域。每批随机化的块数取决于大小类别。对于小于0x350字节的内存块(大小类别ID为1到15),该值等于52(4 * 13),这是TransferBatches数量与每个TransferBatch内内存块数量的乘积。因此,通过在易受攻击的对象和目标对象之间插入N = 52个中间分配,可以将目标放置在溢出范围内,从而使其可被破坏: ![Scudo堆布局示意图]
利用场景
由于我们无法建立相对写原语,我们将触发溢出两次!
- 第一次溢出以reader对象为目标,以获取libbluetooth库的基地址。
- 第二次溢出以executor(callback)对象为目标,以触发代码执行。
并希望在64 KB的堆数据损坏后存活下来。
堆塑造
我们采用略有不同的堆塑造策略来控制溢出的来源。像往常一样,我们依赖拥塞来喷洒数百个CONFIG REJ消息,并使用ERTM传输在堆中创建“空洞”。
下图说明了溢出之前和之后的源数据。我们使用ERTM消息为各种GATT属性保留空间。重要的是要注意,ERTM消息按照它们分配的顺序释放。第一个分配的ERTM消息是易受攻击的GATT分配(以绿色显示)将重用的那个。我们分隔此特定ERTM消息的分配,使其后面跟着多个包含受控数据的CONFIG REJ响应。 ![Scudo源数据示意图]
内存泄漏
不幸的是,泄漏前面利用中使用的callback内容的尝试未成功。然而,在泄漏的数据中始终观察到第二个callback对象。该对象由函数ActivityAttribution::Capture()分配,该函数负责记录HCI数据包。此对象包含多个函数指针,使我们能够推断进程的基地址以及稍后将托管我们payload的分配位置。
代码执行
通过第二次触发漏洞破坏Jemalloc利用中使用的SDP Discovery Callback来实现代码执行。然而,由于内存块随机化,难以可靠地重写callback对象的所有字段。
一种解决方案是将Run函数指针的地址破坏为以下gadget的地址:
|
|
通过此pivot gadget进行利用只需要破坏callback对象的两个特定字段即可劫持执行流,如下图所示: ![Scudo代码执行流程示意图]
利用后
shellcode通过蓝牙安装一个命令处理程序,提供与目标交互的有用功能,例如执行shell命令或将文件上传到设备。具体来说,shellcode首先修补函数l2c_rcv_acl_data()以将其重定向到我们的命令处理程序。每当从控制器接收到消息时,都会调用此函数。
shellcode还注册一个信号处理程序以拦截SIGSEGV信号,防止进程com.android.bluetooth因64 KB溢出引起的不稳定而重启。
结论
CVE-2023-40129是蓝牙栈中的一个关键漏洞,无需用户交互或预先身份验证。我们成功利用它在运行Jemalloc(Xiaomi 12T)和Scudo(Samsung A54)的Android设备上获得远程代码执行。
这些利用并非完全可靠,通常会导致蓝牙进程崩溃。但是,蓝牙守护程序会静默重启,因此我们可以一次又一次地重试利用。我们进行了基本测试,发现平均而言,在Jemalloc设备上获得shell的估计时间约为2分钟,在Scudo设备上最多可达5分钟。
Gabeldorsche栈 (GD)
Gabeldorsche栈在Android 12中引入,并在Android 13中成为