CVE-2026-17685
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/renderer/autofill/password_generation_agent_browsertest.cc |
modified |
Files Changed
chrome/renderer/autofill/password_generation_agent_browsertest.cc
Patch
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.
Regression Test / PoC
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
Original Bug Report
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:
- Clear the password element’s value, reducing its length below
kMinimumLengthForEditedPassword(4). - Shift focus back to the element, triggering focus/suggestion state updates. This invokes
PasswordNoLongerGenerated(), resettingcurrent_generation_item_->password_is_generated_tofalse. - Shift focus to another password generation-eligible field. This triggers
ShowPasswordGenerationSuggestions()for the new field, invokingMaybeCreateCurrentGenerationItem().
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:
- Create a page with two password fields
pwA(Form 1) andpwB(Form 2), both eligible for password generation. - Bind an
inputevent listener topwAin JavaScript. - Accept the generated password suggestion on
pwA, triggeringGeneratedPasswordAccepted(). - In the first loop iteration,
SetAutofillValue()is called onpwA. This triggers the synchronousinputevent listener. - Inside the event listener, the script sets
pwA.value = ''and focuses a helper element, then refocusespwA. - This synchronously dispatches a focus change.
AutofillAgenthandles the focus and callsShowPasswordGenerationSuggestions(pwA). - Seeing the value is empty, the agent calls
PasswordNoLongerGenerated(), settingpassword_is_generated_tofalse. - The script now shifts focus to
pwB. - This triggers
ShowPasswordGenerationSuggestions(pwB). Since the re-entrancy guard checks are bypassed,current_generation_item_is reassigned and the old allocation is freed. - 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.