CVE-2026-113050
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
erase_ifcontent/browser/hid/hid_service.cc |
modified | |
TEST_Pcontent/browser/hid/hid_service_unittest.cc |
modified |
Files Changed
content/browser/hid/hid_service.cccontent/browser/hid/hid_service_unittest.cc
Patch
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);
Regression Test / PoC
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);
Original Bug Report
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:
content/browser/hid/hid_service.cc:682-696— fail-open branch:watcher_ids_entry erased withoutwatchers_.Remove()whenGetDeviceInforeturns nullcontent/browser/hid/hid_service.cc:763-771—GetBrowserContext()returns null once theservice_worker_version_WeakPtr is deadchrome/browser/hid/chrome_hid_delegate.cc:42-47,303-311— null context ⇒ null chooser context ⇒ null device info for every guid (comment explicitly anticipates this state)content/browser/service_worker/service_worker_hid_delegate_observer.cc:80-89— revocation is dispatched to dead-version HidServices with no version-liveness gatecontent/browser/usb/web_usb_service_impl.cc:421-439— contrast: USB erases the connection-owning object, so the same null-context state revokes all connections
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
- 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 inwatchers_keeps the connection open. - Null context is guaranteed for a dead-version SW service:
content/browser/hid/hid_service.cc:763-771returns nullptr once theservice_worker_version_WeakPtr is invalid, andchrome/browser/hid/chrome_hid_delegate.cc:42-47("|browser_context| might be null in a service worker case…") then makesGetDeviceInfo(:303-311) return null for every guid — so the fail-open branch fires for all entries at once. - Revocation still reaches the orphaned service:
service_worker_hid_delegate_observer.cc:80-89dispatches to every registered HidService WeakPtr with no version-liveness check (the device-event path at:143-145has 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). - The leaked watcher is what keeps the device open:
services/device/hid/hid_connection_impl.cc:26-54—HidConnectionImplself-destructs (closing the OS handle) only when itsHidConnectionreceiver or itsHidConnectionWatcherremote disconnects; the renderer holds the former, the leakedwatchers_entry is the latter.Read/Write/GetFeatureReport(:69-117) perform no permission checks. - Silent and permanent:
OnWatcherRemovedskipsDecrementActivityCountbecausewatchers_is non-empty (hid_service.cc:530-531), andChromeHidDelegate::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-emptywatcher_ids_; only~HidServicecloseswatchers_. - Contrast — USB fails safe:
content/browser/usb/web_usb_service_impl.cc:421-439erases aunique_ptr<UsbDeviceClient>on the same null-device-info branch; the destructor closes the client pipe andDeviceImplcloses 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
- User installs the attacker’s MV3 extension and grants it a HID device via the WebHID chooser (persistent grant for the extension origin).
- 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. - Attacker compromises the renderer process hosting its own extension content (standard compromised-renderer premise per
content/SECURITY.md). - 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 vianavigator.hid(exposed on extension SW scopes; the browser-side binder has no version-status gate), creatingwatchers_/watcher_ids_entries. - 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 theblink.mojom.HidServiceanddevice::mojom::HidConnectionpipes open at process level. - User revokes the HID permission in chrome://settings (HID chooser exceptions, also reachable from the in-use indicator).
- 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
-
The user grants the attacker’s MV3 extension a HID device through the real Chrome chooser (
screenshot-chooser-grant.png). -
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 ownblink.mojom.HidServicebinding; injected renderer code (playing a compromised renderer) keeps theHidServiceanddevice.mojom.HidConnectionpipes alive at process level. v2’s install fails on purpose, so v2 is doomed (REDUNDANT+ stopped) and itsServiceWorkerVersionis destroyed while the registration (and the browser-side self-ownedcontent::HidService, kept alive by the held pipe) survive. -
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). -
Revocation dispatch reaches the orphaned service-worker-mode
HidService, whoseGetBrowserContext()is now null →GetDeviceInfo(nullptr, guid)is null for every entry → the!device_infobranch erases thewatcher_ids_bookkeeping withoutwatchers_.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 *** -
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”) andnavigator.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:
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, serialPOC0001, 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.PlatformWritelogs each OUTPUT report ([POC-DEVICE], the device-side oracle) and echoes it back as an input report.third_party/blink/renderer/modules/hid/hid.cc— simulated compromised renderer. When v2’s worker touchesnavigator.hid, the injected code binds its ownblink.mojom.HidServicevia the worker’s BrowserInterfaceBroker (the identical, unprivileged Mojo surface the normal WebHID plumbing uses), callsGetDevices/Connecton 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 stashedHidConnection/HidConnectionClienton its main thread and performs Write/receives echo input reports ([POC-RENDERER]logs).- Observation-only logging (no behavior change):
content::HidService::OnPermissionRevoked/destructor ([POC-ORACLE]), andServiceWorkerVersionDoom/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/chromewithchromium-changes.patchapplied (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 realHidConnectionImpl/HidManagerImpl, so the leaked object graph (watchers_entry →HidConnectionWatcherpipe →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
Connecttime, 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.htmlproof-extension__keeper.js→proof-extension/keeper.jsproof-extension__manifest.json→proof-extension/manifest.jsonproof-extension__page.html→proof-extension/page.htmlproof-extension__page.js→proof-extension/page.jsproof-extension__sub_v1.js→proof-extension/sub_v1.jsproof-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.
- https://source.chromium.org/chromium/chromium/src/+/main:chrome/browser/hid/chrome_hid_delegate.cc;l=42
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/hid/hid_service.cc;l=682
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/hid/hid_service.cc;l=763
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/service_worker/service_worker_hid_delegate_observer.cc;l=80
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/usb/web_usb_service_impl.cc;l=421
- https://source.chromium.org/chromium/chromium/src/+/main:services/device/hid/hid_connection_impl.cc;l=26
- https://source.chromium.org/chromium/chromium/src/+/main:services/device/hid/hid_service.cc
- https://source.chromium.org/chromium/chromium/src/+/main:services/device/usb/mojo/device_impl.cc;l=198
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/hid/hid.cc