Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Variations
DescriptionUse after free in Variations
ComponentVariations
Sub-componentVariations
Bug ClassUAF
Tracker511820971
Fix commit24d13009071b (chromium/src) +104/-15
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
components/variations/service/variations_service.cc
modified
TestVariationsServiceObserver
components/variations/service/variations_service_unittest.cc
modified
TEST_F
components/variations/service/variations_service_unittest.cc
modified

Files Changed

  • components/variations/service/variations_service.cc
  • components/variations/service/variations_service_unittest.cc
  • components/variations/service/variations_service.h
From 24d13009071b12ce38f3b9b5a4a82cc1812d56d1 Mon Sep 17 00:00:00 2001
From: Łukasz Burchard <lburchard@google.com>
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 <asvitkine@chromium.org>
Commit-Queue: Łukasz Burchard <lburchard@google.com>
Reviewed-by: Andrzej Fiedukowicz <afie@google.com>
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>(
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by vm...@google.com

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.cc
  • components/variations/variations_seed_store.cc
  • components/variations/service/safe_seed_manager.cc
  • components/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:

  1. Seed Staleness Bypass: RecordSuccessfulFetchSeedNotModified calls RecordLastFetchTime(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 for HasSeedExpiredSinceTimeHelper to expire it, allowing an attacker to pin the client to an old, potentially vulnerable configuration.
  2. Safe-Mode Suppression: It also calls safe_seed_manager_.RecordSuccessfulFetch(...), which resets kVariationsCrashStreak and kVariationsFailedToFetchSeedStreak to 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.
  3. Date Manipulation: Finally, the attacker’s Date header is passed to UpdateSeedDateAndLogDayChange, storing it as the reference_date in local state. While VariationsSeedStore::GetTimeForStudyDateChecks prevents dates older than the binary’s build time, it does not reject future dates. In components/variations/study_filtering.cc, a far-future reference_date will cause CheckStudyEndDate to prematurely deactivate any study with an end date, and CheckStudyStartDate to prematurely activate any study with a future start date.

Potential Attack Steps

  1. A network attacker (e.g., a rogue Wi-Fi point) monitors for HTTPS traffic to clientservices.googleapis.com.
  2. The attacker forces a TCP reset on the TLS handshake, causing Chrome’s SimpleURLLoader to report a network failure.
  3. Chrome executes MaybeRetryOverHTTP() and issues a plain HTTP GET request.
  4. The attacker intercepts this HTTP request and responds with an HTTP/1.1 304 Not Modified header containing an arbitrary Date value (e.g., Date: Wed, 21 Oct 2030 07:28:00 GMT).
  5. 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.

View on issue tracker