Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Password Manager
DescriptionUse after free in Password Manager
ComponentPassword Manager
Bug ClassUAF
Tracker498815068
Fix commitcb6bb05a7a0e (chromium/src) +354/-154
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
chrome/renderer/autofill/password_generation_agent_browsertest.cc
modified

Files Changed

  • chrome/renderer/autofill/password_generation_agent_browsertest.cc
From cb6bb05a7a0edb57092a57a72bf29f1f0621c633 Mon Sep 17 00:00:00 2001
From: Christoph Schwering <[email protected]>
Date: Wed, 10 Jun 2026 07:10:48 -0700
Subject: [PATCH] [Autofill] Fix reentrancy in PasswordGenerationAgent

This CL introduces a fundamental mechanism to handle reentrant calls
that destroy `current_generation_item_` in PasswordGenerationAgent.

The CL defines three new functions to access the GenerationItemInfo:
(1) GetAndProtect() returns
    - a reference to the GenerationItemInfo;
    - a RAII object for whose lifetime the GenerationItemInfo is
      write-protected;
(2) CheckedSet()
    - crashes if the GenerationItemInfo is write-protected;
    - sets the GenerationItemInfo otherwise;
(3) IsProtected() indicates if the the GenerationItemInfo is protected.

This is a change in behavior: Previously, reentrant write operations
won and it was the readers' responsibility to avoid UAFs. Now, reentrant
write operations in MaybeCreateCurrentGenerationItem() are no-ops.

This fixes a UAF and enables us to simplify previous UAF fixes in the
followups crrev.com/c/7913606 and crrev.com/c/7915888.

Bug: 516910278, 498815068, 511774568, 518812295
Change-Id: Ie7f233fcdf1bf772c251548841d9a1f6df5d325b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7915304
Reviewed-by: Maria Kazinova <[email protected]>
Reviewed-by: Jihad Hanna <[email protected]>
Commit-Queue: Christoph Schwering <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1644619}
---

diff --git a/chrome/renderer/autofill/password_generation_agent_browsertest.cc b/chrome/renderer/autofill/password_generation_agent_browsertest.cc
index 173a972..7d439a8f 100644
--- a/chrome/renderer/autofill/password_generation_agent_browsertest.cc
+++ b/chrome/renderer/autofill/password_generation_agent_browsertest.cc
@@ -37,12 +37,14 @@
 #include "services/service_manager/public/cpp/interface_provider.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
+#include "third_party/blink/public/common/renderer_preferences/renderer_preferences.h"
 #include "third_party/blink/public/platform/web_string.h"
 #include "third_party/blink/public/web/web_document.h"
 #include "third_party/blink/public/web/web_element.h"
 #include "third_party/blink/public/web/web_frame_widget.h"
 #include "third_party/blink/public/web/web_input_element.h"
 #include "third_party/blink/public/web/web_local_frame.h"
+#include "third_party/blink/public/web/web_view.h"
 #include "ui/events/keycodes/keyboard_codes.h"
 
 using autofill::mojom::FocusedFieldType;
@@ -408,10 +410,10 @@
   SCOPED_TRACE(testing::Message()
                << "element_id = " << element_id << " available = " << status);
   if (status == kNotReported) {
-    EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_)).Times(0);
+    EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(0);
   } else {
     // TODO(crbug.com/40279043): Expect the call precisely once.
-    EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_))
+    EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable)
         .Times(testing::AtLeast(1));
   }
 
@@ -678,8 +680,8 @@
 
   // Verify that password mirroring works correctly even when the password
   // is deleted.
-  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_));
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   SimulateUserInputChangeForElement(first_password_element, std::string());
   EXPECT_EQ(std::u16string(), first_password_element.Value().Utf16());
   EXPECT_EQ(std::u16string(), second_password_element.Value().Utf16());
@@ -729,8 +731,8 @@
   }
 
   // Delete one more character and move back to the generation state.
-  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_));
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   SimulateUserTypingASCIICharacter(ui::VKEY_BACK, true);
   fake_pw_client_.Flush();
   // Last focused element shouldn't change while editing.
@@ -783,7 +785,7 @@
 
   // Simulate the user deleting a character. The generation popup should be
   // shown again.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   SimulateUserTypingASCIICharacter(ui::VKEY_BACK, true);
   fake_pw_client_.Flush();
   testing::Mock::VerifyAndClearExpectations(&fake_pw_client_);
@@ -793,8 +795,7 @@
   fake_pw_client_.Flush();
 
   // Focusing the password field will bring up the generation UI again.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_))
-      .Times(AtLeast(1));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(AtLeast(1));
   FocusField("first_password");
   fake_pw_client_.Flush();
   testing::Mock::VerifyAndClearExpectations(&fake_pw_client_);
@@ -830,12 +831,12 @@
   ExpectEditingPopupOnFieldFocus("first_password");
 
   // Delete most of the password.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_)).Times(0);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(0);
   SimulateUserTypingASCIICharacter(ui::VKEY_END, false);
   size_t max_chars_to_delete =
       password.length() -
       PasswordGenerationAgent::kMinimumLengthForEditedPassword;
-  EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _))
+  EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword)
       .Times(testing::AtLeast(1));
   for (size_t i = 0; i < max_chars_to_delete; ++i)
     SimulateUserTypingASCIICharacter(ui::VKEY_BACK, false);
@@ -843,7 +844,7 @@
   testing::Mock::VerifyAndClearExpectations(&fake_pw_client_);
 
   // Delete one more character. The state should move to offering generation.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(testing::_));
   SimulateUserTypingASCIICharacter(ui::VKEY_BACK, true);
   fake_pw_client_.Flush();
@@ -1070,12 +1071,12 @@
     base::RunLoop().RunUntilIdle();
 
     ExpectEditingPopupOnFieldFocus(test_case.generation_element);
-    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _));
+    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword);
     SimulateUserTypingASCIICharacter('a', true);
     base::RunLoop().RunUntilIdle();
 
     ExpectGenerationElementLostFocus("username");
-    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _));
+    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword);
     SimulateUserTypingASCIICharacter('X', true);
     base::RunLoop().RunUntilIdle();
     testing::Mock::VerifyAndClearExpectations(&fake_pw_client_);
@@ -1088,7 +1089,7 @@
     SimulateUserTypingASCIICharacter(ui::VKEY_BACK, true);
     base::RunLoop().RunUntilIdle();
 
-    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _)).Times(0);
+    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword).Times(0);
     ExpectGenerationElementLostFocus("username");
     SimulateUserTypingASCIICharacter('Y', true);
     base::RunLoop().RunUntilIdle();
@@ -1183,8 +1184,8 @@
 
   // Should not reset the password generation as this is not drived by user.
   // Some websites might clear the user data right before submission.
-  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_)).Times(0);
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_)).Times(0);
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated).Times(0);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(0);
   ExecuteJavaScriptForTests(
       "document.getElementById('first_password').value = '';");
   base::RunLoop().RunUntilIdle();
@@ -1206,7 +1207,7 @@
   // Should not reset the password generation as this is not drived by user.
   // Some websites might clear the user data right before submission.
   EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(testing::_)).Times(0);
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_)).Times(0);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(0);
   ExecuteJavaScriptForTests(
       "document.getElementById('first_password').value = '';");
   base::RunLoop().RunUntilIdle();
@@ -1214,9 +1215,8 @@
   // Should reset the password generation now, when user focuses an empty
   // password field. Now we are sure that the form with the previously generated
   // password won't be submitted.
-  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_)).Times(AtLeast(1));
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_))
-      .Times(AtLeast(1));
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated).Times(AtLeast(1));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(AtLeast(1));
   FocusField("first_password");
 }
 
@@ -1295,7 +1295,7 @@
   ExpectEditingPopupOnFieldFocus(kGenerationElementId);
   SimulateUserTypingASCIICharacter(ui::VKEY_END, false);
   EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(testing::_));
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   size_t max_chars_to_delete =
       password.length() -
       PasswordGenerationAgent::kMinimumLengthForEditedPassword + 1;
@@ -1314,8 +1314,7 @@
   // Delete the rest of the characters. The field should now mask new
   // characters. Due to implementation details it's possible to get pings about
   // password generation available.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/renderer/autofill/password_generation_agent_browsertest.cc b/chrome/renderer/autofill/password_generation_agent_browsertest.cc
index 173a972..7d439a8f 100644
--- a/chrome/renderer/autofill/password_generation_agent_browsertest.cc
+++ b/chrome/renderer/autofill/password_generation_agent_browsertest.cc
@@ -37,12 +37,14 @@
 #include "services/service_manager/public/cpp/interface_provider.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
+#include "third_party/blink/public/common/renderer_preferences/renderer_preferences.h"
 #include "third_party/blink/public/platform/web_string.h"
 #include "third_party/blink/public/web/web_document.h"
 #include "third_party/blink/public/web/web_element.h"
 #include "third_party/blink/public/web/web_frame_widget.h"
 #include "third_party/blink/public/web/web_input_element.h"
 #include "third_party/blink/public/web/web_local_frame.h"
+#include "third_party/blink/public/web/web_view.h"
 #include "ui/events/keycodes/keyboard_codes.h"
 
 using autofill::mojom::FocusedFieldType;
@@ -408,10 +410,10 @@
   SCOPED_TRACE(testing::Message()
                << "element_id = " << element_id << " available = " << status);
   if (status == kNotReported) {
-    EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_)).Times(0);
+    EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(0);
   } else {
     // TODO(crbug.com/40279043): Expect the call precisely once.
-    EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_))
+    EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable)
         .Times(testing::AtLeast(1));
   }
 
@@ -678,8 +680,8 @@
 
   // Verify that password mirroring works correctly even when the password
   // is deleted.
-  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_));
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   SimulateUserInputChangeForElement(first_password_element, std::string());
   EXPECT_EQ(std::u16string(), first_password_element.Value().Utf16());
   EXPECT_EQ(std::u16string(), second_password_element.Value().Utf16());
@@ -729,8 +731,8 @@
   }
 
   // Delete one more character and move back to the generation state.
-  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_));
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   SimulateUserTypingASCIICharacter(ui::VKEY_BACK, true);
   fake_pw_client_.Flush();
   // Last focused element shouldn't change while editing.
@@ -783,7 +785,7 @@
 
   // Simulate the user deleting a character. The generation popup should be
   // shown again.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   SimulateUserTypingASCIICharacter(ui::VKEY_BACK, true);
   fake_pw_client_.Flush();
   testing::Mock::VerifyAndClearExpectations(&fake_pw_client_);
@@ -793,8 +795,7 @@
   fake_pw_client_.Flush();
 
   // Focusing the password field will bring up the generation UI again.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_))
-      .Times(AtLeast(1));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(AtLeast(1));
   FocusField("first_password");
   fake_pw_client_.Flush();
   testing::Mock::VerifyAndClearExpectations(&fake_pw_client_);
@@ -830,12 +831,12 @@
   ExpectEditingPopupOnFieldFocus("first_password");
 
   // Delete most of the password.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_)).Times(0);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(0);
   SimulateUserTypingASCIICharacter(ui::VKEY_END, false);
   size_t max_chars_to_delete =
       password.length() -
       PasswordGenerationAgent::kMinimumLengthForEditedPassword;
-  EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _))
+  EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword)
       .Times(testing::AtLeast(1));
   for (size_t i = 0; i < max_chars_to_delete; ++i)
     SimulateUserTypingASCIICharacter(ui::VKEY_BACK, false);
@@ -843,7 +844,7 @@
   testing::Mock::VerifyAndClearExpectations(&fake_pw_client_);
 
   // Delete one more character. The state should move to offering generation.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(testing::_));
   SimulateUserTypingASCIICharacter(ui::VKEY_BACK, true);
   fake_pw_client_.Flush();
@@ -1070,12 +1071,12 @@
     base::RunLoop().RunUntilIdle();
 
     ExpectEditingPopupOnFieldFocus(test_case.generation_element);
-    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _));
+    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword);
     SimulateUserTypingASCIICharacter('a', true);
     base::RunLoop().RunUntilIdle();
 
     ExpectGenerationElementLostFocus("username");
-    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _));
+    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword);
     SimulateUserTypingASCIICharacter('X', true);
     base::RunLoop().RunUntilIdle();
     testing::Mock::VerifyAndClearExpectations(&fake_pw_client_);
@@ -1088,7 +1089,7 @@
     SimulateUserTypingASCIICharacter(ui::VKEY_BACK, true);
     base::RunLoop().RunUntilIdle();
 
-    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword(_, _)).Times(0);
+    EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword).Times(0);
     ExpectGenerationElementLostFocus("username");
     SimulateUserTypingASCIICharacter('Y', true);
     base::RunLoop().RunUntilIdle();
@@ -1183,8 +1184,8 @@
 
   // Should not reset the password generation as this is not drived by user.
   // Some websites might clear the user data right before submission.
-  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_)).Times(0);
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_)).Times(0);
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated).Times(0);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(0);
   ExecuteJavaScriptForTests(
       "document.getElementById('first_password').value = '';");
   base::RunLoop().RunUntilIdle();
@@ -1206,7 +1207,7 @@
   // Should not reset the password generation as this is not drived by user.
   // Some websites might clear the user data right before submission.
   EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(testing::_)).Times(0);
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_)).Times(0);
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(0);
   ExecuteJavaScriptForTests(
       "document.getElementById('first_password').value = '';");
   base::RunLoop().RunUntilIdle();
@@ -1214,9 +1215,8 @@
   // Should reset the password generation now, when user focuses an empty
   // password field. Now we are sure that the form with the previously generated
   // password won't be submitted.
-  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(_)).Times(AtLeast(1));
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_))
-      .Times(AtLeast(1));
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated).Times(AtLeast(1));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(AtLeast(1));
   FocusField("first_password");
 }
 
@@ -1295,7 +1295,7 @@
   ExpectEditingPopupOnFieldFocus(kGenerationElementId);
   SimulateUserTypingASCIICharacter(ui::VKEY_END, false);
   EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated(testing::_));
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   size_t max_chars_to_delete =
       password.length() -
       PasswordGenerationAgent::kMinimumLengthForEditedPassword + 1;
@@ -1314,8 +1314,7 @@
   // Delete the rest of the characters. The field should now mask new
   // characters. Due to implementation details it's possible to get pings about
   // password generation available.
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_))
-      .Times(AnyNumber());
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(AnyNumber());
   for (size_t i = 0;
        i < PasswordGenerationAgent::kMinimumLengthForEditedPassword; ++i)
     SimulateUserTypingASCIICharacter(ui::VKEY_BACK, false);
@@ -1346,7 +1345,7 @@
   size_t max_chars_to_delete =
       password.length() -
       PasswordGenerationAgent::kMinimumLengthForEditedPassword + 1;
-  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable(_));
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable);
   for (size_t i = 0; i < max_chars_to_delete; ++i)
     SimulateUserTypingASCIICharacter(ui::VKEY_BACK, false);
   // The remaining characters no longer count as a generated password, so
@@ -1582,5 +1581,104 @@
   ExpectAutomaticGenerationAvailable(kPasswordElementId, kAvailable);
 }
 
+// Regression test for UAF in GeneratedPasswordAccepted() when JS re-enters
+// MaybeCreateCurrentGenerationItem() while it loops over
+// `current_generation_item_`'s password elements.
+class PasswordGenerationAgentReentrantUafTest
+    : public PasswordGenerationAgentTest {
+ public:
+  void RegisterMainFrameRemoteInterfaces() override {
+    // On Android Chrome (default flags) a JS .focus() during transient user
+    // activation synchronously routes through
+    // AutofillAgent::FocusedElementChanged to
+    // ShowPasswordGenerationSuggestions(). On desktop this path is disabled
+    // (focus_requires_scroll = true). This fixture flips uses_platform_autofill
+    // so that the AutofillAgent under test is created with
+    // focus_requires_scroll = false, enabling the same synchronous path.
+    blink::RendererPreferences prefs =
+        GetMainRenderFrame()->GetWebView()->GetRendererPreferences();
+    prefs.uses_platform_autofill = true;
+    GetMainRenderFrame()->GetWebView()->SetRendererPreferences(prefs);
+    PasswordGenerationAgentTest::RegisterMainFrameRemoteInterfaces();
+  }
+};
+
+// Regression test for UAF in GeneratedPasswordAccepted(). The test passes if it
+// doesn't crash.
+TEST_F(PasswordGenerationAgentReentrantUafTest,
+       GeneratedPasswordAcceptedReentrantFree) {
+  // Allow any driver/client mojo calls during the re-entrant chain; the
+  // crash happens before they're flushed but TearDown may still see them.
+  EXPECT_CALL(fake_pw_client_, AutomaticGenerationAvailable).Times(AnyNumber());
+  EXPECT_CALL(fake_pw_client_, PasswordNoLongerGenerated).Times(AnyNumber());
+  EXPECT_CALL(fake_pw_client_, PresaveGeneratedPassword).Times(AnyNumber());
+#if !BUILDFLAG(IS_ANDROID)
+  EXPECT_CALL(fake_pw_client_, GenerationElementLostFocus()).Times(AnyNumber());
+  EXPECT_CALL(fake_pw_client_, FrameWasScrolled()).Times(AnyNumber());
+  EXPECT_CALL(fake_pw_client_, ShowPasswordEditingPopup).Times(AnyNumber());
+  EXPECT_CALL(fake_pw_client_, PasswordGenerationRejectedByTyping())
+      .Times(AnyNumber());
+#endif
+
+  LoadHTMLWithUserGesture(R"(
+    <form id=f1 action=http://www.random.com/a>
+      <input type=text id=userA>
+      <input type=password id=pwA autocomplete=new-password>
+    </form>
+    <form id=f2 action=http://www.random.com/b>
+      <input type=text id=userB>
+      <input type=password id=pwB autocomplete=new-password>
+    </form>
+    <div id=txt tabindex=0>x</div>
+    <input type=button id=dummy>)");
+
+  // Mark both password fields as generation-eligible (simulates two
+  // FoundFormEligibleForGeneration messages from the browser).
+  SetFoundFormEligibleForGeneration(password_generation_,
+                                    GetMainFrame()->GetDocument(),
+                                    /*new_password_id=*/"pwA",
+                                    /*confirm_password_id=*/nullptr);
+  SetFoundFormEligibleForGeneration(password_generation_,
+                                    GetMainFrame()->GetDocument(),
+                                    /*new_password_id=*/"pwB",
+                                    /*confirm_password_id=*/nullptr);
+
+  // Click pwA: grants transient user activation and (via the
+  // FocusedElementChanged -> ShowSuggestions path) creates
+  // current_generation_item_ for pwA.
+  ASSERT_TRUE(SimulateElementClick("pwA"));
+
+  // Install an `input` handler on pwA. SetAutofillValue() inside the
+  // GeneratedPasswordAccepted() loop dispatches `input` synchronously, so
+  // this runs while the loop holds a reference into
+  // current_generation_item_->password_elements_.
+  //   1. pwA.value = ''  -> generation_element_.Value().length() == 0
+  //   2. txt.focus()     -> moves focus off pwA so step 3 is a real change
+  //   3. pwA.focus()     -> ShowPasswordGenerationSuggestions(pwA)
+  //                         -> PasswordNoLongerGenerated()
+  //                         -> password_is_generated_ = false  (disarm)
+  //   4. pwB.focus()     -> MaybeCreateCurrentGenerationItem(pwB)
+  //                         -> password_is_generated_ guard falls through
+  //                         -> current_generation_item_ reassigned
+  //                         -> old password_elements_ buffer freed
+  ExecuteJavaScriptForTests(R"(
+      var ran = false;
+      document.getElementById('pwA').addEventListener('input', function() {
+        if (ran) return; ran = true;
+        document.getElementById('pwA').value = '';
+        document.getElementById('txt').focus();
+        document.getElementById('pwA').focus();
+        document.getElementById('pwB').focus();
+      });)");
+
+  // GeneratedPasswordAccepted() contains the critical loop. In that loop,
+  // SetAutofillValue(pwA) dispatches the input event above; on return
+  // `current_generation_item->password_elements` and `password_element` have
+  // been freed.
+  password_generation_->GeneratedPasswordAccepted(u"random_password");
+  SUCCEED() << "The test doesn't crash in the previous statement. ASAN bots "
+               "would detect a UAF in the previous statement.";
+}
+
 }  // namespace
 }  // namespace autofill
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Free Write in PasswordGenerationAgent via synchronous re-entry

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 without the security team.

Overview: A potential Use-After-Free (UAF) write exists in PasswordGenerationAgent due to the unsafe use of base::AutoReset during password clearing. A synchronous JavaScript callback can force the reallocation of the tracked password item, freeing the memory pointed to by the AutoReset. Because AutoReset internally uses RAW_PTR_EXCLUSION, this bypasses MiraclePtr and results in an exploitable 1-byte write of 0x00 upon destruction.

Affected files:

  • components/autofill/content/renderer/password_generation_agent.cc
  • base/auto_reset.h

Estimated timestamp from git blame: 2022-08-01

Technical Details

A potential Use-After-Free (UAF) write vulnerability exists in the renderer process within components/autofill/content/renderer/password_generation_agent.cc.

In PasswordGenerationAgent::PasswordNoLongerGenerated, the agent clears a generated password and its related confirmation fields. To prevent false blur events during this process, it temporarily sets a boolean flag on the current_generation_item_ (which is a std::unique_ptr<GenerationItemInfo>) using base::AutoReset<bool>:

// components/autofill/content/renderer/password_generation_agent.cc
for (WebInputElement& element : current_generation_item_->password_elements_) {
  base::AutoReset<bool> auto_reset_update_confirmation_password(
      &current_generation_item_->updating_other_password_fields_, true);
  if (current_generation_item_->generation_element_ != element)
    element.SetAutofillValue(blink::WebString());
}

The vulnerability occurs because element.SetAutofillValue() modifies the DOM directly and synchronously dispatches input and change events (and a focus event if the element wasn’t focused). A malicious webpage can use a synchronous event listener on the confirmation field to execute JavaScript before the AutoReset goes out of scope.

If the attacker’s script calls showPicker() on a different password generation field, it synchronously routes to AutofillAgent::OpenTextDataListChooser -> ShowSuggestions() -> PasswordGenerationAgent::ShowPasswordGenerationSuggestions() -> MaybeCreateCurrentGenerationItem().

Inside MaybeCreateCurrentGenerationItem(), a new GenerationItemInfo struct is created via std::make_unique and assigned to current_generation_item_. This reassignment deletes the old GenerationItemInfo object, freeing its memory back to PartitionAlloc.

When execution returns to PasswordNoLongerGenerated and the loop iteration ends, the AutoReset destructor runs. It attempts to restore the original boolean value (false, or 0x00) to the address it stored. Crucially, base::AutoReset::scoped_variable_ is explicitly marked with RAW_PTR_EXCLUSION in base/auto_reset.h, meaning it is not protected by MiraclePtr (BackupRefPtr). This results in a reliable 1-byte 0x00 write at a fixed offset within a freed memory chunk.

Potential Attacker Steps

Note: These are suggested steps to trigger the vulnerability based on code analysis; our tooling agent does not yet have the ability to run code to provide a working Proof of Concept.

  1. The attacker hosts a page with two password-generation-enabled forms: Form A (fields #genA, #confA) and Form B (field #genB).
  2. The user interacts with the page, granting transient user activation (e.g., via a click).
  3. Password generation is triggered and accepted on #genA, populating current_generation_item_.
  4. The user clicks anywhere on the page, and the attacker’s mousedown listener synchronously calls genA.focus() followed by document.execCommand("delete").
  5. The delete command clears #genA, which synchronously calls PasswordNoLongerGenerated().
  6. PasswordNoLongerGenerated sets the AutoReset on #confA’s loop iteration and calls SetAutofillValue("") on #confA.
  7. SetAutofillValue dispatches an input event to #confA.
  8. The attacker’s input event listener synchronously calls genB.showPicker(). (This uses the transient user activation from the initial click).
  9. showPicker() synchronously triggers MaybeCreateCurrentGenerationItem() for #genB, replacing and freeing the original current_generation_item_.
  10. The input listener finishes, SetAutofillValue returns, and the AutoReset object goes out of scope, writing 0x00 to the freed memory chunk.

Suggested Fix

Avoid using base::AutoReset on members of objects whose lifetimes can be invalidated by synchronous DOM events or JavaScript execution.

Instead of tying the updating_other_password_fields_ flag to the GenerationItemInfo struct, move this state to the PasswordGenerationAgent class itself, or use a custom scope guard that checks if current_generation_item_ still exists (and represents the same form/element, e.g., via a unique ID or base::WeakPtr check) before attempting to revert the flag.

Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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