Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in CSS
DescriptionUse after free in CSS
ComponentCSS
Sub-componentBlink>CSS
Bug ClassUAF
Tracker562230438
Fix commitf6c1c1d9bad1 (chromium/src) +66/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Files Changed

  • third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
  • third_party/blink/web_tests/animations/transition-visited-inherited.html
From f6c1c1d9bad10d0533203f676213864d8662a0df Mon Sep 17 00:00:00 2001
From: Kevin Babbitt <[email protected]>
Date: Thu, 17 Sep 2026 09:24:56 -0700
Subject: [PATCH] Fix leaking of visited links via inherit + CSS Transitions

Bug: 562230438
Change-Id: I2100cfcd9090749e2bbe13ce79568c90c8180790
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8421139
Commit-Queue: Kevin Babbitt <[email protected]>
Reviewed-by: Rune Lillesveen <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1699883}
---
diff --git a/third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc b/third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
index 63b867d..862a937 100644
--- a/third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
+++ b/third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc
@@ -4874,9 +4874,9 @@
   } else {
     // In principle, we should always inherit the InternalVisitedColor()
     // of the parent, but (as an optimization) we don't store anything in
-    // that field for elements outside of visited links.
+    // that field for elements outside of links.
     const StyleColor& inherited_color =
-        (state.ParentStyle()->InsideLink() == EInsideLink::kInsideVisitedLink)
+        (state.ParentStyle()->InsideLink() != EInsideLink::kNotInsideLink)
             ? state.ParentStyle()->InternalVisitedColor()
             : state.ParentStyle()->Color();
     builder.SetInternalVisitedColor(inherited_color);
diff --git a/third_party/blink/web_tests/animations/transition-visited-inherited.html b/third_party/blink/web_tests/animations/transition-visited-inherited.html
new file mode 100644
index 0000000..f4fb4bfb
--- /dev/null
+++ b/third_party/blink/web_tests/animations/transition-visited-inherited.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<script src="../resources/testharness.js"></script>
+<script src="../resources/testharnessreport.js"></script>
+<style id="visited-style">
+a:visited {
+  color: orange;
+}
+</style>
+<style>
+a {
+  color: red;
+}
+a span {
+  color: inherit;
+  transition: color 1s ease;
+}
+</style>
+<a href=""><span>visited link</span></a>
+<a href="http://madeup.site.com"><span>unvisited link</span></a>
+<script>
+promise_test(() => {
+  const transitionrunPromises = [];
+  document.querySelectorAll('span').forEach(element => {
+    const promise = new Promise((resolve, reject) => {
+      let resolved = false;
+      let timeoutCountdownInFrames = 2;
+      const tick = () => {
+        requestAnimationFrame(() => {
+          if (resolved)
+            return;
+          if (--timeoutCountdownInFrames == 0)
+            reject('Timeout waiting for transition run');
+          tick();
+        });
+      }
+      element.addEventListener('transitionrun', () => {
+        resolved = true;
+        resolve();
+      });
+      tick();
+    });
+    transitionrunPromises.push(promise);
+  });
+
+  assert_equals(document.getAnimations().length, 0);
+  // The getAnimations call forced a style sync, thus changing the style now
+  // forces transitions.
+  document.getElementById('visited-style').textContent =
+      'a:visited { color: blue; }';
+
+  const animations = document.getAnimations();
+  assert_equals(
+      animations.length, 2,
+      'inherited transition should occur for visited and unvisited links');
+  animations.forEach(animation => {
+    const keyframes = animation.effect.getKeyframes();
+    assert_equals(keyframes[0].color, 'rgb(255, 0, 0)');
+    assert_equals(keyframes[1].color, 'rgb(255, 0, 0)');
+  });
+  // Expect two transitionrun events even though one does not have a visible
+  // effect.
+  return Promise.all(transitionrunPromises);
+}, 'Do not leak inherited link visited state via web animations API');
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/animations/transition-visited-inherited.html b/third_party/blink/web_tests/animations/transition-visited-inherited.html
new file mode 100644
index 0000000..f4fb4bfb
--- /dev/null
+++ b/third_party/blink/web_tests/animations/transition-visited-inherited.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<script src="../resources/testharness.js"></script>
+<script src="../resources/testharnessreport.js"></script>
+<style id="visited-style">
+a:visited {
+  color: orange;
+}
+</style>
+<style>
+a {
+  color: red;
+}
+a span {
+  color: inherit;
+  transition: color 1s ease;
+}
+</style>
+<a href=""><span>visited link</span></a>
+<a href="http://madeup.site.com"><span>unvisited link</span></a>
+<script>
+promise_test(() => {
+  const transitionrunPromises = [];
+  document.querySelectorAll('span').forEach(element => {
+    const promise = new Promise((resolve, reject) => {
+      let resolved = false;
+      let timeoutCountdownInFrames = 2;
+      const tick = () => {
+        requestAnimationFrame(() => {
+          if (resolved)
+            return;
+          if (--timeoutCountdownInFrames == 0)
+            reject('Timeout waiting for transition run');
+          tick();
+        });
+      }
+      element.addEventListener('transitionrun', () => {
+        resolved = true;
+        resolve();
+      });
+      tick();
+    });
+    transitionrunPromises.push(promise);
+  });
+
+  assert_equals(document.getAnimations().length, 0);
+  // The getAnimations call forced a style sync, thus changing the style now
+  // forces transitions.
+  document.getElementById('visited-style').textContent =
+      'a:visited { color: blue; }';
+
+  const animations = document.getAnimations();
+  assert_equals(
+      animations.length, 2,
+      'inherited transition should occur for visited and unvisited links');
+  animations.forEach(animation => {
+    const keyframes = animation.effect.getKeyframes();
+    assert_equals(keyframes[0].color, 'rgb(255, 0, 0)');
+    assert_equals(keyframes[1].color, 'rgb(255, 0, 0)');
+  });
+  // Expect two transitionrun events even though one does not have a visible
+  // effect.
+  return Promise.all(transitionrunPromises);
+}, 'Do not leak inherited link visited state via web animations API');
+</script>
Loading diff…

Original Bug Report

reported by [email protected]

:visited history leak via CSS transition initiation check on InternalVisitedColor

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: Blink stores a separate internal visited color channel (InternalVisitedColor()) on each ComputedStyle so a link’s actual visited state can influence paint only, never anything web-observable. The CSS transition-start check breaks that invariant: the equality gate for transition: color also compares the visited channel, so a style change that alters only the visited slot starts a real CSSTransition. Combined with InternalVisitedColor::ApplyInherit, which consults the parent link’s actual visited state, web content gets a deterministic JS oracle (transitionrun / element.getAnimations()) for whether a URL is in the user’s :visited set. Verified end-to-end on 155.0.8055.0 (release and ASan builds) with default visited-link partitioning enabled.

Affected files:

Estimated timestamp from git blame: 2019-06-12 (visited-slot comparison under its current name; the same pattern predates this under the older VisitedLinkColor naming, file dates to 2014)

Summary

An attacker page places a probe element with color: inherit; transition: color 0.1s inside an &lt;a href="TARGET"&gt;, then swaps the page’s own a:visited { color: ... } rule to a fresh color. That swap changes the link’s visited-channel color in both style worlds (visited rules are matched structurally by LinkMatchType, not by actual state), and the probe inherits the change into its own visited slot only if the link is actually visited, per the InsideLink() branch in ApplyInherit.

The probe’s web-observable unvisited color never changes. But CSSPropertyEquality::PropertiesEqual(kColor) compares the visited slot too, so the before/after styles compare unequal exactly in the visited world, and Blink starts a CSSTransition on color. The transition’s existence is directly observable:

  • transitionrun fires on the probe (gated only on listener presence);
  • probe.getAnimations() synchronously contains a CSSTransition with transitionProperty === "color".

This is a deterministic control-flow oracle, not a timing or pixel side channel. In the executed reproduction, getComputedStyle(probe).color remained identical (rgb(0, 255, 0)) before and after in both worlds, and no pixel changes (the unvisited channel interpolates lime→lime); only the transition machinery leaks. One probe per URL; many URLs can be tested by rewriting href and re-swapping the rule.


Root Cause Analysis

  1. Actual history bit enters a child’s visited slot: third_party/blink/renderer/core/css/properties/longhands/longhands_custom.cc:4862-4879 — for an element with explicit color: inherit (or currentcolor, via ApplyValue at longhands_custom.cc:4881-4895) inside a link, the inherited visited color depends on whether the parent link is actually visited:

    // longhands_custom.cc:4868-4875
    // In principle, we should always inherit the InternalVisitedColor()
    // of the parent, but (as an optimization) we don't store anything in
    // that field for elements outside of visited links.
    const StyleColor& inherited_color =
        (state.ParentStyle()->InsideLink() == EInsideLink::kInsideVisitedLink)
            ? state.ParentStyle()->InternalVisitedColor()  // <-- real history bit
            : state.ParentStyle()->Color();
    builder.SetInternalVisitedColor(inherited_color);
    

    InsideLink() carries the real (partitioned) visited state from VisitedLinkState::DetermineLinkStateSlowCase (third_party/blink/renderer/core/dom/visited_link_state.cc:182).

  2. Plain color: inherit reaches the gadget: rules on elements inside links keep link-match-type kMatchAll (third_party/blink/renderer/core/css/element_rule_collector.cc:279-285), and cascade expansion duplicates every kMatchAll declaration onto its visited counterpart (third_party/blink/renderer/core/css/resolver/cascade_expansion-inl.h:72-77), so the author’s color: inherit also applies -internal-visited-color: inherit, invoking the applier above.

  3. The transition-start gate consumes the visited slot: third_party/blink/renderer/core/animation/css/css_animations.cc:2638-2643 decides whether to start a transition via ComputedTransitionValuesEqual → CSSPropertyEquality::PropertiesEqual:

    // css_property_equality.cc:394-396
    case CSSPropertyID::kColor:
      return ValueEquals(a.Color(), b.Color()) &&
             ValueEquals(a.InternalVisitedColor(), b.InternalVisitedColor());  // <-- visited slot
    

    A visited-slot-only difference therefore starts a transition. Nothing on the path filters on visited state, and color is non-discretely interpolable, so StartTransition is reached (css_animations.cc:2831).

  4. The transition is web-observable: the new transition is materialized as a playing CSSTransition with a TransitionEventDelegate (css_animations.cc:2486-2528); transitionrun is enqueued on the first phase change (css_animations.cc:3633-3648) and the animation appears in element.getAnimations() (element.cc:797-836), regardless of whether any pixel changes.

Reproduction

  1. Serve:
    <style id=vs>a:visited { color: red; }</style>
    <style>a:link { color: lime; }
           #probe { color: inherit; transition: color 0.1s; }</style>
    <a href="https://victim.example/"><span id=probe>x</span></a>
    
  2. Register probe.addEventListener('transitionrun', handler), force initial style (void getComputedStyle(probe).color), and wait two requestAnimationFrames so rendering has begun (transitions are suppressed before first paint, css_animations.cc:3041-3045).
  3. Swap the rule: vs.textContent = 'a:visited { color: blue; }'.
  4. Read the oracle: probe.getAnimations().length and/or the transitionrun event.
  5. Expected (correct implementation): transition start must depend only on web-observable (unvisited) computed values — no transition in either world, since the unvisited color is lime before and after.

Observed (executed, deterministic on first attempt on both release and ASan builds, 155.0.8055.0): with the link visited, a CSSTransition on color is created — transitionrun fires and getAnimations() is non-empty; with the link unvisited, no transition, no event, empty list. A never-visited control link stayed negative and an always-visited self-link control stayed positive throughout. getComputedStyle(probe).color stayed rgb(0, 255, 0) in all cases, and the history entry was created by a real same-origin top-level link-click navigation, so the leak works with the default-enabled PartitionVisitedLinkDatabaseWithSelfLinks partitioning.

Severity

low — user-information disclosure of a browsing-history bit to arbitrary web content, with no memory-safety impact (renderer logic bug). The oracle is deterministic (control-flow, not timing), but the leak is one bit per probe and default visited-link partitioning confines it to URLs visited from the same top-level site + frame origin (plus self-links), i.e. an info leak of limited scope. This matches the established rating for other :visited-state oracles of the same class.

Suggested Fix Direction

Base the transition-start decision on unvisited values only: drop the InternalVisited* comparisons from CSSPropertyEquality::PropertiesEqual (experimentally confirmed for kColor: comparing only Color() kills the oracle while visited paint still applies). Note every color property case in that switch compares its visited slot (background, borders, caret, outline, SVG paints, etc.); only kColor is exploitable today because only InternalVisitedColor::ApplyInherit consults actual visited state — the generated visited-property appliers inherit from the parent’s unvisited getter — but all visited-slot comparisons should be removed for defense in depth.


POC: :visited history leak via CSS transition-start check (PropertiesEqual(kColor) compares InternalVisitedColor)

Strategy: web-content Bug Type: information disclosure (browsing-history bit, CWE-200) — pure logic bug, no memory corruption

Web content learns whether a URL is in the user’s :visited set through a deterministic control-flow oracle: a style change that touches only the internal visited color channel starts a real CSSTransition, observable via transitionrun events and element.getAnimations(), while getComputedStyle() stays identical in both worlds.

Prerequisites

  • A Chromium desktop build (verified on 155.0.8055.0, both an ASAN build and a release build — identical results; no sanitizer needed, the oracle is pure logic).
  • Python 3 for the local web server.
  • Environment-only Chrome flags used below: --no-first-run --no-default-browser-check --user-data-dir --no-proxy-server --ignore-gpu-blocklist (sandbox/display plumbing; none affect the bug). No security features are disabled; PartitionVisitedLinkDatabaseWithSelfLinks is stable/default in this tree and stays enabled — the POC creates its history entry with a real same-origin top-level link-click navigation, so the visit lands inside the correct &lt;link_url, top_level_site, frame_origin&gt; partition.

Files

  • poc.html — the oracle page. Three probe links, each wrapping a <span class=probe> with color: inherit; transition: color 0.1s:

    • A → /target-visited.html (gets genuinely visited during the run)
    • B → /target-unvisited.html (never visited — negative control)
    • C → href="" (self link, always visited — positive control)

    One oracle run = swap the a:visited { color: … } rule to a fresh color (changes every link’s visited slot in both style worlds, structurally), then test each probe for a new CSSTransition on color via getAnimations() and transitionrun. Phase 1 records the baseline, clicks link A; target-visited.html bounces back to phase 2, which re-runs the oracle and reports the verdict.

  • target-visited.html, target-unvisited.html — navigation targets.

  • server.py — serves the files on http://localhost:8080 and appends /report?... query strings to results.log.

Reproduction

cd /poc-artifacts
python3 server.py &            # writes oracle reports to results.log

rm -rf /tmp/chrome-poc-profile # fresh profile => empty history
/chromium_src/src/out/asan/chrome \
  --no-first-run --no-default-browser-check --ignore-gpu-blocklist \
  --no-proxy-server --user-data-dir=/tmp/chrome-poc-profile \
  http://localhost:8080/poc.html

# wait ~20s, then:
cat results.log

The run is fully automatic (baseline → link-click visit → re-test).

Expected Result

results.log gains two lines:

  • phase=1 (empty history): A=UNVISITED B=UNVISITED C=VISITED — C is the always-visited self-link proving the oracle machinery works.
  • phase=2 (after the single visit to /target-visited.html): A=VISITED B=UNVISITED, eventA=true, attempts=1, leak=CONFIRMED — the history bit was read back purely from the existence of a CSS transition.

In every report, gcs* fields show getComputedStyle(probe).color is rgb(0, 255, 0) before and after in both worlds — the classic oracle stays sealed; only the transition machinery leaks.

Output

Actual captured output (ASAN build, results-asan.log; release build in results-release.log is identical):

phase=1&A=UNVISITED&B=UNVISITED&C=VISITED&eventA=false&eventB=false&eventC=true&gcsA=rgb(0, 255, 0)=>rgb(0, 255, 0)&gcsB=rgb(0, 255, 0)=>rgb(0, 255, 0)&gcsC=rgb(0, 255, 0)=>rgb(0, 255, 0)
phase=2&A=VISITED&B=UNVISITED&C=VISITED&eventA=true&eventB=false&eventC=true&gcsA=rgb(0, 255, 0)=>rgb(0, 255, 0)&gcsB=rgb(0, 255, 0)=>rgb(0, 255, 0)&gcsC=rgb(0, 255, 0)=>rgb(0, 255, 0)&attempts=1&leak=CONFIRMED

screenshot-phase2.png shows the phase-2 page: probes A and C are painted in the (user-visible-only) visited color while B stays lime, alongside the JS verdict RESULT: history leak CONFIRMED (A=VISITED B=UNVISITED C=VISITED).

Both runs were deterministic: first attempt, no timing games, no pixel readback.

Bandaid Fix

bandaid-fix.patch makes CSSPropertyEquality::PropertiesEqual(kColor) compare only the web-observable unvisited channel, so a visited-slot-only difference no longer starts a transition (the long-standing anti-:visited rule: visited state may influence paint only, never web-observable control flow). Call-site audit: the other users of PropertiesEqual (keyframe_effect_model.cc:266 compositor keyframe re-snapshot; style_resolver.cc:321-341 which only queries image/clip-path properties) are unaffected or benefit. Only kColor is exploitable today because only InternalVisitedColor has an actual-visited-state branch in its inherit applier; a complete upstream fix should still drop the visited-slot comparison from all color properties in this switch for defense in depth.

Verification (release build rebuilt with the patch, same POC run) — the oracle is dead even for the always-visited control C, across all retries, while the links still genuinely carry visited state (probes A and C still paint in the visited color, screenshot-phase2-fixed.png). Captured in results-fixed.log:

phase=1&A=UNVISITED&B=UNVISITED&C=UNVISITED&eventA=false&eventB=false&eventC=false&...
phase=2&A=UNVISITED&B=UNVISITED&C=UNVISITED&eventA=false&eventB=false&eventC=false&...&attempts=7&leak=NOT-CONFIRMED

The patch was reverted from the tree after verification.


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