Paint it blue: Attacking the bluetooth stack
Le Bluetooth a toujours été une cible attrayante pour les attaquants, car il est présent presque partout (téléviseurs, chargeurs de voiture, réfrigérateurs connectés, etc.). C'est particulièrement vrai sur les appareils mobiles, où il s'exécute en tant que processus privilégié avec un accès potentiel au microphone, au carnet d'adresses, etc.
En septembre et octobre 2023, Android a publié des bulletins de sécurité concernant des vulnérabilités critiques dans sa pile Bluetooth (Fluoride), pouvant conduire à l'exécution de code à distance. La CVE-2023-40129 est un dépassement d'entier (integer underflow) dans le protocole GATT, accessible sans interaction de l'utilisateur ni authentification. Son exploitation s'est avérée très difficile car elle provoquait un dépassement de tas (heap overflow) de 64 Ko, agissant comme un tsunami qui dévastait tout sur son passage, menant le processus Bluetooth à une mort quasi certaine.
Dans cet article de blog, nous détaillons comment nous avons exploité cette vulnérabilité sur les deux allocateurs natifs d'Android : Scudo et Jemalloc.
Cet article est une traduction de l'article original en anglais.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
La pile Bluetooth
Le diagramme ci-dessus illustre la pile Bluetooth. Elle est divisée en deux parties principales : la pile Contrôleur (Controller) réside dans la puce Bluetooth, tandis que la pile Hôte (Host) est implémentée par le système d'exploitation. L'interface HCI (Host Controller Interface) permet la communication entre les deux composants. Le contrôleur gère principalement les transports physiques et logiques. Notre exploit repose sur ACL, le transport asynchrone qui achemine les trames de données. Sur Android, la pile Hôte - appelée Fluoride - s'exécute en tant que démon dans l'espace utilisateur (userland). Une fois la liaison ACL établie, des connexions L2CAP (Logical Link Control and Adaptation Protocol) peuvent être initiées pour accéder à divers services Bluetooth (BNEP, HID, AVCTP, etc.), qui fournissent des fonctionnalités bien connues telles que le partage réseau, le streaming vidéo, etc. Chaque service est identifié par un Protocol Service Multiplexer (PSM) unique :
| Service |
PSM |
|---|---|
|
SDP (Protocole de Découverte de Service) |
0x0001 |
|
RFCOMM (Communication par Radiofréquence) |
0x0003 |
|
BNEP (Protocole d'Encapsulation Réseau Bluetooth) |
0x000F |
|
HID (Périphérique d'Interface Humaine) |
0x0011 (Control), 0x0013 (Interrupt) |
|
AVCTP (Protocole de Transport de Contrôle Audio/Vidéo) |
0x0017 (Control), 0x001B (Browsing) |
|
AVDTP (Protocole de Transport de Données Audio/Vidéo) |
0x0019 |
|
GATT (Protocole d'Attributs Génériques) |
0x001F |
|
GAP (Profil d'Accès Générique) |
0x01001, 0x1003, 0x1005, 0x1007 |
Le code relatif à chaque service se trouve dans le répertoire system/stack/. Chaque service est enregistré via l'API suivante :
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)
Le paramètre sec_level définit le niveau de sécurité pour accéder au service. La plupart des services exigent que la connexion soit authentifiée et chiffrée.
Très peu de services sont accessibles sans authentification - notamment SDP, RFCOMM et GATT. Mais même lorsqu'une connexion commence sans être authentifiée, certaines opérations (comme l'écriture d'attributs GATT) peuvent l'exiger ultérieurement, réduisant ainsi davantage la surface d'attaque.
Le framework BlueBlue
En nous basant sur le framework de test L2CAP du projet BlueBorne, nous avons développé notre propre framework nommé BlueBlue. Il utilise Scapy pour construire et analyser les trames HCI. Le framework permet d'établir une liaison ACL et d'ouvrir des connexions L2CAP.
Il prend également en charge plusieurs fonctionnalités de la spécification Bluetooth telles que la fragmentation L2CAP et le mode de transmission ERTM. Il implémente toutes les fonctionnalités de la pile Hôte que nous utilisons, ce qui nous donne une grande liberté pour explorer de nouvelles idées.
Avec seulement quelques lignes de code, nous pouvons établir une connexion ACL, nous connecter à un service L2CAP, envoyer une commande et recevoir la réponse :
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())
Le bug
La CVE-2023-40129 est une vulnérabilité présente dans le serveur GATT. Le protocole GATT est utilisé pour exposer des attributs simples de type clé-valeur. Les clés sont des identifiants (handles) de 16 bits, tandis que les valeurs sont de simples données brutes. L'opcode GATT_REQ_READ_MULTI_VAR permet de lire plusieurs attributs à la fois.
La requête est composée de l'opcode GATT_REQ_READ_MULTI_VAR suivi de la liste des identifiants GATT :
La réponse est composée de l'opcode GATT_RSP_READ_MULTI_VAR suivi de la longueur et de la valeur de chaque attribut demandé :
La requête est traitée dans la fonction gatt_process_read_multi_req(), qui est responsable de récupérer les valeurs des attributs demandés :
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);
}
La fonction gatt_sr_process_app_rsp() est appelée pour chaque attribut. Elle transmet la valeur de l'attribut récupéré (encapsulée dans la variable p_msg) à la fonction process_read_multi_rsp() qui la copie dans une nouvelle structure allouée, puis la place dans une file d'attente :
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);
}
La vulnérabilité se trouve dans la fonction build_read_multi_rsp(), qui est responsable de la construction du message de réponse :
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*/
// [...]
}
En haut de la fonction [0], nous pouvons voir une allocation de la structure (p_buf) qui contient le tampon de réponse. La taille du tampon alloué dépend du MTU, qui peut être configuré lors de l'ouverture du canal L2CAP.
La portion de code suivante parcourt la liste des attributs GATT [1] et vérifie s'ils peuvent tenir dans le message de réponse. C'est-à-dire que pour chaque attribut, la fonction calcule la longueur totale attendue du message ([2.1] et [2.2]) et vérifie si elle dépasse le MTU. S'il n'y a pas assez de place pour stocker l'attribut, la taille maximale des données pouvant être copiées dans le tampon est calculée comme indiqué en [3]. Cependant, le calcul de len est erroné car il ne tient pas compte de l'addition en [2.2]. Cet integer underflow conduit à un heap-based overflow en [4] (comme le prédit ironiquement l'instruction is_overflow = true).
L'extrait de code suivant déclenche la vulnérabilité. Il se connecte au canal GATT et configure un MTU de 55. Ensuite, il demande 4 fois l'attribut 9 (16 octets) :
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)
Le dépassement se produit lors de la tentative d'insertion du dernier attribut. Plus précisément, en [3], p_buf->len a une valeur de 55 (1+ 3*(16+2)) et total_len est de 73. Par conséquent, len subira un underflow à -2 (0xfffe), provoquant un dépassement d'environ 64 Ko dans le tampon de réponse.
Récemment, lors de l'OffensiveCon 2025, la Red Team Android de Google à l'origine de la découverte du bug a présenté un PoC d'exploit ciblant une vulnérabilité similaire (CVE-2023-35673) sur les appareils Pixel. Cependant, leur exploit suppose que l'ASLR est désactivé et que l'attaquant est déjà appairé avec l'appareil cible. Dans les sections suivantes, nous détaillons notre stratégie d'exploitation pour exploiter Fluoride sans dépendre de ces hypothèses.
Just Works, Still Works
En 2017, l'article BlueBorne a révélé plusieurs vulnérabilités Bluetooth critiques affectant à la fois BlueZ (pile Linux) et Fluoride (pile Android). Le document décrit une méthode d'authentification « obscure » de la spécification Bluetooth : Just Works. Le mode d'authentification Just Works permet un appairage temporaire sans interaction de l'utilisateur. Il est utilisé lors de l'appairage simple et sécurisé (Secure Simple Pairing - SSP) avec des appareils qui n'ont ni clavier ni écran. Dans ce scénario, l'authentification se produit sans validation de code PIN.
Nous avons implémenté le mode d'authentification Just Works dans le framework BlueBlue et avons confirmé qu'il fonctionne toujours sur Android 13.
L'authentification Just Works comporte certaines limitations. Premièrement, Fluoride traite la connexion comme étant vulnérable aux attaques de type Man-in-the-Middle (MITM), ce qui empêche l'accès à certaines fonctionnalités comme la lecture ou l'écriture d'attributs GATT protégés. Deuxièmement, l'utilisation de Just Works rompt tout appairage existant avec un appareil partageant la même adresse BDADDR. Malgré ses limitations, ce mode d'authentification nous permet toujours d'établir une connexion L2CAP vers divers services Bluetooth tels que GAP, BNEP et AVCTP. Bien que la vulnérabilité ne nécessite pas d'authentification préalable pour être déclenchée, la manière dont nous l'exploitons requiert la connexion à plusieurs canaux L2CAP. C'est là que le mode Just Works entre en jeu.
Primitives d'exploitation
Allocation de données persistantes
L'exploitation de ce bug nécessite une stratégie de shaping du heap très précise afin d'éviter que le démon Bluetooth ne plante à cause d'un état de tas corrompu.
Nous avons audité le code source de Fluoride et identifié des fonctionnalités qui peuvent être détournées pour forcer des allocations de taille contrôlée avec des données contrôlées et rendre ces allocations persistantes. Par exemple, lors de la configuration d'un canal L2CAP, si l'appareil pair ne reconnaît pas une option de configuration, il enverra une copie exacte (message CONFIG REJ) des options rejetées. Une option de configuration est composée d'un type (champ de 1 octet), d'une longueur (champ de 1 octet) et de la valeur réelle de taille arbitraire dont le contenu est entièrement contrôlé. L'allocation de la réponse contenant les options rejetées est effectuée dans la fonction suivante :
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);
/* ... */
}
L'allocation est libérée dès qu'elle est renvoyée au pair initiant la connexion. Cependant, nous pouvons la rendre persistante grâce à la congestion.
Congestion
La spécification Bluetooth fournit une fonctionnalité de contrôle de flux (Flow Control) sur la couche ACL. Si son tampon de réception (RX buffer) ACL est plein, le contrôleur Bluetooth peut effacer le bit FLOW de l'en-tête des paquets ACL qu'il envoie pour empêcher le pair d'envoyer plus de paquets pendant que le tampon de réception est traité. Cette fonctionnalité n'est normalement pas exposée à l'hôte, mais nous pourrions la manipuler en modifiant le firmware d'un contrôleur. Heureusement pour nous, les contrôleurs Cypress disposent même d'une commande HCI propriétaire pour l'activer, il était donc assez simple de simuler une congestion ACL. Dans cet état, un pair (déclaré comme congestionné) peut toujours envoyer des paquets à l'appareil distant mais ne peut pas recevoir les réponses. L'appareil distant traitera ces paquets, mais sera incapable de répondre. La pile Fluoride gère la congestion de manière appropriée. Ainsi, si nous envoyons des requêtes de configuration invalides pendant que notre contrôleur déclare une congestion ACL, Fluoride ne renverra pas les réponses, mais les conservera plutôt dans une file d'attente jusqu'à ce que la congestion cesse.
Il convient de noter que la congestion est limitée par un quota. Une fois le quota atteint, les messages supplémentaires sont abandonnés au lieu d'être mis en file d'attente. Cependant, les canaux de signalisation L2CAP ne sont pas soumis à cette limitation, ce qui signifie que nous pouvons allouer un nombre pratiquement illimité de messages de réponse CONFIG REJ. Nous pouvons libérer toutes ces allocations en fermant la connexion ACL associée.
Il est également important de noter que la congestion est retardée au niveau de la pile Fluoride et que le premier lot de réponses sera libéré dès qu'elles seront envoyées au contrôleur. La fonction suivante vérifie si un paquet peut être envoyé au contrôleur :
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);
}
}
/* ... */
}
La vérification est basée sur la variable controller_xmit_window, qui est décrémentée chaque fois qu'un paquet est transmis au contrôleur sous-jacent dans la fonction l2c_link_send_to_lower_br_edr(). Sa valeur est incrémentée dans l2c_packets_completed par le nombre de paquets acquittés.
Mode de transmission ERTM
ERTM est une couche de transport supplémentaire, construite au-dessus de L2CAP, qui y ajoute de la fiabilité : numérotation de séquence, acquittement et retransmission. Nous pouvons abuser de ce mode de deux manières différentes pour forcer des allocations persistantes :
- Envoyer un fragment L2CAP avec un numéro de séquence inattendu, par exemple
seq_tx = 1. Tant que le message avec le numéro de séquenceseq_tx = 0n'a pas été envoyé, le pair distant conservera tous les messages suivants en mémoire. Ce comportement est utile car il nous permet d'allouer des messages de taille et de contenu contrôlés. - Forcer
Fluorideà envoyer un fragment ERTM, mais ne pas l'acquitter intentionnellement. Le fragment restera en mémoire, et nous pourrons demander sa retransmission à tout moment tant que nous ne l'acquittons pas.
Chacune de ces deux techniques permet l'allocation de jusqu'à 10 messages persistants par connexion L2CAP (c'est pourquoi nous ne pouvions pas nous fier à ERTM pour le spraying). Seul un nombre limité de canaux L2CAP tels que GAP et AVCTP prennent en charge le mode ERTM, et tous nécessitent une authentification avec l'appareil pair.
Primitive de lecture relative
La structure BT_HDR est une cible intéressante. Elle est largement utilisée dans la base de code Bluetooth pour représenter diverses données telles que les messages L2CAP et les fragments ERTM :
typedef struct {
uint16_t event;
uint16_t len;
uint16_t offset;
uint16_t layer_specific;
uint8_t data[];
} BT_HDR;
La structure BT_HDR a une longueur variable. Le champ len représente la longueur du tampon de données. Elle inclut également un champ offset, qui indique la position du début des données dans le champ de données. Pour construire une primitive de lecture relative dans le tas, nous pouvons réécrire le champ len d'un fragment ERTM en attente dans la file d'envoi et augmenter sa taille afin de divulguer le contenu du tas du processus com.android.bluetooth.
Le canal de navigation AVCTP est un bon candidat pour construire la primitive de lecture. Il utilise ERTM et nous pouvons le forcer à transmettre une réponse de taille contrôlée. La requête GET_FOLDER_ITEMS nous permet de demander les métadonnées d'une liste de lecture musicale (par exemple, artiste, nom de la chanson, nom de l'album). En envoyant une requête GET_FOLDER_ITEMS avec des attributs soigneusement sélectionnés, nous pouvons faire en sorte que l'allocation de la réponse tombe dans la même classe de bin que le tampon vulnérable. Si nous modifions la structure BT_HDR liée à la réponse GET_FOLDER_ITEMS, nous pouvons obtenir une fuite d'information en demandant une retransmission du message modifié.
Primitive d'écriture relative
ERTM prend en charge la fragmentation. Les messages sont réassemblés dans la fonction do_sar_reassembly(). À la réception du premier fragment, la fonction alloue une structure BT_HDR en utilisant la taille spécifiée dans le fragment initial :
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;
}
}
Les fragments suivants sont copiés en utilisant les champs len et offset de la structure BT_HDR :
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;
Ainsi, en corrompant le champ offset, puis en envoyant un deuxième fragment avec des données, nous obtenons une primitive d'écriture relative
Contournement de l'ASLR et contrôle du PC
La pile Fluoride utilise l'objet callback de libchrome pour gérer divers événements. Cet objet est intéressant pour construire des primitives d'exploitation car il possède un pointeur de fonction qui est appelé lorsque le callback se déclenche, ainsi que certains des arguments qui lui sont passés. Par conséquent, la fuite de cet objet révélerait l'adresse de base de libbluetooth, et sa réécriture nous donnerait le contrôle sur le flux d'exécution.
Le SDP Discovery Callback est particulièrement intéressant car nous contrôlons son allocation et nous pouvons déclencher le callback à tout moment :
L'objet callback est alloué dans la fonction SdpLookup lors de l'ouverture d'un canal AVRCP :
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;
}
La méthode Bind() est responsable de l'allocation de l'objet callback (0x60 octets). La structure du callback est remplie avec le pointeur de fonction SdbCp ainsi que ses paramètres :
void ConnectionHandler::SdpCb(RawAddress bdaddr, SdpCallback cb,
tSDP_DISCOVERY_DB* disc_db, bool retry,
uint16_t status)
Le callback est appelé dans la fonction avrc_sdp_cback() :
/******************************************************************************
*
* 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;
}
La réécriture de l'objet callback permet de déclencher un appel de fonction arbitraire avec des arguments entièrement contrôlés. Nous pouvons déclencher le callback en nous déconnectant du canal SDP qui est établi par l'appareil distant lors de la connexion au canal de navigation AVRCP.
Exécution de code sur les appareils utilisant Jemalloc
Scénario d'exploitation
Afin d'obtenir l'exécution de code sur des appareils fonctionnant avec Jemalloc, nous avons adopté la stratégie suivante :
- Structurer le tas afin de réécrire deux objets BT_HDR. Le premier fait référence à un message ERTM en attente dans la file de transmission (
reader), tandis que le second correspond à un fragment ERTM en attente dans la file de réception (writer). - Déclencher le dépassement et corrompre les objets
readeretwriter. - Allouer l'objet callback (
executor). - Demander la retransmission du paquet modifié.
- Récupérer le contenu de l'objet
callback. - Réécrire le contenu de l'objet
callbacken utilisant la primitive d'écriture relative. - Déclencher le callback.
Heap shaping
La première étape consiste à structurer le tas afin de réécrire les objets reader et writer avec des données contrôlées. Nous nous appuyons sur les fonctionnalités décrites dans la section précédente, telles que la congestion et le mode de transmission ERTM. Plus précisément, nous avons adopté la stratégie suivante afin de contrôler la source du dépassement ainsi que d'agencer les objets dans le bin de destination.
- Activer la congestion ACL.
- Sprayer plusieurs messages
CONFIG REJ. - Intercaler des allocations de messages ERTM pendant le spray en commençant la séquence avec seq_tx > 0. Les allocations ERTM sont utilisées pour créer des « trous » dans le tas.
- Désactiver la congestion ACL. Les allocations
CONFIG REJsont libérées. - Libérer les allocations ERTM en fermant par exemple la connexion associée. Les allocations ERTM sont réutilisées par les objets liés à GATT lors du débordement du tampon.
La figure suivante illustre l'état du tas pour contrôler la source du dépassement. D'abord, nous sprayons une dizaine de messages CONFIG REJ afin de forcer la congestion au niveau de la pile Bluetooth. Ensuite, nous alternons les allocations de messages ERTM et de messages CONFIG REJ de sorte que chaque message ERTM soit suivi de données contrôlées. Une fois libérées, les allocations ERTM seront réutilisées par les objets GATT (t_GATTS_RSP) contenant les valeurs des attributs qui seront copiées dans l'objet vulnérable.
Maintenant que nous avons l'état de tas souhaité pour contrôler la source du dépassement, voyons comment nous pouvons agencer les objets (reader, writer et executor) dans le même bin que l'objet vulnérable. Pour référence, la taille de l'objet vulnérable dépend de la taille du MTU et est calculée comme suit :
len = sizeof(BT_HDR) + L2CAP_MIN_OFFSET + mtu; // 8 + 13 + MTU
Nous avons décidé de cibler le même bin que celui utilisé pour l'allocation de l'objet callback (executor). En appliquant la même stratégie que celle utilisée pour modeler la source, nous avons obtenu l'état de tas souhaité. Dans la figure ci-dessous, l'objet executor (callback) est alloué après le dépassement.
Fuite de l'ASLR
En corrompant le champ len de l'objet reader, nous pouvons faire fuiter jusqu'à 64 Ko de données qui incluent le contenu des objets executor. Il contient plusieurs pointeurs de fonction qui peuvent être utilisés pour déduire l'adresse de base de la bibliothèque libbluetooth. En analysant les données divulguées, nous avons noté que dans certains cas, l'objet art::Thread y est présent. Il contient plusieurs pointeurs de fonction dans les bibliothèques libart, libm et libc. Comme cet objet est rarement présent dans la fuite, nous avons décidé de ne pas l'utiliser dans l'exploit.
Exécution de code
L'exécution de code est obtenue en réécrivant l'objet SDP Discovery Callback. Nous pouvons y parvenir en modifiant les pointeurs de fonction Run ou SdpCb. Le seul but de la fonction Run est de préparer et de dispatcher l'appel au callback réel SdpCb. Cependant, aucun de ces pointeurs n'est pratique, car nous n'avons pas un contrôle fin sur les arguments.
Afin de contrôler entièrement les arguments, nous avons décidé de réécrire le pointeur de fonction Run pour appeler la fonction suivante :
__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));
}
Cette fonction (fonction gadget) nous permet d'appeler une fonction arbitraire tout en contrôlant 5 arguments, dont les trois premiers sont des QWORD. La fonction cible et ses arguments sont tous deux extraits de l'objet passé en paramètre à gadget.
Maintenant que nous contrôlons les paramètres, voyons comment nous pouvons appeler plusieurs fonctions.
La fonction list_clear prend une structure list_t en entrée et appelle la fonction list_free_node() pour chaque nœud de la liste :
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;
}
En injectant une fausse structure list avec plusieurs nœuds, nous pouvons appeler autant de fonctions que nous le souhaitons. Comme nous n'avions besoin d'appeler que deux fonctions, nous avons utilisé une approche plus simple : effectuer le premier appel via list->free_cb() et le second via list->allocator->free(). Ces appels sont suffisants pour invoquer mprotect() - rendant la page de notre shellcode exécutable - suivi d'un saut vers le shellcode.
La seule pièce manquante du puzzle est de placer des données arbitraires à une adresse connue : le shellcode et toutes les structures (faux objets list et node) nécessaires pour l'exécuter.
L'objet callback nous donne un pointeur vers un tampon de tas de 0x1010 octets. En sprayant des objets (avec des données contrôlées) de la même taille juste après l'allocation de l'objet callback, il y a une forte probabilité qu'ils soient placés de manière contiguë en mémoire. Cela nous permet de déduire une adresse où résident des données contrôlées.
La figure suivante illustre comment détourner le flux de contrôle d'exécution afin d'exécuter notre shellcode et est résumée ci-après :
- L'exécution de code est obtenue en réécrivant l'objet
callbackafin d'appeler la fonctiongadget(). - La fonction gadget appelle la fonction
list_clearavec un faux objetlist(en jaune). - L'instruction
list->free_cb(node->data)appelle à nouveau la fonction gadget afin de préparer l'appel àmprotect()(en rose). - L'instruction
list->allocator->free(node)exécute le shellcode via un appel à la fonction gadget avec un faux objetnode(en vert) en paramètre.
Exécution de code sur les appareils utilisant Scudo
Notes sur l'allocateur Scudo
Scudo est un allocateur de mémoire conçu en mettant l'accent sur l'efficacité et la sécurité. La section suivante se concentre sur l'allocateur primaire qui dessert les petites allocations (< 0x10000 octets).
Scudo organise la mémoire en régions, chacune dédiée aux allocations d'une classe de taille spécifique (ID de classe). Au sein de ces régions, la mémoire est divisée en blocs. Un bloc est constitué de 16 octets de métadonnées suivis d'un chunk - les unités de mémoire réelles retournées au programme lors de l'appel à malloc().
Lorsqu'un thread demande de la mémoire, l'allocateur vérifie d'abord le cache local du thread (thread-local cache) pour des chunks disponibles de la classe de taille appropriée. Si un chunk est trouvé, il est retourné immédiatement. Si le cache est vide, Scudo tente de récupérer un TransferBatch - un groupe de chunks pré-alloués - de la liste globale des blocs libres (global freelist) afin de peupler le cache. Si aucun lot n'est disponible, Scudo alloue de la mémoire à partir d'une région dédiée à la classe de taille, la divise en chunks individuels, randomise leur ordre pour atténuer l'exploitation, et les regroupe en un ou plusieurs TransferBatches. L'un de ces lots est retourné au thread demandeur, tandis que les autres sont stockés dans le cache global pour une utilisation future.
Pour plus d'informations sur l'allocateur Scudo, nous vous recommandons de lire un précédent article de blog de Kevin Denis.
Scudo dispose de mesures de sécurité qui rendent difficile la reproduction du même scénario d'attaque que celui adopté pour Jemalloc :
- Un chunk de mémoire est préfixé par une somme de contrôle (checksum), qui est vérifiée lorsque le chunk est libéré. C'est-à-dire que si nous corrompont les métadonnées d'un bloc puis le libérons, le programme s'arrête.
- Les blocs de mémoire sont mélangés. Dans ce contexte, il est difficile de mettre en place la primitive d'écriture relative, qui suppose que l'objet callback est accessible à partir d'un décalage fixe.
Pour surmonter le premier problème, une approche consiste à structurer le tas de telle sorte à corrompre que des chunks libres ou ou des chunks contrôlés et persistants.
Concernant le mécanisme de mélange, il est appliqué par lot de blocs de mémoire plutôt qu'une seule fois pour toute la région. Le nombre de blocs randomisés par lot dépend de la classe de taille. Pour les blocs mémoire de moins de 0x350 bytes (ID de classe de taille de 1 à 15), cette valeur est égale à 52 (4 * 13), ce qui est le produit du nombre de TransferBatches par le nombre de blocs de mémoire à l'intérieur de chaque TransferBatch. Par conséquent, en insérant N = 52 allocations intermédiaires entre l'objet vulnérable et l'objet cible, il est possible de positionner la cible à portée du dépassement, la rendant ainsi accessible à la corruption :
Scénario d'exploitation
Comme nous ne pouvons pas mettre en place une primitive d'écriture relative, nous allons déclencher le dépassement deux fois !
- Le premier dépassement cible un objet
readerafin d'obtenir l'adresse de base de la bibliothèquelibbluetooth. - Le second dépassement cible un objet
executor(callback) afin de déclencher l'exécution de code.
Et espérer survivre à 64 Ko de données de tas endommagées.
Heap shaping
Nous adoptons une stratégie de shapping du tas légèrement différente afin de contrôler la source du dépassement. Comme d'habitude, nous nous appuyons sur la congestion pour sprayer une centaine de messages CONFIG REJ et utilisons la transmission ERTM pour créer des « trous » dans le tas.
Le diagramme ci-dessous illustre les données sources avant et après le dépassement. Nous réservons de l'espace pour divers attributs GATT en utilisant des messages ERTM. Il est important de noter que les messages ERTM sont libérés dans l'ordre où ils ont été alloués. Le premier message ERTM alloué est celui qui sera récupéré par l'allocation GATT vulnérable (montrée en vert). Nous séparons l'allocation de ce message ERTM spécifique afin qu'il soit suivi de plusieurs réponses CONFIG REJ contenant des données contrôlées.
Divulgation de mémoire
Malheureusement, les tentatives de faire fuiter le contenu du callback utilisé dans l'exploit précédent ont été infructueuses. Cependant, un deuxième objet callback a été observé de manière constante dans les données divulguées. Cet objet est alloué par la fonction ActivityAttribution::Capture(), qui est responsable de la journalisation des paquets HCI. Cet objet contient plusieurs pointeurs de fonction, nous permettant de déduire l'adresse de base du processus ainsi que l'emplacement de l'allocation qui hébergera plus tard notre charge utile.
Exécution de code
L'exécution de code est réalisée en déclenchant la vulnérabilité une deuxième fois pour corrompre le SDP Discovery Callback utilisé dans l'exploit Jemalloc. Cependant, en raison de ma randomisation des chunks de mémoire, il est difficile de réécrire de manière fiable tous les champs de l'objet callback.
Une solution consiste à corrompre le pointeur de fonction Run avec l'adresse du gadget suivant :
LDR X0, [X0]
MOV W8, W1
MOV W1, W2
MOV W2, W8
LDR X3, [X0,#8]
BR X3
L'exploitation via ce gadget pivot ne nécessite que la corruption de deux champs spécifiques de l'objet callback pour détourner le flux d'exécution comme illustré ci-dessous :
Post-exploitation
Le shellcode installe un gestionnaire de commandes via Bluetooth, qui fournit des fonctionnalités utiles pour interagir avec la cible, telles que l'exécution de commandes shell ou le téléversement d'un fichier sur l'appareil. Plus précisément, le shellcode commence par patcher la fonction l2c_rcv_acl_data() pour la détourner vers notre gestionnaire de commandes. Cette fonction est appelée chaque fois qu'un message est reçu du contrôleur.
Le shellcode enregistre également un gestionnaire de signaux pour intercepter les signaux SIGSEGV, empêchant le processus com.android.bluetooth de redémarrer si un thread se bloque en raison de l'instabilité induite par le dépassement de 64 Ko.
Conclusion
La CVE-2023-40129 est une vulnérabilité critique dans la pile Bluetooth, qui ne nécessite ni interaction de l'utilisateur ni authentification préalable. Nous avons réussi à l'exploiter pour obtenir l'exécution de code à distance sur des appareils Android fonctionnant avec Jemalloc (Xiaomi 12T) et Scudo (Samsung A54).
Les exploits ne sont pas parfaitement fiables et conduisent souvent au crash du processus Bluetooth. Cependant, le démon Bluetooth redémarre silencieusement, nous pouvons donc réessayer l'exploit encore et encore. Nous avons effectué des tests basiques et constaté qu'en moyenne, le temps estimé pour obtenir un shell (ETS - Estimated Time of Shell) est d'environ 2 minutes sur les appareils Jemalloc, et jusqu'à 5 minutes sur les appareils Scudo.
La pile Gabeldorsche (GD)
La pile Gabeldorsche a été introduite dans Android 12 et est devenue la pile Bluetooth par défaut dans Android 13. Elle représente un changement architectural majeur, avec une réécriture progressive de la pile Bluetooth en Rust. Cependant, fin 2023, seules les couches de bas niveau avaient été réécrites, laissant les couches supérieures inchangées. Par conséquent, la vulnérabilité restait exploitable même lorsque GD était activé.
Références
BlueBorne. Ben Seri, Gregory Vishnepolsky (Armis Labs)
Behind the Shield: Unmasking Scudo's Defenses. Kevin Denis (Synacktiv)
0-click RCE on the IVI component: Pwn2Own Automotive Edition. Mikhail Evdokimov (PCAutomotive) - Hexacon'24
Fighting Cavities: Securing Android Bluetooth by Red Teaming. Jeong Wook Oh, Rishika Hooda and Xuan Xing (Google) - OffensiveCon'25