Overview

Medium
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Serial
DescriptionUse after free in Serial
ComponentSerial
Sub-componentUSB
Bug ClassUAF
Action tierwatch — corruption primitive but gated (local) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker497000161
Fix commit9bb3f8bce630 (chromium/src) +112/-27
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-27 (per vendor advisory)
Fix landed2026-04-30 (public git)
Disclosed2026-06-02
Patch gapNot computed: mapped commit cites a different bug, not this CVE's bug.

Background

`UsbServiceAndroid`
the native //services/device USB service backing WebUSB on Android, whose state is bound to the service sequence.
Service sequence
the base::SequencedTaskRunner on which UsbServiceAndroid is created and expected to run, guarded by the UsbService sequence_checker_.
JNI callback
a native method (deviceAttached, deviceDetached, devicePermissionRequestComplete) invoked from Java in ChromeUsbService.java via ChromeUsbServiceJni.get().
Android main looper
the UI thread that dispatches BroadcastReceiver callbacks 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.

Key insight
The single mistake was letting Java 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

  1. 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.
  2. BroadcastReceiver fires on the main looper Android delivers ACTION_USB_DEVICE_ATTACHED/DETACHED or the permission-result intent to ChromeUsbService.java on the UI thread, which calls the native deviceAttached/deviceDetached/devicePermissionRequestComplete JNI entry.
  3. 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.
  4. Access freed state the off-sequence callback dereferences UsbServiceAndroid state that is being or has been destroyed on the service sequence, producing a use-after-free.
  5. 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

An attacker who can induce USB device attach/detach or permission events gains a data-race use-after-free on the sequence-bound state of 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

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

Audit Directions

  • JNI callbacks mutating sequence-bound state
    audit every @CalledByNative/@NativeMethods entry that fires from Android BroadcastReceiver, listener, or main-looper contexts and touches native state, verifying it hops to the owning sequence before mutation.
  • Raw `this` handed to Java
    flag native objects that pass reinterpret_cast<int64_t>(this) into Java_*_create, since callbacks can outlive the object; prefer a ref-counted, weak-pointer-gated delegate that PostTasks to a captured SequencedTaskRunner.
  • Missing sequence assertions
    search //services/device for sequence-bound methods lacking DCHECK_CALLED_ON_VALID_SEQUENCE, which mask cross-thread entry like the SerialDeviceEnumeratorAndroid race referenced by the fix.
From 9bb3f8bce630aceaf883ad66729eb2c7444893e4 Mon Sep 17 00:00:00 2001
From: Matt Reynolds <mattreynolds@google.com>
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 <mattreynolds@chromium.org>
Reviewed-by: Alvin Ji <alvinji@chromium.org>
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 vm...@google.com

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