Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Autofill
DescriptionInappropriate implementation in Autofill
ComponentAutofill
Bug ClassLogic Error
Tracker496304083
Fix commite51e3ebeefac (chromium/src) +85/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/html/forms/autofill_event.cc
modified

Files Changed

  • chrome/browser/autofill/autofill_event_handler_browsertest.cc
  • third_party/blink/renderer/core/html/forms/autofill_event.cc
  • third_party/blink/renderer/core/html/forms/autofill_event.h
  • third_party/blink/renderer/core/html/forms/autofill_event.idl
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;
 };
 
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cc
  • third_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:

  1. Return a fresh callback per access: Instantiate and return a new AutofillRefillFunction and V8AutofillRefillCallback every time the refill getter is invoked, using the current caller’s ScriptState.
  2. 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 specific DOMWrapperWorld reading 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.

View on issue tracker