CVE-2026-11012
Overview
Background
- `UsbServiceAndroid`
- the native
//services/deviceUSB service backing WebUSB on Android, whose state is bound to the service sequence. - Service sequence
- the
base::SequencedTaskRunneron whichUsbServiceAndroidis created and expected to run, guarded by theUsbServicesequence_checker_. - JNI callback
- a native method (
deviceAttached,deviceDetached,devicePermissionRequestComplete) invoked from Java inChromeUsbService.javaviaChromeUsbServiceJni.get(). - Android main looper
- the UI thread that dispatches
BroadcastReceivercallbacks for USB attach/detach and permission intents, a different thread from the service sequence.
Root Cause Analysis
ChromeUsbService.java registers BroadcastReceiver handlers for USB attach/detach and permission-result intents; those callbacks fire on the Android main looper and immediately call the native deviceAttached, deviceDetached, and devicePermissionRequestComplete JNI entries. Those native entries directly mutated sequence-bound state on UsbServiceAndroid — devices_by_id_ and the UsbService observer list via NotifyDeviceAdded/NotifyDeviceRemoved — from the main looper thread rather than the service sequence, violating the single-sequence ownership invariant enforced by sequence_checker_. The concurrent access races with the service’s own sequenced work and with UsbServiceAndroid teardown, so a callback could touch objects already being freed on the service sequence, yielding a use-after-free.
The fix interposes a ref-counted UsbServiceAndroid::JniDelegate that captures base::SequencedTaskRunner::GetCurrentDefault() in its constructor and PostTasks each JNI entry onto the service sequence, holding only a base::WeakPtr<UsbServiceAndroid> that is checked on that sequence before dispatch. This guarantees all state mutation happens on the owning sequence and that a callback arriving after destruction is safely dropped rather than dereferencing freed memory.
BroadcastReceiver callbacks mutate sequence-bound native state directly from the Android main looper instead of hopping to the service sequence; the fix routes every JNI entry through a JniDelegate trampoline that PostTasks to the captured task_runner_ and gates the dereference behind a weak pointer checked on that sequence.Attack Path
- Connect or trigger a USB device event
an attacker with local access plugs, unplugs, or prompts a permission dialog for a USB device while a WebUSB page holds an active
UsbServiceAndroid. - BroadcastReceiver fires on the main looper
Android delivers
ACTION_USB_DEVICE_ATTACHED/DETACHEDor the permission-result intent toChromeUsbService.javaon the UI thread, which calls the nativedeviceAttached/deviceDetached/devicePermissionRequestCompleteJNI entry. - Race against the service sequence
the JNI entry mutates
devices_by_id_and the observer list off-sequence, concurrently with the service’s own sequenced work or its teardown. - Access freed state
the off-sequence callback dereferences
UsbServiceAndroidstate that is being or has been destroyed on the service sequence, producing a use-after-free. - Exploit the corrupted state the attacker leverages the dangling access to corrupt memory in the process hosting the USB service, potentially leading to controlled read/write.
Impact Assessment
UsbServiceAndroid, in the process hosting the device/USB service on Android. Exploitation requires local USB events (or the ability to prompt permission dialogs) to race the callback against the service sequence, and could escalate to memory corruption and code execution in that process.Changed Functions
| Function | Change | Notes |
|---|---|---|
UsbServiceAndroidservices/device/usb/usb_service_android.cc |
modified | |
ifservices/device/usb/usb_service_android.cc |
modified |
Files Changed
services/device/usb/android/java/src/org/chromium/device/usb/ChromeUsbService.javaservices/device/usb/usb_service_android.cc
Audit Directions
- JNI callbacks mutating sequence-bound stateaudit every
@CalledByNative/@NativeMethodsentry that fires from AndroidBroadcastReceiver, listener, or main-looper contexts and touches native state, verifying it hops to the owning sequence before mutation. - Raw `this` handed to Javaflag native objects that pass
reinterpret_cast<int64_t>(this)intoJava_*_create, since callbacks can outlive the object; prefer a ref-counted, weak-pointer-gated delegate thatPostTasks to a capturedSequencedTaskRunner. - Missing sequence assertionssearch
//services/devicefor sequence-bound methods lackingDCHECK_CALLED_ON_VALID_SEQUENCE, which mask cross-thread entry like theSerialDeviceEnumeratorAndroidrace referenced by the fix.
Patch
From 9bb3f8bce630aceaf883ad66729eb2c7444893e4 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <[email protected]> Date: Wed, 29 Apr 2026 18:52:35 -0700 Subject: [PATCH] usb: Hop UsbServiceAndroid JNI callbacks to the service sequence DeviceAttached, DeviceDetached, and DevicePermissionRequestComplete are invoked by ChromeUsbService.java from BroadcastReceiver callbacks on the Android main looper, but mutate sequence-bound state (devices_by_id_, the UsbService observer list via NotifyDeviceAdded/Removed) without hopping to the service sequence. This is the same data race pattern fixed in SerialDeviceEnumeratorAndroid by commit eaffd0c6d1 (crbug.com/497000161). Add task_runner_, capture SequencedTaskRunner::GetCurrentDefault() in the constructor, split each JNI entry into a trampoline that PostTasks to the service sequence, and add DCHECK_CALLED_ON_VALID_SEQUENCE on the UsbService sequence_checker_ for sequence-bound methods. This patch is based on an initial proposal by Muhammad Aadil. Bug: 502959826 Change-Id: Ief6297751fe718dc9bf7f16c582c7dbfe895205c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7788050 Commit-Queue: Matt Reynolds <[email protected]> Reviewed-by: Alvin Ji <[email protected]> Cr-Commit-Position: refs/heads/main@{#1622887} --- diff --git a/services/device/usb/android/java/src/org/chromium/device/usb/ChromeUsbService.java b/services/device/usb/android/java/src/org/chromium/device/usb/ChromeUsbService.java index 218e177..2b69d641 100644 --- a/services/device/usb/android/java/src/org/chromium/device/usb/ChromeUsbService.java +++ b/services/device/usb/android/java/src/org/chromium/device/usb/ChromeUsbService.java @@ -17,6 +17,7 @@ import org.jni_zero.CalledByNative; import org.jni_zero.JNINamespace; +import org.jni_zero.NativeClassQualifiedName; import org.jni_zero.NativeMethods; import org.chromium.base.ContextUtils; @@ -38,13 +39,13 @@ private static final String TAG = "Usb"; private static final String ACTION_USB_PERMISSION = "org.chromium.device.ACTION_USB_PERMISSION"; - long mUsbServiceAndroid; + long mUsbServiceJniDelegate; UsbManager mUsbManager; @Nullable BroadcastReceiver mUsbPermissionReceiver; @Nullable BroadcastReceiver mUsbDeviceChangeReceiver; - private ChromeUsbService(long usbServiceAndroid) { - mUsbServiceAndroid = usbServiceAndroid; + private ChromeUsbService(long usbServiceJniDelegate) { + mUsbServiceJniDelegate = usbServiceJniDelegate; mUsbManager = (UsbManager) ContextUtils.getApplicationContext().getSystemService(Context.USB_SERVICE); @@ -53,8 +54,8 @@ } @CalledByNative - private static ChromeUsbService create(long usbServiceAndroid) { - return new ChromeUsbService(usbServiceAndroid); + private static ChromeUsbService create(long usbServiceJniDelegate) { + return new ChromeUsbService(usbServiceJniDelegate); } @CalledByNative @@ -81,7 +82,7 @@ if (mUsbManager.hasPermission(device)) { ChromeUsbServiceJni.get() .devicePermissionRequestComplete( - mUsbServiceAndroid, device.getDeviceId(), true); + mUsbServiceJniDelegate, device.getDeviceId(), true); } else { Context context = ContextUtils.getApplicationContext(); Intent intent = new Intent(ACTION_USB_PERMISSION); @@ -110,7 +111,7 @@ assumeNonNull(intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)); ChromeUsbServiceJni.get() .devicePermissionRequestComplete( - mUsbServiceAndroid, + mUsbServiceJniDelegate, device.getDeviceId(), intent.getBooleanExtra( UsbManager.EXTRA_PERMISSION_GRANTED, false)); @@ -123,11 +124,12 @@ UsbDevice device = assumeNonNull(intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)); if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) { - ChromeUsbServiceJni.get().deviceAttached(mUsbServiceAndroid, device); + ChromeUsbServiceJni.get() + .deviceAttached(mUsbServiceJniDelegate, device); } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals( intent.getAction())) { ChromeUsbServiceJni.get() - .deviceDetached(mUsbServiceAndroid, device.getDeviceId()); + .deviceDetached(mUsbServiceJniDelegate, device.getDeviceId()); } } }; @@ -153,11 +155,13 @@ @NativeMethods interface Natives { - void deviceAttached(long nativeUsbServiceAndroid, @Nullable UsbDevice device); + @NativeClassQualifiedName("UsbServiceAndroid::JniDelegate") + void deviceAttached(long nativePointer, @Nullable UsbDevice device); - void deviceDetached(long nativeUsbServiceAndroid, int deviceId); + @NativeClassQualifiedName("UsbServiceAndroid::JniDelegate") + void deviceDetached(long nativePointer, int deviceId); - void devicePermissionRequestComplete( - long nativeUsbServiceAndroid, int deviceId, boolean granted); + @NativeClassQualifiedName("UsbServiceAndroid::JniDelegate") + void devicePermissionRequestComplete(long nativePointer, int deviceId, boolean granted); } } diff --git a/services/device/usb/usb_service_android.cc b/services/device/usb/usb_service_android.cc index 69f2645..96ebd936 100644 --- a/services/device/usb/usb_service_android.cc +++ b/services/device/usb/usb_service_android.cc @@ -22,10 +22,71 @@ namespace device { -UsbServiceAndroid::UsbServiceAndroid() : UsbService() { +// Bounces JNI callbacks to `task_runner_` (the service sequence). Holds a weak +// reference to the service since it may be destroyed. The weak pointer must be +// checked on the service sequence. +class UsbServiceAndroid::JniDelegate + : public base::RefCountedThreadSafe<JniDelegate> { + public: + explicit JniDelegate(base::WeakPtr<UsbServiceAndroid> service) + : service_(std::move(service)), + task_runner_(base::SequencedTaskRunner::GetCurrentDefault()) {} + + void DeviceAttached(JNIEnv* env, + const base::android::JavaRef<jobject>& usb_device) { + task_runner_->PostTask( + FROM_HERE, base::BindOnce(&JniDelegate::DeviceAttachedInternal, this, + base::android::ScopedJavaGlobalRef<jobject>( + env, usb_device))); + } + + void DeviceDetached(int32_t device_id) { + task_runner_->PostTask( + FROM_HERE, + base::BindOnce(&JniDelegate::DeviceDetachedInternal, this, device_id)); + } + + void DevicePermissionRequestComplete(int32_t device_id, bool granted) { + task_runner_->PostTask( + FROM_HERE, + base::BindOnce(&JniDelegate::DevicePermissionRequestCompleteInternal, + this, device_id, granted)); + } + + private: + friend class base::RefCountedThreadSafe<JniDelegate>; + ~JniDelegate() = default; + + void DeviceAttachedInternal( + base::android::ScopedJavaGlobalRef<jobject> usb_device) { + if (service_) { + service_->DeviceAttachedInternal(usb_device); + } + } + + void DeviceDetachedInternal(int32_t device_id) { + if (service_) { + service_->DeviceDetachedInternal(device_id); + } + } + + void DevicePermissionRequestCompleteInternal(int32_t device_id, + bool granted) { + if (service_) { + service_->DevicePermissionRequestCompleteInternal(device_id, granted); + } + } + + base::WeakPtr<UsbServiceAndroid> service_; + scoped_refptr<base::SequencedTaskRunner> task_runner_; +}; + +UsbServiceAndroid::UsbServiceAndroid() + : task_runner_(base::SequencedTaskRunner::GetCurrentDefault()) { + jni_delegate_ = base::MakeRefCounted<JniDelegate>(weak_factory_.GetWeakPtr()); JNIEnv* env = AttachCurrentThread(); - j_object_.Reset( - Java_ChromeUsbService_create(env, reinterpret_cast<int64_t>(this))); + j_object_.Reset(Java_ChromeUsbService_create( + env, reinterpret_cast<int64_t>(jni_delegate_.get()))); ScopedJavaLocalRef<jobjectArray> devices = Java_ChromeUsbService_getDevices(env, j_object_); for (auto usb_device : devices.CreateView(env)) { @@ -36,20 +97,24 @@ }
Original Bug Report
Potential data race and UAF in SerialDeviceEnumeratorAndroid callbacks map
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: SerialDeviceEnumeratorAndroid mutates an absl::flat_hash_map concurrently from multiple background threads without synchronization. This data race can lead to heap corruption and a potential use-after-free in the highly privileged browser process. Triggering this requires the disabled-by-default Web Serial on Android feature and explicit user permission.
Affected files:
services/device/serial/serial_device_enumerator_android.ccservices/device/serial/serial_device_enumerator_android.hservices/device/serial/serial_io_handler_android.ccservices/device/serial/android/java/src/org/chromium/device/serial/ChromeSerialManager.java
Estimated timestamp from git blame: 2026-02-02
Description
There is a potential data race and memory corruption vulnerability in SerialDeviceEnumeratorAndroid due to unsynchronized access to the callbacks_ member variable, which is an absl::flat_hash_map<std::string, std::unique_ptr<Callbacks>>.
This map is mutated from at least two distinct, unsynchronized thread contexts:
- Chrome ThreadPool (
OpenPath): When a Web Serial port is opened,SerialIoHandlerAndroid::OpenImpl()posts a task usingbase::ThreadPool::PostTask({base::MayBlock(), ...})to executeSerialDeviceEnumeratorAndroid::OpenPath(). This method performsfind(),emplace(), and potentiallyextract()on thecallbacks_map. Because it uses an unsequenced task runner, concurrentport.open()requests can execute simultaneously on multiple worker threads. - Android AsyncTask Thread (
OpenPathCallbackViaJni/ErrorCallbackViaJni): When the Android OS finishes opening the port, Java invokes the completion handlers via JNI. The Java code usesAsyncTask.THREAD_POOL_EXECUTOR, meaning these JNI calls execute on an Android background thread. The C++ JNI handlers (OpenPathCallbackViaJniandErrorCallbackViaJni) directly callcallbacks_.extract()without posting back to a safe sequence.
absl::flat_hash_map is not thread-safe. Concurrent mutations—such as a ThreadPool worker performing an emplace() (which may trigger a rehash and free the backing array) while an Android worker performs an extract()—will severely corrupt the map’s internal state. This leads to Heap Use-After-Free (UAF) and memory corruption.
Because the Device Service on Android runs directly in the browser process, this heap corruption provides a potential path to a sandbox escape and Remote Code Execution (RCE) with the privileges of the browser app.
Note: The kWebSerialWiredDevicesAndroid feature is currently disabled by default and requires a physical USB device and explicit user permission to trigger.
Potential Steps to Reproduce
Note: Our tooling agent cannot run live code, so these are suggested steps to trigger the race condition.
- Enable the
chrome://flags/#enable-web-serial-wired-devices-androidfeature flag. - Connect a USB-serial device to the Android device.
- Navigate to a malicious website that requests Web Serial permissions (
navigator.serial.requestPort()). - The user approves the chooser dialog.
- The malicious JavaScript acquires multiple references to the
SerialPortobject (e.g., using same-origin Web Workers or iframes). - The script concurrently calls
port.open({ baudRate: 9600 })across the workers, or rapidly initiates and aborts port openings. - This causes simultaneous
emplace()andextract()calls on theabsl::flat_hash_mapfrom Chrome’s ThreadPool and Android’s AsyncTask threads, triggering a crash or UAF.
Suggested Fix
Access to the callbacks_ map must be synchronized.
- Use a SequencedTaskRunner: Ensure all accesses to
callbacks_happen onSerialDeviceEnumeratorAndroid’s internaltask_runner_.- Modify
SerialIoHandlerAndroid::OpenImplto post to the enumerator’s specifictask_runner_instead of a genericThreadPool::PostTask. - Modify the JNI callbacks (
OpenPathCallbackViaJniandErrorCallbackViaJni) so they do not accesscallbacks_directly. Instead, they should post a task totask_runner_to handle the extraction and callback execution, similar to howAddPortViaJnihandles non-initial enumerations.
- Modify
- Alternatively, use a Mutex: Guard
callbacks_with abase::Lock, though sequence bounding is generally preferred in Chromium over manual locking.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.