Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Autofill
DescriptionUse after free in Autofill
ComponentAutofill
Bug ClassUAF
Tracker516910278
Fix commitcb6bb05a7a0e (chromium/src) +354/-154
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

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]

Potential Use-After-Free in PasswordGenerationAgent::GeneratedPasswordAccepted

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: A potential Use-After-Free (UAF) vulnerability exists in PasswordGenerationAgent due to synchronous JavaScript execution during iteration over a heap-allocated vector of elements. By triggering a re-entrant focus/text change during password generation, the underlying GenerationItemInfo structure can be destroyed and freed. Resuming the iteration or accessing the elements afterwards results in a use-after-free scenario.

Affected files:

  • components/autofill/content/renderer/password_generation_agent.cc

Estimated timestamp from git blame: 2018-11-06

Root Cause Analysis

In components/autofill/content/renderer/password_generation_agent.cc, the method PasswordGenerationAgent::GeneratedPasswordAccepted performs an iteration over current_generation_item_->password_elements_ (which is a heap-allocated std::vector<blink::WebInputElement> owned by current_generation_item_):

  for (blink::WebInputElement& password_element :
       current_generation_item_->password_elements_) {
    ScopedUpdatingOtherPasswordFields auto_reset_update_confirmation_password(
        this);
    password_element.SetAutofillValue(blink::WebString::FromUtf16(password));
    // ...

When password_element.SetAutofillValue is called, it can dispatch synchronous events (e.g., keydown/keyup and subsequent DOM events) into the document, allowing untrusted attacker-controlled JavaScript to execute synchronously mid-iteration.

During this synchronous execution window, the attacker can:

  1. Clear the password element’s value, reducing its length below kMinimumLengthForEditedPassword (4).
  2. Shift focus back to the element, triggering focus/suggestion state updates. This invokes PasswordNoLongerGenerated(), resetting current_generation_item_->password_is_generated_ to false.
  3. Shift focus to another password generation-eligible field. This triggers ShowPasswordGenerationSuggestions() for the new field, invoking MaybeCreateCurrentGenerationItem().

Since current_generation_item_->password_is_generated_ is now false, the re-entrancy guard in MaybeCreateCurrentGenerationItem() is bypassed:

  if (current_generation_item_ &&
      (current_generation_item_->generation_element_ == generation_element ||
       current_generation_item_->password_is_generated_))
    return;

This causes the agent to reassign the current_generation_item_ unique pointer:

  current_generation_item_ = std::make_unique<GenerationItemInfo>(...);

Reassigning the unique pointer immediately destroys the old GenerationItemInfo, deallocating the underlying std::vector<blink::WebInputElement>. When control returns from JavaScript to the C++ loop in GeneratedPasswordAccepted, the range-for loop’s internal iterators/references point to deallocated memory, resulting in a Use-After-Free.

A secondary, identical instance also potentially occurs in CopyElementValueToOtherInputElements() via text-change mirroring, where elements is passed as a reference to current_generation_item_->password_elements_.


Potential Trigger Steps

Note: Since our testing environment is purely static and we cannot run code, the following sequence represents a potential proof-of-concept flow:

  1. Create a page with two password fields pwA (Form 1) and pwB (Form 2), both eligible for password generation.
  2. Bind an input event listener to pwA in JavaScript.
  3. Accept the generated password suggestion on pwA, triggering GeneratedPasswordAccepted().
  4. In the first loop iteration, SetAutofillValue() is called on pwA. This triggers the synchronous input event listener.
  5. Inside the event listener, the script sets pwA.value = '' and focuses a helper element, then refocuses pwA.
  6. This synchronously dispatches a focus change. AutofillAgent handles the focus and calls ShowPasswordGenerationSuggestions(pwA).
  7. Seeing the value is empty, the agent calls PasswordNoLongerGenerated(), setting password_is_generated_ to false.
  8. The script now shifts focus to pwB.
  9. This triggers ShowPasswordGenerationSuggestions(pwB). Since the re-entrancy guard checks are bypassed, current_generation_item_ is reassigned and the old allocation is freed.
  10. The JavaScript execution completes, and the native stack resumes iteration over the now-deallocated password_elements_ vector.

MiraclePtr / Mitigation Discussion

The dangling iterators and elements are stored within a standard std::vector heap allocation on PartitionAlloc. Because the Use-After-Free occurs on the container’s backing buffer itself (and raw iterators used in the range-for loop expansion), this is not mitigated by MiraclePtr.


Suggested Fix

The safest and most robust fix is to use a base::WeakPtr to track the lifetime of GenerationItemInfo and return early if the item is destroyed or replaced during synchronous event dispatch.

In components/autofill/content/renderer/password_generation_agent.cc:

void PasswordGenerationAgent::GeneratedPasswordAccepted(
    const std::u16string& password) {
  if (!current_generation_item_) {
    return;
  }
  // ...
  base::WeakPtr<GenerationItemInfo> generation_item =
      current_generation_item_->weak_ptr_factory_.GetWeakPtr();

  for (size_t i = 0; i < generation_item->password_elements_.size(); ++i) {
    ScopedUpdatingOtherPasswordFields auto_reset_update_confirmation_password(
        this);
    
    // We copy the element to a local variable to be safe
    blink::WebInputElement password_element = generation_item->password_elements_[i];
    password_element.SetAutofillValue(blink::WebString::FromUtf16(password));

    // Check if the generation item was replaced or destroyed during synchronous JS
    if (!generation_item) {
      return;
    }
    
    if (!render_frame()) {
      return;
    }
    if (password_element.Value().IsEmpty()) {
      return;
    }
    password_agent_->TrackAutofilledElement(password_element);
  }
  // ...

This pattern should also be applied to other loops in password_generation_agent.cc that invoke SetAutofillValue or dispatch synchronous events, including CopyElementValueToOtherInputElements and the subsequent UpdateStateForTextChange loop.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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.

View on issue tracker