CVE-2025-48593, patched in November’s Android Security Bulletin, only affects devices that support acting as Bluetooth headphones / speakers, such as some smartwatches, smart glasses, and cars.

To find out the impact of the issue, I examined the patch from the Android bulletin and wrote a proof-of-concept that crashes the Bluetooth service in the Android Automotive emulator in Android Studio.

You can find my proof of concept at https://github.com/zhuowei/blueshrimp.

Should I be worried?

No, you don’t need to worry about this:

  • As far as I can tell, phones and tablets are NOT vulnerable to CVE-2025-48593. The issue only affects Android devices that support acting as Bluetooth headphones / speakers, such as some smartwatches, smart glasses, and cars.
  • In addition, an attacker has to get a victim to pair to the attacker before they can access the headset service. As long as you don’t accept the pairing request on your smartwatch/glasses/car, you should be fine.
  • My proof-of-concept isn’t useful for a real attacker: I don’t attempt to defeat ASLR, so this only crashes the Bluetooth service on a device. It can’t do anything malicious.

Demo

Here’s a video showing the Bluetooth service crashing with “fault addr 0x4141414141414141” on the Android Automotive emulator in Android Studio:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/sdk_gcar_arm64/emulator_car64_arm64:14/UAA1.250512.001/13479943:userdebug/dev-keys'
Revision: '0'
ABI: 'arm64'
Timestamp: 2025-12-01 17:28:17.644347763-0500
Process uptime: 0s
Cmdline: com.google.android.bluetooth
pid: 6386, tid: 6424, name: bt_main_thread  >>> com.google.android.bluetooth <<<
uid: 1001002
tagged_addr_ctrl: 0000000000000001 (PR_TAGGED_ADDR_ENABLE)
pac_enabled_keys: 000000000000000f (PR_PAC_APIAKEY, PR_PAC_APIBKEY, PR_PAC_APDAKEY, PR_PAC_APDBKEY)
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x4141414141414141
    x0  4141414141414141  x1  b4000073106a14a0  x2  0000000000000103  x3  414141414141413e
    x4  b4000073106a15a3  x5  4141414141414241  x6  0000000000000100  x7  000000000000010f
    x8  0000000000000000  x9  4141414141414141  x10 0000000000000002  x11 00000070c20c8558
    x12 0000000000000018  x13 00000000ffffffbf  x14 0000000000000003  x15 0000000000000001
    x16 00000070c253f470  x17 00000073f6ee3a40  x18 00000070bb2c6060  x19 00000070c258c0c0
    x20 b4000073106a14a3  x21 0000000000000100  x22 00000070bc384000  x23 000000004141413e
    x24 00000070bc384000  x25 00000070bc384000  x26 00000070bc383ff8  x27 00000000000fc000
    x28 00000000000fe000  x29 00000070bc383470
    lr  00000070c20c3d58  sp  00000070bc383460  pc  00000073f6ee3b38  pst 00000000a0001000

15 total frames
backtrace:
      #00 pc 000000000005fb38  /apex/com.android.runtime/lib64/bionic/libc.so (__memcpy_aarch64_simd+248) (BuildId: 8bd98d931a32d13659267d7d53286e73)
      #01 pc 00000000006aad54  /apex/com.android.btservices/lib64/libbluetooth_jni.so (sdp_copy_raw_data(tCONN_CB*, bool)+344) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #02 pc 00000000006aa0c0  /apex/com.android.btservices/lib64/libbluetooth_jni.so (process_service_search_attr_rsp(tCONN_CB*, unsigned char*, unsigned char*)+624) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #03 pc 00000000006a9760  /apex/com.android.btservices/lib64/libbluetooth_jni.so (sdp_data_ind(unsigned short, BT_HDR*)+212) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #04 pc 00000000007387b4  /apex/com.android.btservices/lib64/libbluetooth_jni.so (l2c_csm_execute(t_l2c_ccb*, tL2CEVT, void*)+9412) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #05 pc 00000000009d6ce8  /apex/com.android.btservices/lib64/libbluetooth_jni.so (base::debug::TaskAnnotator::RunTask(char const*, base::PendingTask*)+196) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #06 pc 00000000009d6260  /apex/com.android.btservices/lib64/libbluetooth_jni.so (base::MessageLoop::RunTask(base::PendingTask*)+352) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #07 pc 00000000009d6574  /apex/com.android.btservices/lib64/libbluetooth_jni.so (base::MessageLoop::DoWork()+452) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #08 pc 00000000009d8964  /apex/com.android.btservices/lib64/libbluetooth_jni.so (base::MessagePumpDefault::Run(base::MessagePump::Delegate*)+100) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #09 pc 00000000009e4a34  /apex/com.android.btservices/lib64/libbluetooth_jni.so (base::RunLoop::Run()+64) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #10 pc 000000000069aaa4  /apex/com.android.btservices/lib64/libbluetooth_jni.so (bluetooth::common::MessageLoopThread::Run(std::__1::promise<void>)+336) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #11 pc 000000000069a584  /apex/com.android.btservices/lib64/libbluetooth_jni.so (bluetooth::common::MessageLoopThread::RunThread(bluetooth::common::MessageLoopThread*, std::__1::promise<void>)+48) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #12 pc 000000000069b090  /apex/com.android.btservices/lib64/libbluetooth_jni.so (void* std::__1::__thread_proxy<std::__1::tuple<std::__1::unique_ptr<std::__1::__thread_struct, std::__1::default_delete<std::__1::__thread_struct> >, void (*)(bluetooth::common::MessageLoopThread*, std::__1::promise<void>), bluetooth::common::MessageLoopThread*, std::__1::promise<void> > >(void*)+84) (BuildId: fe3c1bf88cf688f5197df2b2f326f723)
      #13 pc 00000000000cb6a8  /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 8bd98d931a32d13659267d7d53286e73)
      #14 pc 000000000006821c  /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 8bd98d931a32d13659267d7d53286e73)

Tested

I tested against 3 Android Emulators in Android Studio:

  • Android Automotive 14, API 34-ext9, “Android Automotive with Google APIs arm64-v8a System Image”, version 5 - worked out of the box
  • Android 15, API 35, “Google APIs ARM 64 v8a System Image”, version 9 - worked once I force-enabled acting as a Bluetooth headset with root and setprop bluetooth.profile.hfp.hf.enabled true
  • Android 16 API 36.1 “Google APIs ARM 64 v8a System Image” revision 3 - patched against CVE-2025-48593: with force-enabled headset, running the proof-of-concept gives me:
    bumble.core.InvalidStateError: channel not open
    

I also tested against real devices:

I don’t have a physical Android device that acts as a Bluetooth headset, so I used root to force-enable it on two devices: my Pixel 3 XL (an Android 11 device), and an Android 14 device.

  • the Android 11 device seems to be unaffected: it closes the SDP after the first response, like the patched emulator. It seems Android 11 is not vulnerable?
  • the Android 14 device does seem to be affected the same way as the emulator.

My understanding of what’s happening

Bluetooth headphones use the Handsfree Profile.

Handsfree Profile is special: unlike some Bluetooth services, where one side acts as a server and the other side connects to it, both the headset and the connecting device (e.g. a phone) need to run Bluetooth servers.

After the phone connects to the headset’s Handsfree service (UUID 0x111e), the headset then connects back to the phone’s Handsfree Audio Gateway service (UUID 0x111f).

When a phone opens an RFCOMM connection to the headset’s Handsfree service, in the headset’s hf_client code:

  • bta_hf_client_allocate_handle allocates a tBTA_HF_CLIENT_CB handle from the pool
  • bta_hf_client_do_disc allocates a tSDP_DISCOVERY_DB, stores it in client_cb->p_disc_db, and starts SDP discovery
  • SDP_ServiceSearchAttributeRequest2 stores the tSDP_DISCOVERY_DB into a tCONN_CB’s p_ccb->p_db, then connects to the phone’s SDP service
  • now the tSDP_DISCOVERY_DB is stored both in the hf_client’s client_cb->p_disc_db handle and in the SDP layer’s p_ccb->p_db
hf_client:                  SDP:          
tBTA_HF_CLIENT_CB           tCONN_CB 1    
+-------------+             +------------+
|             |             |            |
| ACTIVE      |             | ACTIVE     |
|             |             |            |
|  p_disc_db  |             |   p_db     |
+------+------+             +------+-----+
       |                           |      
       |       tSDP_DISCOVERY_DB 1 |      
       |       +-----------+       |      
       |       |           |       |      
       |       |           |       |      
       +-------+           +-------+      
               |           |              
               +-----------+                        

When the phone’s RFCOMM connection is closed:

  • bta_hf_client_mgmt_cback emits a BTA_HF_CLIENT_RFC_CLOSE_EVT
  • the bta_hf_client_st_opening state table calls the handler for bta_hf_client_rfc_close and resets the state machine to BTA_HF_CLIENT_INIT_ST
  • bta_hf_client_sm_execute sees the state transition and deallocates the tBTA_HF_CLIENT_CB handle back to the pool
  • However - before the patch for CVE-2025-48593 - the SDP connection is not cancelled, and is still waiting for a response
  • At this time, there’s a tBTA_HF_CLIENT_CB returned to the unallocated pool, with client_cb->p_disc_db still set and a still active SDP discovery
hf_client:                  SDP:          
tBTA_HF_CLIENT_CB           tCONN_CB 1    
+-------------+             +------------+
|             |             |            |
| INACTIVE    |             | ACTIVE     |
|             |             |            |
|  p_disc_db  |             |   p_db     |
+------+------+             +------+-----+
       |                           |      
       |       tSDP_DISCOVERY_DB 1 |      
       |       +-----------+       |      
       |       |           |       |      
       |       |           |       |      
       +-------+           +-------+      
               |           |              
               +-----------+              

When the phone answers the SDP discovery with an error:

hf_client:                  SDP:          
tBTA_HF_CLIENT_CB           tCONN_CB 1    
+-------------+             +------------+
|             |             |            |
| INACTIVE    |             | INACTIVE   |
|             |             |            |
|  p_disc_db  |             |   p_db     |
+-------------+             +------------+
                                         
               (freed)
               +-----------+  
               |           |            
               |           | 
               |           |     
               |           |              
               +-----------+              

However, if the phone opens RFCOMM again before the first SDP discovery returns:

  • we reallocate a handle (probably the same handle that was deallocated to the pool previously) and call discovery again.
  • the client_cb->p_disc_db now points to a new tSDP_DISCOVERY_DB, and the SDP layer holds two tSDP_DISCOVERY_DBs: one p_ccb->p_db holds the old DB from the first connection and one p_ccb->p_db holds the new DB from the second connection
hf_client:                  SDP:                            
tBTA_HF_CLIENT_CB           tCONN_CB 1       tCONN_CB 2     
+-------------+             +------------+   +-------------+
|             |             |            |   |             |
| ACTIVE      |             | ACTIVE     |   | ACTIVE      |
|             |             |            |   |             |
|  p_disc_db  |             |   p_db     |   |   p_db      |
+-----+-------+             +------+-----+   +----+--------+
      |                            |              |         
      |        tSDP_DISCOVERY_DB 1 |              |         
      |        +-----------+       |              |         
      |        |           |       |              |         
      |        |           |       |              |         
      |        |           +-------+              |         
      |        |           |                      |         
      |        +-----------+                      |         
      |                                           |         
      |                                           |         
      |                                           |         
      |        tSDP_DISCOVERY_DB 2                |         
      |        +-----------+                      |         
      |        |           |                      |         
      |        |           |                      |         
      +--------+           +----------------------+         
               |           |                                
               |           |                                
               +-----------+                                

Now, the phone answers the first SDP discovery with an error:

  • the SDP layer closes the p_ccb from the first connection
  • bta_hf_client_free_db frees client_cb->p_disc_db, which is the second connection’s DB
  • now the hf_client’s client_cb->p_disc_db is freed and set to null, and the SDP’s p_ccb for the first connection is gone
  • but the p_ccb for the second connection is still active, so p_ccb->p_db for the second SDP discovery request points to a freed tSDP_DISCOVERY_DB
hf_client:                  SDP:                            
tBTA_HF_CLIENT_CB           tCONN_CB 1       tCONN_CB 2     
+-------------+             +------------+   +-------------+
|             |             |            |   |             |
| ACTIVE      |             | INACTIVE   |   | ACTIVE      |
|             |             |            |   |             |
|  p_disc_db  |             |   p_db     |   |   p_db      |
+-------------+             +------------+   +----+--------+
                                                  |         
               tSDP_DISCOVERY_DB 1                |         
               +-----------+                      |         
               |           |                      |         
               |           |                      |         
               |           |                      |         
               |           |                      |         
               +-----------+                      |         
                                                  |         
                                                  |         
                                                  |         
               (freed!)                           |         
               +-----------+                      |         
               |           |                      |         
               |           |                      |         
               |           +----------------------+         
               |           |                                
               |           |                                
               +-----------+                                

Finally, the phone answers the second SDP discovery with an actual response:

  • the SDP layer processes the incoming data in sdp_data_ind and dispatches to sdp_disc_server_rsp
  • process_service_search_attr_rsp starts reading from p_ccb->p_db
  • since p_db was already freed by bta_hf_client_free_db from the first SDP discovery’s error response, the second SDP response causes use-after-free.

Exploiting the use-after-free

To exploit the use-after-free, I need to re-allocate the memory buffer with contents I control.

Both of Android’s supported memory allocators (Scudo and Jemalloc) have a first-in-first-out thread local cache. After triggering the issue, the next time Bluetooth calls malloc on the bt_main_thread for around 0x1010 bytes, it would re-use the most recently freed memory block of similar size - the freed tSDP_DISCOVERY_DB.

Unfortunately, just sending a Bluetooth packet isn’t enough to trigger this allocation. Received packets are allocated on a different thread, bt_stack_manager_thread. I have to find an allocation that happens on the main thread, inside the protocol implementations.

Synaktive’s writeup of a previous Android Bluetooth exploit mentions that packet reassembly can be used to allocate memory on the Bluetooth main thread.

I wasn’t able to use their approach of using ERTM with AVCTP, as Bumble does not support ERTM; however, it turns out AVCTP’s own reassembly routine also does a osi_malloc and fills it with the received packet’s contents.

I control everything but the first 0x13 bytes of the AVCTP allocation. Conveniently, tSDP_DISCOVERY_DB has a raw_data field:

struct tSDP_DISCOVERY_DB {
  uint32_t mem_size;                                  /* Memory size of the DB        */
  uint32_t mem_free;                                  /* Memory still available       */
  tSDP_DISC_REC* p_first_rec;                         /* Addr of first record in DB   */
  uint16_t num_uuid_filters;                          /* Number of UUIds to filter    */
  bluetooth::Uuid uuid_filters[SDP_MAX_UUID_FILTERS]; /* UUIDs to filter */
  uint16_t num_attr_filters;                          /* Number of attribute filters  */
  uint16_t attr_filters[SDP_MAX_ATTR_FILTERS];        /* Attributes to filter */
  uint8_t* p_free_mem;                                /* Pointer to free memory       */
  uint8_t* raw_data; /* Received record from server. allocated/released by client  */
  uint32_t raw_size; /* size of raw_data */
  uint32_t raw_used; /* length of raw_data used */
};

and the sdp_copy_raw_data method memcpys the SDP response directly into raw_data.

So, to get an arbitrary write, my proof-of-concept:

  • responds to the first SDP request with an error, which frees the tSDP_DISCOVERY_DB
  • (at this point, the Android Automotive emulator tries to connect to A2DP and sends another SDP request: my proof-of-concept answers this request with an error too.)
  • waits for the second SDP request
  • sends an AVRCP packet to reallocate the dangling tSDP_DISCOVERY_DB, with a fake object that sets raw_data to my target address
  • responds to the second SDP request
  • sdp_copy_raw_data runs and does a memcpy into my target address with my SDP response

I wrote the proof-of-concept using Bumble:

  • it’s a full Bluetooth stack, which gives me full control over the SDP connection
  • it can connect both to Android Emulator and to real USB Bluetooth dongles

What about ASLR?

ASLR is left as an exercise for the reader… because I’m not experienced enough to figure this out.

I think it’s possible to get an information disclosure from this.

I currently re-allocate after the SDP request is already sent, but I found it’s possible to re-allocate just before the SDP request is sent by delaying the L2CAP configuration response. This lets me overwrite num_uuid_filters and num_attr_filters. Since there’s no bounds check when accessing uuid_filters and attr_filters, I can get it to copy memory after those fields into the SDP request. (A good target might be p_free_mem, just past the attr_filters - it gives the address of the tSDP_DISCOVERY_DB itself.)

(In fact, if num_attr_filters is set high enough, sdpu_build_attrib_seq also overflows the request buffer, giving a relative out-of-bounds write when constructing the SDP request).

Unfortunately, num_uuid_filters falls into the 0x13 bytes that I can’t control with AVCTP, and it’s set to “0x06 0x06”. So sdpu_build_uuid_seq fills up the entire 0x1010 byte buffer, then I get “cannot send message bigger than peer’s mtu size: len=4096 mtu=1691”.

(Maybe ERTM, which only has 0x8 bytes of overhead, would work?)

Anyways, shaping the heap so interesting memory falls behind the tSDP_DISCOVERY_DB is probably going to be difficult. Let me know if you figure it out!

Thanks

Credits to:

  • “Dikun Zhang (stardesty) of Li Auto security team”, according to the Android Security Bulletin, for originally discovering this issue.
  • the members of FreeXR and XRBreak for their support.

What I learned

  • Far too much about the Android Bluetooth stack
  • How to write Bluetooth services in Bumble
  • How Bluetooth protocols, such as L2CAP, SDP, RFCOMM, Headset Client, and AVRCP/AVCTP work
  • How to (kinda…) forward Android Emulator’s Bluetooth into another Linux VM
  • How to use Frida to instrument Android apps. This was crucial for logging when the database is freed and what malloc re-allocated it.
  • How to capture Bluetooth traffic on physical Android devices, thanks to wejn’s guide