Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Serial
DescriptionUse after free in Serial
ComponentSerial
Bug ClassUAF
Tracker497000161
Fix commit9bb3f8bce630 (chromium/src) +112/-27
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
UsbServiceAndroid
services/device/usb/usb_service_android.cc
modified
if
services/device/usb/usb_service_android.cc
modified

Files Changed

  • services/device/usb/android/java/src/org/chromium/device/usb/ChromeUsbService.java
  • services/device/usb/usb_service_android.cc
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 @@
 }
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • services/device/serial/serial_device_enumerator_android.h
  • services/device/serial/serial_io_handler_android.cc
  • services/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:

  1. Chrome ThreadPool (OpenPath): When a Web Serial port is opened, SerialIoHandlerAndroid::OpenImpl() posts a task using base::ThreadPool::PostTask({base::MayBlock(), ...}) to execute SerialDeviceEnumeratorAndroid::OpenPath(). This method performs find(), emplace(), and potentially extract() on the callbacks_ map. Because it uses an unsequenced task runner, concurrent port.open() requests can execute simultaneously on multiple worker threads.
  2. Android AsyncTask Thread (OpenPathCallbackViaJni / ErrorCallbackViaJni): When the Android OS finishes opening the port, Java invokes the completion handlers via JNI. The Java code uses AsyncTask.THREAD_POOL_EXECUTOR, meaning these JNI calls execute on an Android background thread. The C++ JNI handlers (OpenPathCallbackViaJni and ErrorCallbackViaJni) directly call callbacks_.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.

  1. Enable the chrome://flags/#enable-web-serial-wired-devices-android feature flag.
  2. Connect a USB-serial device to the Android device.
  3. Navigate to a malicious website that requests Web Serial permissions (navigator.serial.requestPort()).
  4. The user approves the chooser dialog.
  5. The malicious JavaScript acquires multiple references to the SerialPort object (e.g., using same-origin Web Workers or iframes).
  6. The script concurrently calls port.open({ baudRate: 9600 }) across the workers, or rapidly initiates and aborts port openings.
  7. This causes simultaneous emplace() and extract() calls on the absl::flat_hash_map from Chrome’s ThreadPool and Android’s AsyncTask threads, triggering a crash or UAF.

Suggested Fix

Access to the callbacks_ map must be synchronized.

  1. Use a SequencedTaskRunner: Ensure all accesses to callbacks_ happen on SerialDeviceEnumeratorAndroid’s internal task_runner_.
    • Modify SerialIoHandlerAndroid::OpenImpl to post to the enumerator’s specific task_runner_ instead of a generic ThreadPool::PostTask.
    • Modify the JNI callbacks (OpenPathCallbackViaJni and ErrorCallbackViaJni) so they do not access callbacks_ directly. Instead, they should post a task to task_runner_ to handle the extraction and callback execution, similar to how AddPortViaJni handles non-initial enumerations.
  2. Alternatively, use a Mutex: Guard callbacks_ with a base::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.

View on issue tracker