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 <jochen@chromium.org>
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 <jochen@chromium.org>
Reviewed-by: Kentaro Hara <haraken@chromium.org>
Reviewed-by: Christoph Schwering <schwering@google.com>
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.