Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in HID
DescriptionOut of bounds read in HID
ComponentHID
Sub-componentHID
Bug ClassOOB
Tracker562655586
Fix commit1ec973fe9bfe (chromium/src) +110/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
erase_if
content/browser/hid/hid_service.cc
modified
TEST_P
content/browser/hid/hid_service_unittest.cc
modified

Files Changed

  • content/browser/hid/hid_service.cc
  • content/browser/hid/hid_service_unittest.cc
From 1ec973fe9bfe617a260346196f76da1603aad683 Mon Sep 17 00:00:00 2001
From: Rob Pitkin <[email protected]>
Date: Fri, 18 Sep 2026 12:32:58 -0700
Subject: [PATCH] hid: Disconnect watcher on null device info

In HidService::OnPermissionRevoked, when GetDeviceInfo() returns nullptr
(such as when BrowserContext is null for a service worker whose version
was destroyed, or if device information is otherwise unavailable), the
watcher was erased from watcher_ids_ without removing the receiver from
watchers_. This left the HidConnectionWatcher receiver bound and kept
the hardware device connection open.

Remove the watcher from watchers_ before erasing the entry when
device_info is null, ensuring connection cleanup mirrors the fail-safe
behavior of WebUsbServiceImpl.

Bug: 562655586
TAG=agy
CONV=6728c607-f6f2-45c6-bfb3-0aab72f12073

Change-Id: Iacce84a07f292ac809107ed03f28c4905dc0c5b6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8423111
Reviewed-by: Alvin Ji <[email protected]>
Commit-Queue: Rob Pitkin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1701019}
---
diff --git a/content/browser/hid/hid_service.cc b/content/browser/hid/hid_service.cc
index d538b84..e2edcbc 100644
--- a/content/browser/hid/hid_service.cc
+++ b/content/browser/hid/hid_service.cc
@@ -683,10 +683,9 @@
       std::erase_if(watcher_ids_, [&](const auto& watcher_entry) {
         const auto* device_info =
             delegate->GetDeviceInfo(browser_context, watcher_entry.first);
-        if (!device_info)
-          return true;
-
-        if (delegate->HasDevicePermission(browser_context, render_frame_host_,
+        // Only retain the watcher if the device exists and permission is held.
+        if (device_info &&
+            delegate->HasDevicePermission(browser_context, render_frame_host_,
                                           origin_, *device_info)) {
           return false;
         }
diff --git a/content/browser/hid/hid_service_unittest.cc b/content/browser/hid/hid_service_unittest.cc
index 0b290c1..3f51549 100644
--- a/content/browser/hid/hid_service_unittest.cc
+++ b/content/browser/hid/hid_service_unittest.cc
@@ -56,11 +56,13 @@
 
 using ::base::test::InvokeFuture;
 using ::base::test::RunClosure;
+using ::base::test::RunOnceClosure;
 using ::base::test::TestFuture;
 using ::testing::_;
 using ::testing::ByMove;
 using ::testing::ElementsAre;
 using ::testing::Expectation;
+using ::testing::Ref;
 using ::testing::Return;
 
 enum HidServiceCreationType {
@@ -1172,6 +1174,111 @@
   }
 }
 
+TEST_P(HidServiceTest, RevokeDevicePermissionWithNullDeviceInfo) {
+  auto service_creation_type = GetParam();
+  const auto& service = GetService(service_creation_type);
+
+  EXPECT_CALL(hid_delegate(), HasDevicePermission).WillRepeatedly(Return(true));
+
+  auto device_info = device::mojom::HidDeviceInfo::New();
+  device_info->guid = kTestGuid;
+  ConnectDevice(*device_info);
+  CheckHidServiceConnectedState(service_creation_type, false);
+
+  FakeHidConnectionClient connection_client;
+  EXPECT_CALL(hid_delegate(), IncrementConnectionCount);
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo)
+      .WillOnce(Return(device_info.get()));
+  auto connection = OpenDevice(service, device_info, connection_client);
+  CheckHidServiceConnectedState(service_creation_type, true);
+
+  // Configure GetDeviceInfo to return nullptr, simulating unavailable device
+  // info or null BrowserContext during permission revocation.
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo(_, kTestGuid))
+      .WillOnce(Return(nullptr));
+
+  TestFuture<void> disconnect_future;
+  connection.set_disconnect_handler(disconnect_future.GetCallback());
+
+  TestFuture<void> decrement_future;
+  url::Origin origin = url::Origin::Create(GURL(kTestUrl));
+  EXPECT_CALL(hid_delegate(),
+              DecrementConnectionCount(GetBrowserContext(service_creation_type),
+                                       origin))
+      .WillOnce(RunOnceClosure(decrement_future.GetCallback()));
+
+  hid_delegate().OnPermissionRevoked(origin);
+
+  EXPECT_TRUE(decrement_future.Wait());
+  EXPECT_TRUE(disconnect_future.Wait());
+  CheckHidServiceConnectedState(service_creation_type, false);
+  EXPECT_FALSE(connection.is_connected());
+}
+
+TEST_P(HidServiceTest, RevokeDevicePermissionPartialMultiDevice) {
+  auto service_creation_type = GetParam();
+  const auto& service = GetService(service_creation_type);
+
+  EXPECT_CALL(hid_delegate(), HasDevicePermission).WillRepeatedly(Return(true));
+
+  const std::string kGuid1 = "guid-1";
+  const std::string kGuid2 = "guid-2";
+
+  auto device_info1 = device::mojom::HidDeviceInfo::New();
+  device_info1->guid = kGuid1;
+  ConnectDevice(*device_info1);
+
+  auto device_info2 = device::mojom::HidDeviceInfo::New();
+  device_info2->guid = kGuid2;
+  ConnectDevice(*device_info2);
+
+  CheckHidServiceConnectedState(service_creation_type, false);
+
+  FakeHidConnectionClient connection_client1;
+  FakeHidConnectionClient connection_client2;
+
+  EXPECT_CALL(hid_delegate(), IncrementConnectionCount).Times(2);
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo)
+      .WillOnce(Return(device_info1.get()))
+      .WillOnce(Return(device_info2.get()));
+
+  auto connection1 = OpenDevice(service, device_info1, connection_client1);
+  auto connection2 = OpenDevice(service, device_info2, connection_client2);
+  CheckHidServiceConnectedState(service_creation_type, true);
+
+  // Device 1 returns nullptr for GetDeviceInfo, while Device 2 returns valid
+  // info and retains permission.
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo(_, kGuid1))
+      .WillOnce(Return(nullptr));
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo(_, kGuid2))
+      .WillOnce(Return(device_info2.get()));
+  EXPECT_CALL(hid_delegate(), HasDevicePermission(_, _, _, Ref(*device_info2)))
+      .WillOnce(Return(true));
+
+  TestFuture<void> disconnect_future1;
+  connection1.set_disconnect_handler(disconnect_future1.GetCallback());
+
+  TestFuture<void> decrement_future;
+  url::Origin origin = url::Origin::Create(GURL(kTestUrl));
+  EXPECT_CALL(hid_delegate(), DecrementConnectionCount(_, origin))
+      .WillRepeatedly(Return());
+  EXPECT_CALL(hid_delegate(),
+              DecrementConnectionCount(GetBrowserContext(service_creation_type),
+                                       origin))
+      .WillOnce(RunOnceClosure(decrement_future.GetCallback()))
+      .RetiresOnSaturation();
+
+  hid_delegate().OnPermissionRevoked(origin);
+
+  EXPECT_TRUE(decrement_future.Wait());
+  EXPECT_TRUE(disconnect_future1.Wait());
+
+  // Device 1 was severed and disconnected, but Device 2 remains connected.
+  EXPECT_FALSE(connection1.is_connected());
+  EXPECT_TRUE(connection2.is_connected());
+  CheckHidServiceConnectedState(service_creation_type, true);
+}
+
 TEST_P(HidServiceTest, OpenDevicesThenHidServiceReset) {
   auto service_creation_type = GetParam();
   auto& service = GetService(service_creation_type);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/hid/hid_service_unittest.cc b/content/browser/hid/hid_service_unittest.cc
index 0b290c1..3f51549 100644
--- a/content/browser/hid/hid_service_unittest.cc
+++ b/content/browser/hid/hid_service_unittest.cc
@@ -56,11 +56,13 @@
 
 using ::base::test::InvokeFuture;
 using ::base::test::RunClosure;
+using ::base::test::RunOnceClosure;
 using ::base::test::TestFuture;
 using ::testing::_;
 using ::testing::ByMove;
 using ::testing::ElementsAre;
 using ::testing::Expectation;
+using ::testing::Ref;
 using ::testing::Return;
 
 enum HidServiceCreationType {
@@ -1172,6 +1174,111 @@
   }
 }
 
+TEST_P(HidServiceTest, RevokeDevicePermissionWithNullDeviceInfo) {
+  auto service_creation_type = GetParam();
+  const auto& service = GetService(service_creation_type);
+
+  EXPECT_CALL(hid_delegate(), HasDevicePermission).WillRepeatedly(Return(true));
+
+  auto device_info = device::mojom::HidDeviceInfo::New();
+  device_info->guid = kTestGuid;
+  ConnectDevice(*device_info);
+  CheckHidServiceConnectedState(service_creation_type, false);
+
+  FakeHidConnectionClient connection_client;
+  EXPECT_CALL(hid_delegate(), IncrementConnectionCount);
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo)
+      .WillOnce(Return(device_info.get()));
+  auto connection = OpenDevice(service, device_info, connection_client);
+  CheckHidServiceConnectedState(service_creation_type, true);
+
+  // Configure GetDeviceInfo to return nullptr, simulating unavailable device
+  // info or null BrowserContext during permission revocation.
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo(_, kTestGuid))
+      .WillOnce(Return(nullptr));
+
+  TestFuture<void> disconnect_future;
+  connection.set_disconnect_handler(disconnect_future.GetCallback());
+
+  TestFuture<void> decrement_future;
+  url::Origin origin = url::Origin::Create(GURL(kTestUrl));
+  EXPECT_CALL(hid_delegate(),
+              DecrementConnectionCount(GetBrowserContext(service_creation_type),
+                                       origin))
+      .WillOnce(RunOnceClosure(decrement_future.GetCallback()));
+
+  hid_delegate().OnPermissionRevoked(origin);
+
+  EXPECT_TRUE(decrement_future.Wait());
+  EXPECT_TRUE(disconnect_future.Wait());
+  CheckHidServiceConnectedState(service_creation_type, false);
+  EXPECT_FALSE(connection.is_connected());
+}
+
+TEST_P(HidServiceTest, RevokeDevicePermissionPartialMultiDevice) {
+  auto service_creation_type = GetParam();
+  const auto& service = GetService(service_creation_type);
+
+  EXPECT_CALL(hid_delegate(), HasDevicePermission).WillRepeatedly(Return(true));
+
+  const std::string kGuid1 = "guid-1";
+  const std::string kGuid2 = "guid-2";
+
+  auto device_info1 = device::mojom::HidDeviceInfo::New();
+  device_info1->guid = kGuid1;
+  ConnectDevice(*device_info1);
+
+  auto device_info2 = device::mojom::HidDeviceInfo::New();
+  device_info2->guid = kGuid2;
+  ConnectDevice(*device_info2);
+
+  CheckHidServiceConnectedState(service_creation_type, false);
+
+  FakeHidConnectionClient connection_client1;
+  FakeHidConnectionClient connection_client2;
+
+  EXPECT_CALL(hid_delegate(), IncrementConnectionCount).Times(2);
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo)
+      .WillOnce(Return(device_info1.get()))
+      .WillOnce(Return(device_info2.get()));
+
+  auto connection1 = OpenDevice(service, device_info1, connection_client1);
+  auto connection2 = OpenDevice(service, device_info2, connection_client2);
+  CheckHidServiceConnectedState(service_creation_type, true);
+
+  // Device 1 returns nullptr for GetDeviceInfo, while Device 2 returns valid
+  // info and retains permission.
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo(_, kGuid1))
+      .WillOnce(Return(nullptr));
+  EXPECT_CALL(hid_delegate(), GetDeviceInfo(_, kGuid2))
+      .WillOnce(Return(device_info2.get()));
+  EXPECT_CALL(hid_delegate(), HasDevicePermission(_, _, _, Ref(*device_info2)))
+      .WillOnce(Return(true));
+
+  TestFuture<void> disconnect_future1;
+  connection1.set_disconnect_handler(disconnect_future1.GetCallback());
+
+  TestFuture<void> decrement_future;
+  url::Origin origin = url::Origin::Create(GURL(kTestUrl));
+  EXPECT_CALL(hid_delegate(), DecrementConnectionCount(_, origin))
+      .WillRepeatedly(Return());
+  EXPECT_CALL(hid_delegate(),
+              DecrementConnectionCount(GetBrowserContext(service_creation_type),
+                                       origin))
+      .WillOnce(RunOnceClosure(decrement_future.GetCallback()))
+      .RetiresOnSaturation();
+
+  hid_delegate().OnPermissionRevoked(origin);
+
+  EXPECT_TRUE(decrement_future.Wait());
+  EXPECT_TRUE(disconnect_future1.Wait());
+
+  // Device 1 was severed and disconnected, but Device 2 remains connected.
+  EXPECT_FALSE(connection1.is_connected());
+  EXPECT_TRUE(connection2.is_connected());
+  CheckHidServiceConnectedState(service_creation_type, true);
+}
+
 TEST_P(HidServiceTest, OpenDevicesThenHidServiceReset) {
   auto service_creation_type = GetParam();
   auto& service = GetService(service_creation_type);
Loading diff…

Original Bug Report

reported by [email protected]

WebHID permission revocation bypass via unclosed watchers on null BrowserContext

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: HidService::OnPermissionRevoked in the browser process fails open when its backing ServiceWorkerVersion has been destroyed: GetBrowserContext() is null, so the delegate returns null device info for every guid, and the null-device_info branch erases the watcher_ids_ bookkeeping entry without calling watchers_.Remove(). One user revocation therefore empties the bookkeeping map while leaving every HidConnectionWatcher receiver — and thus every open device connection — intact and untracked. A compromised renderer hosting the attacker’s own extension keeps full HID read/write access to the revoked device, silently and permanently. The WebUSB sibling handler fails safe in the identical state; this is HID-specific.

Affected files:

Estimated timestamp from git blame: 2020-10-01

Summary

A service-worker-mode HidService is self-owned (mojo::MakeSelfOwnedReceiver, hid_service.cc:221-226): its lifetime is the renderer-held pipe, not the ServiceWorkerVersion. Revocation routing is keyed by registration id and erased only on registration deletion (service_worker_device_delegate_observer.cc:48-59), so in the state “version destroyed, registration alive, pipes held open”, the user’s revocation is still dispatched to the orphaned service — and the handler then fails open on its null browser context.

The failure is silent (no CHECK; both connection-count updates no-op on null context) and unrecoverable: every later cleanup sweep (OnDeviceRemoved, OnDeviceChanged, repeat revocation) keys off the now-empty watcher_ids_, so the orphaned watchers can never be found again. Only renderer death or browser exit closes the connection. Device-side HidConnectionImpl I/O performs no permission checks; enforcement lives entirely in the bypassed layer.

This was demonstrated end-to-end in an instrumented build: at revocation the handler observed a null BrowserContext, watcher_ids_ went 1→0 while watchers_ kept its entry, and the renderer completed 13+ post-revocation write/read round trips to the device while chrome://settings showed no remaining grant and getDevices() returned []. A control run with the version still alive closed the connection correctly, isolating the null-context state as the trigger.

This is distinct from the previously reported unregister-path bypass (there, routing is severed at the broker and the handler is never invoked; extension update takes that path). Here routing is intact, the handler runs, and the defect is inside the handler — fixing one does not fix the other. It is also distinct from the valid-context null-GetDeviceInfo double-decrement report, whose consequence is a CHECK crash; on this path no CHECK can fire.


Root Cause Analysis

  1. Fail-open branch: content/browser/hid/hid_service.cc:682-696 — the revocation sweep erases the guid→ReceiverId bookkeeping without closing the receiver when device info is unavailable:
    // [`content/browser/hid/hid_service.cc:682-696`](https://source.chromium.org/chromium/chromium/src/+/main:content/browser/hid/hid_service.cc;l=682)
    size_t watchers_removed =
        std::erase_if(watcher_ids_, [&](const auto& watcher_entry) {
          const auto* device_info =
              delegate->GetDeviceInfo(browser_context, watcher_entry.first);
          if (!device_info)
            return true;             // <-- erased from watcher_ids_, but NO watchers_.Remove()
          ...
          watchers_.Remove(watcher_entry.second);  // only on the has-device path
          return true;
        });
    
    watcher_ids_ exists precisely “to allow closing particular connections when the user revokes a permission” (hid_service.h:150-154); the entry left in watchers_ keeps the connection open.
  2. Null context is guaranteed for a dead-version SW service: content/browser/hid/hid_service.cc:763-771 returns nullptr once the service_worker_version_ WeakPtr is invalid, and chrome/browser/hid/chrome_hid_delegate.cc:42-47 ("|browser_context| might be null in a service worker case…") then makes GetDeviceInfo (:303-311) return null for every guid — so the fail-open branch fires for all entries at once.
  3. Revocation still reaches the orphaned service: service_worker_hid_delegate_observer.cc:80-89 dispatches to every registered HidService WeakPtr with no version-liveness check (the device-event path at :143-145 has one; the revocation path does not), and routing is erased only on registration deletion, not version destruction (service_worker_device_delegate_observer.cc:48-59).
  4. The leaked watcher is what keeps the device open: services/device/hid/hid_connection_impl.cc:26-54 — HidConnectionImpl self-destructs (closing the OS handle) only when its HidConnection receiver or its HidConnectionWatcher remote disconnects; the renderer holds the former, the leaked watchers_ entry is the latter. Read/Write/GetFeatureReport (:69-117) perform no permission checks.
  5. Silent and permanent: OnWatcherRemoved skips DecrementActivityCount because watchers_ is non-empty (hid_service.cc:530-531), and ChromeHidDelegate::DecrementConnectionCount(nullptr, origin) no-ops (chrome_hid_delegate.cc:50-58,369-387) — also leaving the profile’s connection-tracker count permanently stale. All subsequent sweeps iterate the now-empty watcher_ids_; only ~HidService closes watchers_.
  6. Contrast — USB fails safe: content/browser/usb/web_usb_service_impl.cc:421-439 erases a unique_ptr<UsbDeviceClient> on the same null-device-info branch; the destructor closes the client pipe and DeviceImpl closes the device (services/device/usb/mojo/device_impl.cc:198-201,839-845). USB: null context ⇒ revoke-all. HID: null context ⇒ forget-all-but-close-none.

Reproduction

  1. User installs the attacker’s MV3 extension and grants it a HID device via the WebHID chooser (persistent grant for the extension origin).
  2. Extension page registers a service worker v1 at a non-root scope ({scope: '/sub/'} — allowed for extensions, chrome_content_browser_client_extensions_part.cc:155-158); v1 activates without using HID.
  3. Attacker compromises the renderer process hosting its own extension content (standard compromised-renderer premise per content/SECURITY.md).
  4. Page registers the same scope with a different script (v2) — an update job on the same registration (service_worker_register_job.cc:312-356). v2’s top-level script opens the granted device via navigator.hid (exposed on extension SW scopes; the browser-side binder has no version-status gate), creating watchers_/watcher_ids_ entries.
  5. v2’s install handler rejects: v2 is doomed (REDUNDANT) and destroyed while the registration survives because v1 is still installed (service_worker_register_job.cc:934-937); a stalling renderer cannot pin the version past the stop timeout (service_worker_version.cc:1702-1709). The compromised renderer keeps the blink.mojom.HidService and device::mojom::HidConnection pipes open at process level.
  6. User revokes the HID permission in chrome://settings (HID chooser exceptions, also reachable from the in-use indicator).
  7. Expected: the browser closes the device connection — the norm stated in content/SECURITY.md (“when access to a permission is revoked, the renderer should be cut-off from the protected resource”), and what this same handler does when the version is alive.

Observed (demonstrated in an instrumented build with the stock permission/HID layers): OnPermissionRevoked ran with browser_context == nullptr, emptied watcher_ids_ while watchers_ retained its entry, and no CHECK fired; the renderer then continued successful Write/input-report round trips on the revoked device indefinitely (one every 2s until browser exit), while the settings UI showed no grant and getDevices() returned []. No subsequent revocation or device event could close the orphaned connection.

Severity

medium — Browser-process defeat of the user’s permission-revocation decision, with silent, unrecoverable persistence: the attacker retains unrestricted input-report reads and output/feature-report writes on protected hardware after the grant is gone. Not memory corruption, so higher tiers do not apply; preconditions (user installs the attacker’s extension, grants a device, renderer compromise, later revocation) bound it below high. Per the extensions security FAQ, access “without appropriate permission… would be a security bug” — post-revocation there is no permission. Chrome rated the same premise/component/impact class Medium in CVE-2026-13864.

Suggested Fix Direction

In the !device_info branch, close the watcher before erasing the bookkeeping entry (watchers_.Remove(watcher_entry.second)), and/or treat a null browser context as revoke-all — mirroring the fail-safe WebUSB semantics. This one-line change was verified to close the hole: the identical attack flow ends with the connection closed at revocation and all further I/O impossible.


POC: WebHID revocation bypass — HidService::OnPermissionRevoked with dead ServiceWorkerVersion (null BrowserContext) erases watcher_ids_ without closing watchers_; device connections silently and permanently survive revocation

Strategy: code-injection (simulated compromised renderer of the attacker’s own extension process) Bug Type: CWE-459 — permission-revocation bypass / incomplete cleanup (logic bug in the browser process; no memory corruption, so no ASan report is expected — the proof is the behavioral oracle below)

What the POC demonstrates

  1. The user grants the attacker’s MV3 extension a HID device through the real Chrome chooser (screenshot-chooser-grant.png).

  2. The extension registers a benign service worker v1 at the non-root scope /sub/, then re-registers the same scope with a different script (v2 = same-registration update job). v2’s evaluation opens the granted device via its own blink.mojom.HidService binding; injected renderer code (playing a compromised renderer) keeps the HidService and device.mojom.HidConnection pipes alive at process level. v2’s install fails on purpose, so v2 is doomed (REDUNDANT + stopped) and its ServiceWorkerVersion is destroyed while the registration (and the browser-side self-owned content::HidService, kept alive by the held pipe) survive.

  3. The user revokes the HID permission in the real chrome://settings/content/hidDevices UI (screenshot-settings-exception.png, the automation clicks the row’s delete button).

  4. Revocation dispatch reaches the orphaned service-worker-mode HidService, whose GetBrowserContext() is now null → GetDeviceInfo(nullptr, guid) is null for every entry → the !device_info branch erases the watcher_ids_ bookkeeping without watchers_.Remove():

    [POC-ORACLE] HidService::OnPermissionRevoked ... mode=service-worker browser_context=0 sw_version_alive=0 watcher_ids(before)=1 watchers(before)=1
    [POC-ORACLE] HidService::OnPermissionRevoked ... watcher_ids(after)=0 watchers(after)=1  *** BUG STATE: bookkeeping emptied but device connections left open and untracked ***
    
  5. Device I/O continues indefinitely after revocation (13+ full Write→device→input-echo round trips in the 20 s observation window, one every 2 s, until the browser is closed), while the settings UI shows the grant is gone (screenshot-settings-after-revoke.png, “No HID devices found”) and navigator.hid.getDevices() returns []:

    [POC-DEVICE]   OUTPUT report received from browser (9 bytes): 0050574E2330313700   <- after revocation
    [POC-RENDERER] INPUT report received from device (echo): report_id=0 payload=50574E2330313700
    [POC-RENDERER] io #17 Write(output report) result: success=1
    

The state is unrecoverable: watcher_ids_ is empty, so no later sweep (second revocation, device removal/change) can ever find the orphaned watcher. Only renderer death or browser exit closes the connection.

Files

File Purpose
poc.mjs Node orchestrator: launches Chrome, automates the user’s chooser grant (X11) and settings revocation (DevTools-driven click of the real settings UI), runs the SW dance, analyzes the log
proof-extension/ The attacker extension: manifest.json (MV3, no background SW), page.html/js (driver page A), keeper.html/js (page B: keeps the process alive, polls getDevices), sub_v1.js, sub_v2.js
chromium-changes.patch The injected code (see “Injected code” below)
bandaid-fix.patch Minimal fix; verified to close the hole (see below)
chrome_log_vulnerable.txt Full Chrome log of the successful reproduction (grep POC-)
verdict_vulnerable.json Machine-checked verdict of the reproduction run
poc_run_vulnerable.log Orchestrator transcript of the reproduction run
chrome_log_bandaid_fixed.txt, verdict_bandaid.json, poc_run_bandaid.log Fix-verification run: identical flow, connection now closed at revocation
poc_run_control_version_alive.log Control run: same flow but revocation while v2’s version was still alive → non-null context → the handler closed the connection correctly (watchers(after)=0). Isolates the null-context state as the defect trigger
screenshot-chooser-grant.png Real HID chooser granting “POC Virtual Device” to the extension
screenshot-settings-exception.png chrome://settings/content/hidDevices showing the grant + its delete button before revocation
screenshot-settings-after-revoke.png Same page after revocation: “No HID devices found” (while device I/O keeps running)

Injected code (chromium-changes.patch)

The vulnerable browser-side logic is unmodified. The patch contains:

  1. services/device/hid/hid_service.cc — virtual HID hardware. The sandbox has no /dev/uhid/udev, so a kernel virtual device is impossible; this substitutes the platform hardware backend with a virtual device (vendor 0xFEED, product 0xBEEF, serial POC0001, vendor-page report descriptor with 8-byte input/output reports). Everything above — HidManagerImpl, HidConnectionImpl (the dual-pipe watcher ownership the bug leaks), content::HidService, HidChooserContext, chooser and settings UI — is stock. PlatformWrite logs each OUTPUT report ([POC-DEVICE], the device-side oracle) and echoes it back as an input report.
  2. third_party/blink/renderer/modules/hid/hid.cc — simulated compromised renderer. When v2’s worker touches navigator.hid, the injected code binds its own blink.mojom.HidService via the worker’s BrowserInterfaceBroker (the identical, unprivileged Mojo surface the normal WebHID plumbing uses), calls GetDevices/Connect on it, and stashes the resulting pipes in process-global state so worker teardown cannot close them — the standard capability of a compromised renderer (pipes are process-level resources). Page B later binds the stashed HidConnection/HidConnectionClient on its main thread and performs Write/receives echo input reports ([POC-RENDERER] logs).
  3. Observation-only logging (no behavior change): content::HidService::OnPermissionRevoked/destructor ([POC-ORACLE]), and ServiceWorkerVersion Doom/OnStoppedInternal/destructor ([POC-SW], used to wait until v2’s version object is actually destroyed before revoking).

Prerequisites

  • Linux ASan build of Chrome at /chromium_src/src/out/asan/chrome with chromium-changes.patch applied (autoninja -C out/asan chrome)
  • X display (headful), xdotool, scrot, Node ≥ 22 (global WebSocket)

Reproduction

cd /chromium_src/src && git apply /poc-artifacts/chromium-changes.patch
autoninja -C out/asan chrome

cd /poc-artifacts && node poc.mjs        # exit code 0 = reproduced
# evidence: work/chrome_log.txt (grep POC-), work/verdict.json, work/*.png

The orchestrator only automates user actions (chooser click via xdotool, settings-row delete click via DevTools Runtime.evaluate in the settings page) and drives the extension pages over the DevTools protocol (--remote-debugging-port). Environment-required flags: --user-data-dir, --no-first-run, --no-default-browser-check, --ignore-gpu-blocklist (sandbox GL), --no-proxy-server (sandbox proxy env), --enable-logging=stderr (log capture), --load-extension (simulates the premise “user installed the attacker’s extension”). No security feature is disabled and no non-default feature is enabled.

Expected Result

Exit code 0 and work/verdict.json equal to (numbers ≥ shown):

{
  "revocation_reached_sw_hidservice": true,
  "null_browser_context_at_revocation": true,
  "bug_state_logged": true,
  "post_revocation_write_successes": 13,
  "post_revocation_device_output_reports": 13,
  "post_revocation_read_echo_successes": 13,
  "connection_closed_by_browser": false,
  "granted_list_after_revocation": 0
}

Output (actual, from chrome_log_vulnerable.txt)

[POC-DEVICE] virtual HID device enumerated: guid=...
[POC-RENDERER] worker attack: start in service worker scope chrome-extension://hfnh.../sub_v2.js -- binding own blink.mojom.HidService via BrowserInterfaceBroker
[POC-RENDERER] worker attack: GetDevices returned 1 granted device(s)
[POC-RENDERER] worker attack: device connection ESTABLISHED; HidConnection pipe stashed at process level ...
[POC-SW] Doom version_id=2 script=chrome-extension://hfnh.../sub_v2.js running_status=2 devtools_attached=0
[POC-SW] OnStoppedInternal version_id=2 ...
[POC-SW] ~ServiceWorkerVersion version_id=2 script=chrome-extension://hfnh.../sub_v2.js      <- v2 destroyed, registration + held pipes survive
... user clicks the delete button in chrome://settings/content/hidDevices ...
[POC-ORACLE] HidService::OnPermissionRevoked this=0x73ba22b05240 mode=service-worker origin=chrome-extension://hfnh... browser_context=0 sw_version_alive=0 watcher_ids(before)=1 watchers(before)=1
[POC-ORACLE] HidService::OnPermissionRevoked this=0x73ba22b05240 watcher_ids(after)=0 watchers(after)=1  *** BUG STATE: bookkeeping emptied but device connections left open and untracked ***
[POC-DEVICE] OUTPUT report received from browser (9 bytes): 0050574E2330313700               <- AFTER revocation
[POC-RENDERER] INPUT report received from device (echo): report_id=0 payload=50574E2330313700
[POC-RENDERER] io #17 Write(output report) result: success=1
...continues every 2 s until browser exit; getDevices() meanwhile returns []...

Control run (defect isolation)

poc_run_control_version_alive.log: when revocation happens while v2’s ServiceWorkerVersion is still alive, the same handler sees a non-null browser context and closes the connection correctly (watcher_ids(after)=0 watchers(after)=0, stolen HidConnection DISCONNECTED). The null-context state is precisely what flips the handler from fail-safe to fail-open. (The USB sibling, WebUsbServiceImpl::OnPermissionRevoked, is fail-safe even with a null context because the erased entry owns the connection.)

Bandaid fix (bandaid-fix.patch) — verified

In the !device_info branch of HidService::OnPermissionRevoked, close the watcher before erasing the bookkeeping entry (mirrors the USB semantics):

if (!device_info) {
  watchers_.Remove(watcher_entry.second);
  return true;
}

Verification (chrome_log_bandaid_fixed.txt): identical attack flow and identical null-context state, but at revocation watchers(after)=0, followed within 1 ms by [POC-DEVICE] device connection CLOSED by the browser and [POC-RENDERER] stolen HidConnection DISCONNECTED; every later I/O attempt logs stolen connection pipe is CLOSED; device I/O no longer possible.

Fidelity notes

  • Virtual device: the only simulated component is the physical HID hardware (sandbox has no /dev/uhid). It is injected below the real HidConnectionImpl/HidManagerImpl, so the leaked object graph (watchers_ entry → HidConnectionWatcher pipe → HidConnectionImpl → platform connection) is the production one.
  • Compromised renderer: required by the vulnerability’s premise (holding Mojo pipe endpoints past worker teardown). The injected code uses only the renderer-legitimate Mojo surface (BrowserInterfaceBroker → HidService) and performs no browser-side changes; the browser-side validation it faces (permission check at Connect time, non-nullable client field enforced by message validation) is fully active — an earlier POC iteration that passed a null client was killed with “Terminating render process for bad Mojo message”, demonstrating the validation layer stayed honest.
  • User actions (install extension, chooser grant, settings revocation) are automated but exercised through the real production UI surfaces.

Attached file hierarchy:

The following files were flattened for attachment. Double-underscores (__) in filenames represent directory separators.

  • proof-extension__keeper.html → proof-extension/keeper.html
  • proof-extension__keeper.js → proof-extension/keeper.js
  • proof-extension__manifest.json → proof-extension/manifest.json
  • proof-extension__page.html → proof-extension/page.html
  • proof-extension__page.js → proof-extension/page.js
  • proof-extension__sub_v1.js → proof-extension/sub_v1.js
  • proof-extension__sub_v2.js → proof-extension/sub_v2.js

The following directories were attached as zip archives:

  • work.zip → work/

Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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