CVE-2026-11012
Overview
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
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.