CVE-2026-113205
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/variations/service/variations_service.cc |
modified | |
TestVariationsServiceObservercomponents/variations/service/variations_service_unittest.cc |
modified | |
TEST_Fcomponents/variations/service/variations_service_unittest.cc |
modified |
Files Changed
components/variations/service/variations_service.cccomponents/variations/service/variations_service_unittest.cccomponents/variations/service/variations_service.h
Patch
From 24d13009071b12ce38f3b9b5a4a82cc1812d56d1 Mon Sep 17 00:00:00 2001 From: Łukasz Burchard <[email protected]> Date: Wed, 16 Sep 2026 03:47:12 -0700 Subject: [PATCH] [variations] Do not trust unauthenticated Date header over HTTP retry When an initial variations seed fetch over HTTPS fails, Chrome falls back to an HTTP retry. HTTP responses lack transport security, so the response headers cannot be authenticated and must not be trusted. Previously, a 304 Not Modified response received over the HTTP retry called RecordSuccessfulFetchSeedNotModified(), which updated the stored kVariationsSeedDate using the unauthenticated Date header. An attacker could exploit this by forcing HTTP fallback and spoofing a 304 response with an arbitrary future Date header, manipulating date-gated field trials or bypassing study expiry checks. Similarly, on an HTTP 200 response, the unauthenticated Date header was passed to StoreSeed(). This change resolves the vulnerability: 1. In OnSimpleLoaderComplete(), sets response_date to std::nullopt when last_request_was_http_retry_ is true, ignoring unauthenticated Date headers. 2. For HTTP 304 responses: - Records the last fetch time using local base::Time::Now() and notifies SafeSeedManager to reset failure streaks, preventing unnecessary retries and false safe mode triggers. - Updates the stored seed date in RecordSuccessfulFetchSeedNotModified() only when response_date has a value (received over HTTPS). Skipping this on insecure 304 responses prevents date spoofing and avoids spurious day changes in Variations.SeedDateChange. 3. For HTTP 200 responses: - Passes response_date.value_or(base::Time()) to StoreSeed(), passing a null base::Time() when fetched over an HTTP retry. 4. For clients that only succeed over HTTP retry, kVariationsSeedDate remains un-updated. This causes VariationsSeedStore::GetTimeForStudyDateChecks() to fall back to base::GetBuildTime(), which is safe against remote manipulation. TAG=agy CONV=9f31a747-adcd-4fb7-9cd8-7957f219668b Bug: 511820971 Change-Id: I7694358d46c688660059b6d8c66243ecb1a6c80f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7994437 Reviewed-by: Alexei Svitkine <[email protected]> Commit-Queue: Łukasz Burchard <[email protected]> Reviewed-by: Andrzej Fiedukowicz <[email protected]> Cr-Commit-Position: refs/heads/main@{#1698815} --- diff --git a/components/variations/service/variations_service.cc b/components/variations/service/variations_service.cc index 192aa8f3..b2c3d46 100644 --- a/components/variations/service/variations_service.cc +++ b/components/variations/service/variations_service.cc @@ -1144,10 +1144,12 @@ DCHECK(headers); DCHECK(response_body); - std::optional<base::Time> response_date = headers->GetDateValue(); + // Ignore the Date header value from HTTP as it cannot be trusted. + const std::optional<base::Time> response_date = + last_request_was_http_retry_ ? std::nullopt : headers->GetDateValue(); // If the seed was fetched securely, opportunistically update the network time // tracker with the headers time. - if (response_date && !last_request_was_http_retry_) { + if (response_date) { DCHECK(!response_date->is_null()); const base::TimeDelta latency = now - last_request_started_time_; @@ -1158,7 +1160,7 @@ if (response_code == net::HTTP_NOT_MODIFIED) { // TODO(crbug.com/420652919): Reject responses without a date. - RecordSuccessfulFetchSeedNotModified(response_date.value_or(base::Time())); + RecordSuccessfulFetchSeedNotModified(response_date); return; } @@ -1526,18 +1528,19 @@ } void VariationsService::RecordSuccessfulFetchSeedNotModified( - base::Time response_date) { + std::optional<base::Time> response_date) { // Update the client-side fetch time to the current time. field_trial_creator_.seed_store()->RecordLastFetchTime(base::Time::Now()); safe_seed_manager_.RecordSuccessfulFetch(field_trial_creator_.seed_store()); - // Update the seed date value in local state (used for expiry check on - // next start up), since 304 is a successful response. Note that the - // serial number included in the request is always that of the latest - // seed, even when running in safe mode, so it's appropriate to always - // modify the latest seed's date. - field_trial_creator_.seed_store()->UpdateSeedDateAndLogDayChange( - response_date); + // Only treat a 304 as confirmation that the stored seed is current when it + // was received over HTTPS. Callers set `response_date` to std::nullopt for + // insecure HTTP retries because unauthenticated Date headers cannot be + // trusted. + if (response_date) { + field_trial_creator_.seed_store()->UpdateSeedDateAndLogDayChange( + response_date.value()); + } } VariationsSeedStore* VariationsService::GetSeedStoreForTesting() { diff --git a/components/variations/service/variations_service.h b/components/variations/service/variations_service.h index 042c7b9..80aa691 100644 --- a/components/variations/service/variations_service.h +++ b/components/variations/service/variations_service.h @@ -378,10 +378,11 @@ // streaks. void RecordSuccessfulFetchNewSeed(); - // Like VariationsService::RecordSuccessfulFetchNewSeed(), but intended to be - // called for 304 responses from the variations server. Also, updates the seed - // date and client fetch time. - void RecordSuccessfulFetchSeedNotModified(base::Time response_date); + // Records a 304 response from the variations server. Updates the client fetch + // time and clears failure streaks. If `response_date` has a value (present on + // secure HTTPS responses), updates the stored seed date. + void RecordSuccessfulFetchSeedNotModified( + std::optional<base::Time> response_date); // Performs a simulation of the given `seed` to find any runtime mutable // changes that need to be applied to the current session, and apply them. diff --git a/components/variations/service/variations_service_unittest.cc b/components/variations/service/variations_service_unittest.cc index c6dccde..799909cc 100644 --- a/components/variations/service/variations_service_unittest.cc +++ b/components/variations/service/variations_service_unittest.cc @@ -192,6 +192,7 @@ bool delta_compressed_seed() const { return delta_compressed_seed_; } bool gzip_compressed_seed() const { return gzip_compressed_seed_; } bool runtime_simulation_called() const { return runtime_simulation_called_; } + base::Time stored_date() const { return stored_date_; } bool CallMaybeRetryOverHTTP() { return CallMaybeRetryOverHTTPForTesting(); } void SimulateAndApplyRuntimeMutableChanges( @@ -247,6 +248,7 @@ stored_seed_data_ = seed_data; stored_country_ = country_code; stored_geo_level_ = geo_level1; + stored_date_ = date_fetched; delta_compressed_seed_ = is_delta_compressed; gzip_compressed_seed_ = is_gzip_compressed; OnSeedStoreResult(is_delta_compressed, seed_stores_succeed_, @@ -277,6 +279,7 @@ bool runtime_simulation_called_ = false; base::OnceClosure fetch_intercepted_callback_; std::string last_header_serial_number_; + base::Time stored_date_; }; class TestVariationsServiceObserver : public VariationsService::Observer { @@ -1095,6 +1098,88 @@ EXPECT_EQ(0, prefs_.GetInteger(prefs::kVariationsFailedToFetchSeedStreak)); } +TEST_F(VariationsServiceTest, NotModifiedOverHttpRetryDoesNotUpdateState) { + // Set a fixed past reference date (2024-01-01 00:00:00 UTC). + base::Time initial_time; + ASSERT_TRUE( + base::Time::FromUTCString("2024-01-01 00:00:00 UTC", &initial_time)); + prefs_.SetInteger(prefs::kVariationsCrashStreak, 2); + prefs_.SetInteger(prefs::kVariationsFailedToFetchSeedStreak, 1); + prefs_.SetTime(prefs::kVariationsSeedDate, initial_time); + prefs_.SetTime(prefs::kVariationsLastFetchTime, initial_time); + VariationsService::EnableFetchForTesting(); + + TestVariationsService service( + std::make_unique<web_resource::TestRequestAllowedNotifier>( + &prefs_, network_tracker_), + &prefs_, GetMetricsStateManager(), /*use_secure_url=*/false); + service.set_intercepts_fetch(false); + + std::string headers("HTTP/1.1 304 Not Modified\n\n"); + auto head = network::mojom::URLResponseHead::New(); + head->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + net::HttpUtil::AssembleRawHeaders(headers)); + // Set a spoofed Date header in the future relative to initial_time. + head->headers->SetHeader( + "Date", net::HttpUtil::TimeFormatHTTP(initial_time + base::Days(30))); + network::URLLoaderCompletionStatus status; + service.test_url_loader_factory()->AddResponse(service.interception_url(), + std::move(head), "", status); + + service.set_last_request_was_retry(false); + service.set_insecure_url(service.interception_url()); + EXPECT_TRUE(service.CallMaybeRetryOverHTTP()); + base::RunLoop().RunUntilIdle(); + + // A 304 received over the HTTP retry should not be treated as confirmation + // that the stored seed is current, so the seed date should not be updated. + // However, since we successfully reached the server, the last fetch time + // and failure streaks should be updated (using the local time, which cannot + // be spoofed). + EXPECT_EQ(0, prefs_.GetInteger(prefs::kVariationsCrashStreak)); + EXPECT_EQ(0, prefs_.GetInteger(prefs::kVariationsFailedToFetchSeedStreak)); + EXPECT_EQ(initial_time, prefs_.GetTime(prefs::kVariationsSeedDate)); + EXPECT_NE(initial_time, prefs_.GetTime(prefs::kVariationsLastFetchTime)); +} + +TEST_F(VariationsServiceTest, SeedDateIgnoredOverHttpRetry) { + // Set a fixed past reference date (2024-01-01 00:00:00 UTC). + base::Time initial_time; + ASSERT_TRUE( + base::Time::FromUTCString("2024-01-01 00:00:00 UTC", &initial_time)); + prefs_.SetTime(prefs::kVariationsSeedDate, initial_time); + prefs_.SetTime(prefs::kVariationsLastFetchTime, initial_time); + VariationsService::EnableFetchForTesting(); + + TestVariationsService service( + std::make_unique<web_resource::TestRequestAllowedNotifier>( + &prefs_, network_tracker_), + &prefs_, GetMetricsStateManager(), /*use_secure_url=*/false); + service.set_intercepts_fetch(false); + + // Return 200 OK with a spoofed Date header in the future. + std::string headers("HTTP/1.1 200 OK\n\n"); + auto head = network::mojom::URLResponseHead::New(); + head->headers = base::MakeRefCounted<net::HttpResponseHeaders>( + net::HttpUtil::AssembleRawHeaders(headers)); + // Set a spoofed Date header in the future relative to initial_time. + head->headers->SetHeader( + "Date", net::HttpUtil::TimeFormatHTTP(initial_time + base::Days(30))); + network::URLLoaderCompletionStatus status; + std::string seed_body = SerializeSeed(CreateTestSeed()); + service.test_url_loader_factory()->AddResponse( + service.interception_url(), std::move(head), seed_body, status); + + service.set_last_request_was_retry(false); + service.set_insecure_url(service.interception_url()); + EXPECT_TRUE(service.CallMaybeRetryOverHTTP()); + base::RunLoop().RunUntilIdle(); + + // The seed date should NOT be set to the future date. It should be + // base::Time() because the fetch was insecure. + EXPECT_EQ(base::Time(), service.stored_date()); +} + TEST_F(VariationsServiceTest, FieldTrialCreatorInitializedCorrectly) { TestVariationsService service( std::make_unique<web_resource::TestRequestAllowedNotifier>(
Regression Test / PoC
diff --git a/components/variations/service/variations_service_unittest.cc b/components/variations/service/variations_service_unittest.cc
index c6dccde..799909cc 100644
--- a/components/variations/service/variations_service_unittest.cc
+++ b/components/variations/service/variations_service_unittest.cc
@@ -192,6 +192,7 @@
bool delta_compressed_seed() const { return delta_compressed_seed_; }
bool gzip_compressed_seed() const { return gzip_compressed_seed_; }
bool runtime_simulation_called() const { return runtime_simulation_called_; }
+ base::Time stored_date() const { return stored_date_; }
bool CallMaybeRetryOverHTTP() { return CallMaybeRetryOverHTTPForTesting(); }
void SimulateAndApplyRuntimeMutableChanges(
@@ -247,6 +248,7 @@
stored_seed_data_ = seed_data;
stored_country_ = country_code;
stored_geo_level_ = geo_level1;
+ stored_date_ = date_fetched;
delta_compressed_seed_ = is_delta_compressed;
gzip_compressed_seed_ = is_gzip_compressed;
OnSeedStoreResult(is_delta_compressed, seed_stores_succeed_,
@@ -277,6 +279,7 @@
bool runtime_simulation_called_ = false;
base::OnceClosure fetch_intercepted_callback_;
std::string last_header_serial_number_;
+ base::Time stored_date_;
};
class TestVariationsServiceObserver : public VariationsService::Observer {
@@ -1095,6 +1098,88 @@
EXPECT_EQ(0, prefs_.GetInteger(prefs::kVariationsFailedToFetchSeedStreak));
}
+TEST_F(VariationsServiceTest, NotModifiedOverHttpRetryDoesNotUpdateState) {
+ // Set a fixed past reference date (2024-01-01 00:00:00 UTC).
+ base::Time initial_time;
+ ASSERT_TRUE(
+ base::Time::FromUTCString("2024-01-01 00:00:00 UTC", &initial_time));
+ prefs_.SetInteger(prefs::kVariationsCrashStreak, 2);
+ prefs_.SetInteger(prefs::kVariationsFailedToFetchSeedStreak, 1);
+ prefs_.SetTime(prefs::kVariationsSeedDate, initial_time);
+ prefs_.SetTime(prefs::kVariationsLastFetchTime, initial_time);
+ VariationsService::EnableFetchForTesting();
+
+ TestVariationsService service(
+ std::make_unique<web_resource::TestRequestAllowedNotifier>(
+ &prefs_, network_tracker_),
+ &prefs_, GetMetricsStateManager(), /*use_secure_url=*/false);
+ service.set_intercepts_fetch(false);
+
+ std::string headers("HTTP/1.1 304 Not Modified\n\n");
+ auto head = network::mojom::URLResponseHead::New();
+ head->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ net::HttpUtil::AssembleRawHeaders(headers));
+ // Set a spoofed Date header in the future relative to initial_time.
+ head->headers->SetHeader(
+ "Date", net::HttpUtil::TimeFormatHTTP(initial_time + base::Days(30)));
+ network::URLLoaderCompletionStatus status;
+ service.test_url_loader_factory()->AddResponse(service.interception_url(),
+ std::move(head), "", status);
+
+ service.set_last_request_was_retry(false);
+ service.set_insecure_url(service.interception_url());
+ EXPECT_TRUE(service.CallMaybeRetryOverHTTP());
+ base::RunLoop().RunUntilIdle();
+
+ // A 304 received over the HTTP retry should not be treated as confirmation
+ // that the stored seed is current, so the seed date should not be updated.
+ // However, since we successfully reached the server, the last fetch time
+ // and failure streaks should be updated (using the local time, which cannot
+ // be spoofed).
+ EXPECT_EQ(0, prefs_.GetInteger(prefs::kVariationsCrashStreak));
+ EXPECT_EQ(0, prefs_.GetInteger(prefs::kVariationsFailedToFetchSeedStreak));
+ EXPECT_EQ(initial_time, prefs_.GetTime(prefs::kVariationsSeedDate));
+ EXPECT_NE(initial_time, prefs_.GetTime(prefs::kVariationsLastFetchTime));
+}
+
+TEST_F(VariationsServiceTest, SeedDateIgnoredOverHttpRetry) {
+ // Set a fixed past reference date (2024-01-01 00:00:00 UTC).
+ base::Time initial_time;
+ ASSERT_TRUE(
+ base::Time::FromUTCString("2024-01-01 00:00:00 UTC", &initial_time));
+ prefs_.SetTime(prefs::kVariationsSeedDate, initial_time);
+ prefs_.SetTime(prefs::kVariationsLastFetchTime, initial_time);
+ VariationsService::EnableFetchForTesting();
+
+ TestVariationsService service(
+ std::make_unique<web_resource::TestRequestAllowedNotifier>(
+ &prefs_, network_tracker_),
+ &prefs_, GetMetricsStateManager(), /*use_secure_url=*/false);
+ service.set_intercepts_fetch(false);
+
+ // Return 200 OK with a spoofed Date header in the future.
+ std::string headers("HTTP/1.1 200 OK\n\n");
+ auto head = network::mojom::URLResponseHead::New();
+ head->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
+ net::HttpUtil::AssembleRawHeaders(headers));
+ // Set a spoofed Date header in the future relative to initial_time.
+ head->headers->SetHeader(
+ "Date", net::HttpUtil::TimeFormatHTTP(initial_time + base::Days(30)));
+ network::URLLoaderCompletionStatus status;
+ std::string seed_body = SerializeSeed(CreateTestSeed());
+ service.test_url_loader_factory()->AddResponse(
+ service.interception_url(), std::move(head), seed_body, status);
+
+ service.set_last_request_was_retry(false);
+ service.set_insecure_url(service.interception_url());
+ EXPECT_TRUE(service.CallMaybeRetryOverHTTP());
+ base::RunLoop().RunUntilIdle();
+
+ // The seed date should NOT be set to the future date. It should be
+ // base::Time() because the fetch was insecure.
+ EXPECT_EQ(base::Time(), service.stored_date());
+}
+
TEST_F(VariationsServiceTest, FieldTrialCreatorInitializedCorrectly) {
TestVariationsService service(
std::make_unique<web_resource::TestRequestAllowedNotifier>(
Original Bug Report
Manipulation of Finch variations via unauthenticated 304 HTTP fallback
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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A network attacker can force Chrome to fallback to an insecure HTTP fetch for Variations seeds and respond with a spoofed 304 Not Modified containing an arbitrary Date header. Chrome processes this without cryptographic validation, allowing an attacker to permanently pin the client to a specific seed, bypass safe-mode crash protections, and manipulate date-gated field trials.
Affected files:
components/variations/service/variations_service.cccomponents/variations/variations_seed_store.cccomponents/variations/service/safe_seed_manager.cccomponents/variations/study_filtering.cc
Estimated timestamp from git blame: 2025-06-10
Summary
A logic flaw exists in how the VariationsService handles fetch retries and 304 Not Modified responses. If an attacker intercepts the initial HTTPS fetch for a Finch seed and forces a network failure, Chrome falls back to an unauthenticated HTTP GET request. The attacker can then respond with a spoofed 304 Not Modified containing a malicious Date header. Because 304 responses lack a body and are not cryptographically signed, Chrome accepts this response entirely on trust. This allows an attacker to reset staleness checks, suppress safe-mode fallbacks, and inject a far-future reference date to manipulate date-gated field trials within the currently active, signed seed.
Technical Details
When VariationsService attempts to fetch a seed, it normally does so over HTTPS. If this request fails at the network layer, VariationsService::OnSimpleLoaderComplete will trigger a fallback by calling MaybeRetryOverHTTP().
// components/variations/service/variations_service.cc
if (MaybeRetryOverHTTP()) {
// ...
return;
}
By default, MaybeRetryOverHTTP() issues a plain HTTP request to http://clientservices.googleapis.com/chrome-variations/seed. An attacker on the local network can intercept this request and return a spoofed HTTP/1.1 304 Not Modified response with a malicious Date header (e.g., in the far future).
When VariationsService::OnSimpleLoaderComplete processes this 304 response, it does not perform any cryptographic verification (as there is no body to verify). It accepts the response and extracts the Date header, passing it to RecordSuccessfulFetchSeedNotModified.
This leads to three specific impacts:
- Seed Staleness Bypass:
RecordSuccessfulFetchSeedNotModifiedcallsRecordLastFetchTime(base::Time::Now()). This sets the fetch time to the current local system time, causing the seed to appear completely fresh. This prevents the seed from ever reaching the 30-day age limit required forHasSeedExpiredSinceTimeHelperto expire it, allowing an attacker to pin the client to an old, potentially vulnerable configuration. - Safe-Mode Suppression: It also calls
safe_seed_manager_.RecordSuccessfulFetch(...), which resetskVariationsCrashStreakandkVariationsFailedToFetchSeedStreakto 0. If the currently pinned seed is causing browser crashes, the attacker can continuously suppress the crash streak, preventing Chrome from engaging its safe-mode fallback mechanisms. - Date Manipulation: Finally, the attacker’s
Dateheader is passed toUpdateSeedDateAndLogDayChange, storing it as thereference_datein local state. WhileVariationsSeedStore::GetTimeForStudyDateChecksprevents dates older than the binary’s build time, it does not reject future dates. Incomponents/variations/study_filtering.cc, a far-futurereference_datewill causeCheckStudyEndDateto prematurely deactivate any study with an end date, andCheckStudyStartDateto prematurely activate any study with a future start date.
Potential Attack Steps
- A network attacker (e.g., a rogue Wi-Fi point) monitors for HTTPS traffic to
clientservices.googleapis.com. - The attacker forces a TCP reset on the TLS handshake, causing Chrome’s
SimpleURLLoaderto report a network failure. - Chrome executes
MaybeRetryOverHTTP()and issues a plain HTTP GET request. - The attacker intercepts this HTTP request and responds with an
HTTP/1.1 304 Not Modifiedheader containing an arbitraryDatevalue (e.g.,Date: Wed, 21 Oct 2030 07:28:00 GMT). - Chrome processes the response, updating its local state and resetting security-critical counters and timers based on the unauthenticated input. (Note: These are potential steps based on code analysis; we have not verified them with a working exploit.)
Suggested Fix
The most robust fix is to remove the unauthenticated HTTP fallback entirely. If that is not feasible for compatibility reasons, 304 responses received over HTTP fallback MUST NOT be used to update security-critical state (fetch time, crash streaks, or seed date) unless the response can somehow be cryptographically tied to the Variations server. Additionally, VariationsSeedStore::GetTimeForStudyDateChecks should sanitize dates to ensure they are not unreasonably far in the future compared to the local system clock.
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
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.