CVE-2026-17734
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/forms/autofill_event.cc |
modified |
Files Changed
chrome/browser/autofill/autofill_event_handler_browsertest.ccthird_party/blink/renderer/core/html/forms/autofill_event.ccthird_party/blink/renderer/core/html/forms/autofill_event.hthird_party/blink/renderer/core/html/forms/autofill_event.idl
Patch
From e51e3ebeefaca2d1e9326bde3297e9d857230466 Mon Sep 17 00:00:00 2001 From: Jochen Eisinger <[email protected]> Date: Thu, 11 Jun 2026 06:06:10 -0700 Subject: [PATCH] Fix cross-world leak in AutofillEvent::refill callback caching The AutofillEvent::refill getter was caching the callback function in a C++ member variable. Since C++ DOM objects are shared across V8 worlds, this allowed a privileged isolated world to cache a callback that could then be accessed by the main world, leading to a cross-world leak (UXSS). This CL fixes the issue by caching the callback on the V8 wrapper object instead of the C++ object, using [CachedAttribute] in IDL. Since JS wrappers are per-world, the cache is now naturally partitioned by world. Bug: 496304083 Change-Id: I129bd58852d1074ae36b15f38c16ba4df680eb66 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7921126 Commit-Queue: Jochen Eisinger <[email protected]> Reviewed-by: Kentaro Hara <[email protected]> Reviewed-by: Christoph Schwering <[email protected]> Cr-Commit-Position: refs/heads/main@{#1645305} --- diff --git a/chrome/browser/autofill/autofill_event_handler_browsertest.cc b/chrome/browser/autofill/autofill_event_handler_browsertest.cc index ef2ddd7..f6a8108f 100644 --- a/chrome/browser/autofill/autofill_event_handler_browsertest.cc +++ b/chrome/browser/autofill/autofill_event_handler_browsertest.cc @@ -955,4 +955,84 @@ << "CC Number iframe should not contain CVC field from other iframe"; } +// Verifies that the refill callback does not leak across V8 worlds. +// 1. Accessing event.refill in an isolated world caches it for that world. +// 2. Accessing event.refill in the main world caches it for the main world. +// 3. The main world should get its own callback, not the isolated world's one. +IN_PROC_BROWSER_TEST_F(AutofillEventHandlerBrowserTest, + AutofillEventIsolatedWorldRefillLeak) { + GURL url = + embedded_test_server()->GetURL("/autofill/autofill_address_enabled.html"); + ASSERT_TRUE(chrome_test_utils::NavigateToURL(web_contents(), url)); + + TestAutofillManager* manager = main_autofill_manager(); + ASSERT_TRUE(manager->WaitForFormsSeen(/*min_num_awaited_calls=*/1)); + + const std::vector<const FormStructure*> form_structures = + test_api(*manager).form_structures(); + ASSERT_FALSE(form_structures.empty()); + const FormData& form = form_structures.front()->ToFormData(); + const FormFieldData& trigger_field = form.fields()[0]; + + const int32_t kIsolatedWorldId = 1; + + // Set up listeners in both worlds. + // Isolated world listener: stores refill callback. + ASSERT_TRUE(content::ExecJs(web_contents(), + R"( + window.isolatedRefill = null; + document.addEventListener('autofill', (e) => { + window.isolatedRefill = e.refill; + }); + )", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + kIsolatedWorldId)); + + // Main world listener: stores refill callback and checks its constructor. + ASSERT_TRUE(content::ExecJs(web_contents(), + R"( + window.mainRefill = null; + window.mainRefillConstructorIsFunction = false; + document.addEventListener('autofill', (e) => { + window.mainRefill = e.refill; + window.mainRefillConstructorIsFunction = + (e.refill.constructor === Function); + }); + )")); + + // Trigger autofill. + FillAddress(main_frame(), form, trigger_field); + + // Wait for autofill to complete. + ASSERT_TRUE(manager->WaitForAutofillFill(/*num_expected_fills=*/1)); + + // Verify both worlds got their callbacks. + EXPECT_TRUE(content::EvalJs(web_contents(), "window.isolatedRefill !== null", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, + kIsolatedWorldId) + .ExtractBool()); + + EXPECT_TRUE(content::EvalJs(web_contents(), "window.mainRefill !== null") + .ExtractBool()); + + // Verify no leak: main world's callback constructor is the main world's + // Function. + EXPECT_TRUE( + content::EvalJs(web_contents(), "window.mainRefillConstructorIsFunction") + .ExtractBool()) + << "Main world refill callback constructor is not main world's Function " + "(leak suspected)."; + + // Verify they are different objects/wrappers by setting a property in + // isolated world and ensuring main world cannot see it. + ASSERT_TRUE(content::ExecJs( + web_contents(), "window.isolatedRefill.foo = 'bar';", + content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, kIsolatedWorldId)); + + EXPECT_FALSE( + content::EvalJs(web_contents(), "window.mainRefill.foo === 'bar'") + .ExtractBool()) + << "Main world saw property set by isolated world (leak!)."; +} + } // namespace autofill diff --git a/third_party/blink/renderer/core/html/forms/autofill_event.cc b/third_party/blink/renderer/core/html/forms/autofill_event.cc index 4c6e113c..5a6765b 100644 --- a/third_party/blink/renderer/core/html/forms/autofill_event.cc +++ b/third_party/blink/renderer/core/html/forms/autofill_event.cc @@ -47,7 +47,6 @@ void AutofillEvent::Trace(Visitor* visitor) const { visitor->Trace(field_data_); - visitor->Trace(refill_callback_); Event::Trace(visitor); } @@ -92,14 +91,9 @@ return nullptr; } - // Lazily create the callback on first access. - if (!refill_callback_) { - auto* function = MakeGarbageCollected<AutofillRefillFunction>(this); - refill_callback_ = V8AutofillRefillCallback::Create( - function->ToV8Function(script_state).template As<v8::Object>()); - } - - return refill_callback_.Get(); + auto* function = MakeGarbageCollected<AutofillRefillFunction>(this); + return V8AutofillRefillCallback::Create( + function->ToV8Function(script_state).template As<v8::Object>()); } void AutofillEvent::DoRefill(ScriptPromiseResolver<IDLUndefined>* resolver) { diff --git a/third_party/blink/renderer/core/html/forms/autofill_event.h b/third_party/blink/renderer/core/html/forms/autofill_event.h index 9d3b7e8..7eb5e1a 100644 --- a/third_party/blink/renderer/core/html/forms/autofill_event.h +++ b/third_party/blink/renderer/core/html/forms/autofill_event.h @@ -47,6 +47,7 @@ // supported. When called, the callback triggers the refill and returns a // Promise that resolves immediately. V8AutofillRefillCallback* refill(ScriptState*); + bool IsRefillDirty() const { return false; } const HeapVector<Member<AutofillFieldData>>& autofillValues() const; void Trace(Visitor*) const final; @@ -59,7 +60,6 @@ void DoRefill(ScriptPromiseResolver<IDLUndefined>* resolver); HeapVector<Member<AutofillFieldData>> field_data_; - Member<V8AutofillRefillCallback> refill_callback_; base::UnguessableToken fill_id_; bool supports_refill_; }; diff --git a/third_party/blink/renderer/core/html/forms/autofill_event.idl b/third_party/blink/renderer/core/html/forms/autofill_event.idl index ef77c1260..fefdf48 100644 --- a/third_party/blink/renderer/core/html/forms/autofill_event.idl +++ b/third_party/blink/renderer/core/html/forms/autofill_event.idl @@ -9,6 +9,6 @@ [SameObject, SaveSameObject] readonly attribute FrozenArray<AutofillFieldData> autofillValues; // Callback to request a refill of the form. Returns a promise that resolves // when the refill is done. Null if refill is not supported. - [CallWith=ScriptState] readonly attribute AutofillRefillCallback? refill; + [CallWith=ScriptState, CachedAttribute=IsRefillDirty] readonly attribute AutofillRefillCallback? refill; };
Regression Test / PoC
diff --git a/chrome/browser/autofill/autofill_event_handler_browsertest.cc b/chrome/browser/autofill/autofill_event_handler_browsertest.cc
index ef2ddd7..f6a8108f 100644
--- a/chrome/browser/autofill/autofill_event_handler_browsertest.cc
+++ b/chrome/browser/autofill/autofill_event_handler_browsertest.cc
@@ -955,4 +955,84 @@
<< "CC Number iframe should not contain CVC field from other iframe";
}
+// Verifies that the refill callback does not leak across V8 worlds.
+// 1. Accessing event.refill in an isolated world caches it for that world.
+// 2. Accessing event.refill in the main world caches it for the main world.
+// 3. The main world should get its own callback, not the isolated world's one.
+IN_PROC_BROWSER_TEST_F(AutofillEventHandlerBrowserTest,
+ AutofillEventIsolatedWorldRefillLeak) {
+ GURL url =
+ embedded_test_server()->GetURL("/autofill/autofill_address_enabled.html");
+ ASSERT_TRUE(chrome_test_utils::NavigateToURL(web_contents(), url));
+
+ TestAutofillManager* manager = main_autofill_manager();
+ ASSERT_TRUE(manager->WaitForFormsSeen(/*min_num_awaited_calls=*/1));
+
+ const std::vector<const FormStructure*> form_structures =
+ test_api(*manager).form_structures();
+ ASSERT_FALSE(form_structures.empty());
+ const FormData& form = form_structures.front()->ToFormData();
+ const FormFieldData& trigger_field = form.fields()[0];
+
+ const int32_t kIsolatedWorldId = 1;
+
+ // Set up listeners in both worlds.
+ // Isolated world listener: stores refill callback.
+ ASSERT_TRUE(content::ExecJs(web_contents(),
+ R"(
+ window.isolatedRefill = null;
+ document.addEventListener('autofill', (e) => {
+ window.isolatedRefill = e.refill;
+ });
+ )",
+ content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
+ kIsolatedWorldId));
+
+ // Main world listener: stores refill callback and checks its constructor.
+ ASSERT_TRUE(content::ExecJs(web_contents(),
+ R"(
+ window.mainRefill = null;
+ window.mainRefillConstructorIsFunction = false;
+ document.addEventListener('autofill', (e) => {
+ window.mainRefill = e.refill;
+ window.mainRefillConstructorIsFunction =
+ (e.refill.constructor === Function);
+ });
+ )"));
+
+ // Trigger autofill.
+ FillAddress(main_frame(), form, trigger_field);
+
+ // Wait for autofill to complete.
+ ASSERT_TRUE(manager->WaitForAutofillFill(/*num_expected_fills=*/1));
+
+ // Verify both worlds got their callbacks.
+ EXPECT_TRUE(content::EvalJs(web_contents(), "window.isolatedRefill !== null",
+ content::EXECUTE_SCRIPT_DEFAULT_OPTIONS,
+ kIsolatedWorldId)
+ .ExtractBool());
+
+ EXPECT_TRUE(content::EvalJs(web_contents(), "window.mainRefill !== null")
+ .ExtractBool());
+
+ // Verify no leak: main world's callback constructor is the main world's
+ // Function.
+ EXPECT_TRUE(
+ content::EvalJs(web_contents(), "window.mainRefillConstructorIsFunction")
+ .ExtractBool())
+ << "Main world refill callback constructor is not main world's Function "
+ "(leak suspected).";
+
+ // Verify they are different objects/wrappers by setting a property in
+ // isolated world and ensuring main world cannot see it.
+ ASSERT_TRUE(content::ExecJs(
+ web_contents(), "window.isolatedRefill.foo = 'bar';",
+ content::EXECUTE_SCRIPT_DEFAULT_OPTIONS, kIsolatedWorldId));
+
+ EXPECT_FALSE(
+ content::EvalJs(web_contents(), "window.mainRefill.foo === 'bar'")
+ .ExtractBool())
+ << "Main world saw property set by isolated world (leak!).";
+}
+
} // namespace autofill
Original Bug Report
UXSS via cross-world leak in AutofillEvent::refill callback caching
Flapjack (go/flapjack), an LLM-powered vulnerability discovery tool, has identified a security issue and generated a PoC.
d8 variant: ‘Asan’
flags: –allow-natives-syntax –omit-quit
Overview: The AutofillEvent::refill getter lazily initializes and caches a JavaScript callback function on the event object. Because DOM events are shared across V8 worlds, accessing this property from a privileged isolated world caches a privileged v8::Function that can subsequently be read by the main world. Attackers can abuse this leaked function to execute arbitrary code in the privileged context, leading to Universal Cross-Site Scripting (UXSS).
Affected files:
third_party/blink/renderer/bindings/core/v8/script_function.ccthird_party/blink/renderer/core/html/forms/autofill_event.cc
Estimated timestamp from git blame: 2026-01-13
Root Cause
In third_party/blink/renderer/core/html/forms/autofill_event.cc, the refill getter is defined as follows:
V8AutofillRefillCallback* AutofillEvent::refill(ScriptState* script_state) {
// ...
if (!refill_callback_) {
auto* function = MakeGarbageCollected<AutofillRefillFunction>(this);
refill_callback_ = V8AutofillRefillCallback::Create(
function->ToV8Function(script_state).template As<v8::Object>());
}
return refill_callback_.Get();
}
When refill is first accessed, it creates a v8::Function bound to the caller’s ScriptState (which represents a specific v8::Context and DOMWrapperWorld) and caches it in the refill_callback_ member.
Because AutofillEvent is a DOM Event, a single event instance can be observed and accessed from multiple V8 worlds (e.g., an extension’s isolated world and the page’s main world). If a privileged isolated world accesses event.refill first, a v8::Function belonging to that privileged context is created and cached.
If the main world subsequently accesses event.refill, Blink’s bindings layer (ToV8Traits<CallbackInterfaceBase>::ToV8) returns the cached v8::Function. The bindings layer does contain a cross-world check (DCHECK(&callback->GetWorld() == &script_state->World());), but because it is a DCHECK, it is compiled out in release builds. This allows the privileged function to cross the boundary into the unprivileged main world.
Exploitation
Once the unprivileged world obtains a reference to the privileged v8::Function, it can access its constructor property to obtain the privileged world’s Function constructor. The attacker can then execute arbitrary code in the privileged context and retrieve its global object by calling:
const privilegedGlobal = leakedFunc.constructor("return this")();
This completely breaks the isolated world security boundary, resulting in UXSS.
Suggested Fix
Do not cache the callback in a C++ member variable (refill_callback_) on the AutofillEvent object, as C++ DOM objects are shared across worlds.
Instead, either:
- Return a fresh callback per access: Instantiate and return a new
AutofillRefillFunctionandV8AutofillRefillCallbackevery time therefillgetter is invoked, using the current caller’sScriptState. - Cache per-world: If caching is strictly necessary for performance or object identity, store the cached callback on the JavaScript wrapper object itself (e.g., using a
V8PrivateProperty), which ensures the cache is strictly tied to the specificDOMWrapperWorldreading the property.
Evaluated with Chrome root at commit: a3f5fcb392f2902650ca2b71820e7e418787e18b
The description of the vuln is LLM-generated and can contain mistakes. Your feedback is appreciated, and will help us improve Flapjack over time. The PoC was run in a VM and it seemed to be legit - if not, let us know and we can strengthen our checker.