Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in CSS
DescriptionInsufficient policy enforcement in CSS
ComponentCSS
Bug ClassLogic Error
Tracker501801823
Fix commit67362671e281 (chromium/src) +135/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/animation/css/css_animations.cc
modified
CORE_EXPORT
third_party/blink/renderer/core/animation/css_default_interpolation_type.h
modified

Files Changed

  • third_party/blink/renderer/core/animation/css/css_animations.cc
  • third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
  • third_party/blink/renderer/core/animation/css_default_interpolation_type.h
  • third_party/blink/renderer/core/css/properties/css_property.h
  • third_party/blink/renderer/core/css/properties/longhands/custom_property.cc
  • third_party/blink/web_tests/external/wpt/css/css-values/attr-security-animation.html
From 67362671e2817011a4547f9500e45380e3c66ca7 Mon Sep 17 00:00:00 2001
From: moonira <moonira@google.com>
Date: Fri, 17 Apr 2026 14:39:28 -0700
Subject: [PATCH] Propagate attr() taint flag to CSSDefaultNonInterpolableValue

Previously, if an `attr()` function was used in a custom property and
underwent a CSS transition, the tainted flag could be lost.

This patch ensures that the `is_attr_tainted` flag is properly
propagated to `CSSDefaultNonInterpolableValue` when transitioning
registered custom properties of type `<string>`.

Fixed: 501801823
Change-Id: I015539cf9fdaabd94e35aa5be5582e5dab7a2f4f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7772751
Reviewed-by: Anders Hartvoll Ruud <andruud@chromium.org>
Commit-Queue: Munira Tursunova <moonira@google.com>
Cr-Commit-Position: refs/heads/main@{#1616868}
---

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 5e78174c..9572e90 100644
--- a/third_party/blink/renderer/core/animation/css/css_animations.cc
+++ b/third_party/blink/renderer/core/animation/css/css_animations.cc
@@ -2656,6 +2656,7 @@
     return;
   }
 
+  bool is_attr_tainted = false;
   if (!start || !end) {
     const Document& document = state.animating_element.GetDocument();
     const CSSValue* start_css_value =
@@ -2687,12 +2688,27 @@
       // correctly. If that bug is fixed, then this should never happen.
       return;
     }
+    if (property.IsCSSCustomProperty()) {
+      CSSPropertyRef custom_ref(property.GetCSSPropertyName(), 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>(start_css_value));
+        MakeGarbageCollected<CSSDefaultNonInterpolableValue>(
+            start_css_value,
+            CSSDefaultNonInterpolableValue::AttrTainted(is_attr_tainted)));
     end = InterpolationValue(
         MakeGarbageCollected<InterpolableList>(0),
-        MakeGarbageCollected<CSSDefaultNonInterpolableValue>(end_css_value));
+        MakeGarbageCollected<CSSDefaultNonInterpolableValue>(
+            end_css_value,
+            CSSDefaultNonInterpolableValue::AttrTainted(is_attr_tainted)));
   }
 
   // If the interpolated transform lists contain any singular matrices, a
diff --git a/third_party/blink/renderer/core/animation/css_default_interpolation_type.cc b/third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
index c9212564..2f727946 100644
--- a/third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
+++ b/third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
@@ -13,8 +13,9 @@
 namespace blink {
 
 CSSDefaultNonInterpolableValue::CSSDefaultNonInterpolableValue(
-    const CSSValue* css_value)
-    : css_value_(css_value) {
+    const CSSValue* css_value,
+    AttrTainted is_attr_tainted)
+    : css_value_(css_value), is_attr_tainted_(is_attr_tainted) {
   DCHECK(css_value_);
 }
 
@@ -49,7 +50,8 @@
 
   return InterpolationValue(
       MakeGarbageCollected<InterpolableList>(0),
-      MakeGarbageCollected<CSSDefaultNonInterpolableValue>(css_value));
+      MakeGarbageCollected<CSSDefaultNonInterpolableValue>(
+          css_value, CSSDefaultNonInterpolableValue::AttrTainted(false)));
 }
 
 void CSSDefaultInterpolationType::Composite(
@@ -66,9 +68,15 @@
     CSSInterpolationEnvironment& environment) const {
   DCHECK(
       To<CSSDefaultNonInterpolableValue>(non_interpolable_value)->CssValue());
+  CSSProperty::ValueMode values_mode =
+      To<CSSDefaultNonInterpolableValue>(non_interpolable_value)
+              ->IsAttrTainted()
+          ? CSSProperty::ValueMode::kAttrTaintedAndAnimated
+          : CSSProperty::ValueMode::kAnimated;
   StyleBuilder::ApplyProperty(
       GetProperty().GetCSSPropertyName(), environment.GetState(),
-      *To<CSSDefaultNonInterpolableValue>(non_interpolable_value)->CssValue());
+      *To<CSSDefaultNonInterpolableValue>(non_interpolable_value)->CssValue(),
+      values_mode);
 }
 
 }  // namespace blink
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 9223868..6e78560 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
@@ -14,7 +14,9 @@
 class CORE_EXPORT CSSDefaultNonInterpolableValue final
     : public NonInterpolableValue {
  public:
-  explicit CSSDefaultNonInterpolableValue(const CSSValue*);
+  using AttrTainted = base::StrongAlias<class AttrTaintedTag, bool>;
+  explicit CSSDefaultNonInterpolableValue(const CSSValue*,
+                                          AttrTainted is_attr_tainted);
   ~CSSDefaultNonInterpolableValue() final = default;
 
   void Trace(Visitor* visitor) const override {
@@ -24,10 +26,18 @@
 
   const CSSValue* CssValue() const { return css_value_.Get(); }
 
+  bool IsAttrTainted() const { return is_attr_tainted_; }
+
   DECLARE_NON_INTERPOLABLE_VALUE_TYPE();
 
  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.
+  bool is_attr_tainted_;
 };
 
 template <>
diff --git a/third_party/blink/renderer/core/css/properties/css_property.h b/third_party/blink/renderer/core/css/properties/css_property.h
index a50c37f..61e04f52 100644
--- a/third_party/blink/renderer/core/css/properties/css_property.h
+++ b/third_party/blink/renderer/core/css/properties/css_property.h
@@ -314,6 +314,9 @@
     kNormal,
     // https://drafts.csswg.org/css-variables/#animation-tainted
     kAnimated,
+    // https://drafts.csswg.org/css-values-5/#attr-taint
+    kAttrTainted,
+    kAttrTaintedAndAnimated,
   };
 
  private:
diff --git a/third_party/blink/renderer/core/css/properties/longhands/custom_property.cc b/third_party/blink/renderer/core/css/properties/longhands/custom_property.cc
index 4cf650e..a3f3919 100644
--- a/third_party/blink/renderer/core/css/properties/longhands/custom_property.cc
+++ b/third_party/blink/renderer/core/css/properties/longhands/custom_property.cc
@@ -204,7 +204,8 @@
     return;
   }
 
-  bool is_animation_tainted = value_mode == ValueMode::kAnimated;
+  bool is_animation_tainted = value_mode == ValueMode::kAnimated ||
+                              value_mode == ValueMode::kAttrTaintedAndAnimated;
 
   // Note that the computed value ("SetVariableValue") is stored separately
   // from the substitution value ("SetVariableData") on ComputedStyle.
@@ -212,8 +213,10 @@
   // the custom property, and the computed value is generally used in other
   // cases (e.g. serialization).
 
-  bool is_attr_tainted = declaration && declaration->VariableDataValue() &&
-                         declaration->VariableDataValue()->IsAttrTainted();
+  bool is_attr_tainted = value_mode == ValueMode::kAttrTainted ||
+                         value_mode == ValueMode::kAttrTaintedAndAnimated ||
+                         (declaration && declaration->VariableDataValue() &&
+                          declaration->VariableDataValue()->IsAttrTainted());
 
   registered_value = &StyleBuilderConverter::ConvertRegisteredPropertyValue(
       state, *registered_value, context);
diff --git a/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-animation.html b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-animation.html
new file mode 100644
index 0000000..6390b05
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-animation.html
@@ -0,0 +1,51 @@
+<!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 --s {
+    syntax: "<string>";
+    inherits: false;
+    initial-value: "x";
+  }
+  #attr {
+    --s: attr(href);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-animation.html b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-animation.html
new file mode 100644
index 0000000..6390b05
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-animation.html
@@ -0,0 +1,51 @@
+<!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 --s {
+    syntax: "<string>";
+    inherits: false;
+    initial-value: "x";
+  }
+  #attr {
+    --s: attr(href);
+    animation: 1s anim linear;
+    background-image: image-set(var(--s));
+  }
+
+  @keyframes anim {
+    from {
+      --s: "x";
+    }
+    to {
+      --s: attr(href);
+    }
+  }
+</style>
+
+<html>
+  <body>
+    <div id="attr" href="https://does-not-exist.test/404.png">div</div>
+  </body>
+</html>
+
+<script>
+  test(() => {
+    // during animation
+    document.getAnimations().forEach(anim => {
+      anim.currentTime = 600;
+    });
+    var elem = document.getElementById("attr");
+    assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+
+    // after animation
+    document.getAnimations().forEach(anim => {
+      anim.currentTime = 3000;
+    });
+    var elem = document.getElementById("attr");
+    assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+  });
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition.html b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition.html
new file mode 100644
index 0000000..ad15e56
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-values/attr-security-transition.html
@@ -0,0 +1,34 @@
+<!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 --s {
+    syntax: "<string>";
+    inherits: false;
+    initial-value: "x";
+  }
+  #attr {
+    --s: attr(href);
+    transition: --s 10s allow-discrete -9s;
+    background-image: image-set(var(--s));
+  }
+  @starting-style {
+    #attr { --s: "x"; }
+  }
+</style>
+
+<html>
+  <body>
+    <div id="attr" href="https://does-not-exist.test/404.png">div</div>
+  </body>
+</html>
+
+<script>
+    var elem = document.getElementById("attr");
+    test(() => {
+        assert_equals(window.getComputedStyle(elem).getPropertyValue("background-image"), 'none');
+    }, `background-image value should be attr-tainted`);
+</script>
Loading diff…

Original Bug Report

reported by vm...@google.com

Bypass of attr()-security via discrete 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 without the Chrome Security team.

Overview: A potential vulnerability in Blink’s CSS engine allows bypassing the attr()-security control by using discrete transitions on registered custom properties. The issue occurs because transition endpoints are snapshotted as typed CSS values, which strips the is_attr_tainted flag. This could permit the exfiltration of sensitive attribute values via CSS-only techniques.

Affected files:

  • third_party/blink/renderer/core/css/properties/longhands/custom_property.cc
  • third_party/blink/renderer/core/animation/css/css_animations.cc
  • third_party/blink/renderer/core/css/resolver/style_cascade.cc
  • third_party/blink/renderer/core/animation/css_default_interpolation_type.cc
  • third_party/blink/renderer/core/css/css_syntax_definition.cc
  • third_party/blink/renderer/core/css/properties/css_parsing_utils.cc
  • third_party/blink/renderer/core/css/css_attr_type.cc

Estimated timestamp from git blame: 2025-04-02

Summary A potential vulnerability exists in Blink’s CSS implementation that allows for the laundering of the is_attr_tainted flag. This flag is a security mechanism designed to enforce the attr()-security control, preventing values derived from HTML attributes via attr() from being used in sensitive contexts like URLs. By leveraging discrete transitions on registered custom properties, an attacker can strip the taint from a value, bypassing these security checks and potentially exfiltrating sensitive data.

Technical Details

  1. The attr()-security control marks tokens substituted from attr() as tainted. This boolean flag (is_attr_tainted_) is stored within CSSVariableData.
  2. When a registered custom property (e.g., with syntax <string>) is assigned an attr() value, CustomProperty::ApplyValue parses it into a typed CSSValue (like CSSStringValue). CSSValue does not store the taint flag. However, ApplyValue correctly creates and stores a parallel CSSVariableData on the ComputedStyle with the taint preserved.
  3. If a CSS transition with transition-behavior: allow-discrete is triggered on this property, the animation engine calculates endpoints using AnimationUtils::KeyframeValueFromComputedStyle. This function retrieves the computed typed CSSValue (CSSStringValue), which lacks the taint information.
  4. During the animation, CSSDefaultInterpolationType::Apply passes this untainted CSSStringValue back into CustomProperty::ApplyValue.
  5. Inside CustomProperty::ApplyValue, the code attempts to cast the value to a CSSUnparsedDeclarationValue to extract the taint: const auto* declaration = DynamicTo<CSSUnparsedDeclarationValue>(value);.
  6. Because the incoming value is a CSSStringValue, the cast fails (declaration is nullptr), and is_attr_tainted is incorrectly evaluated as false.
  7. A new, untainted CSSVariableData is created and stored on the ComputedStyle.
  8. If this laundered custom property is subsequently used in a sensitive sink via var() (e.g., image-set()), the security check in css_parsing_utils::ConsumeImage (stream.IsAttrTainted()) evaluates to false, allowing the URL to be fetched.

Suggested Attacker Steps (Note: These are suggested steps; our tooling agent does not yet have the ability to run code to confirm a working exploit).

  1. Define a registered custom property with <string> syntax.
    @property --s {
      syntax: "<string>";
      inherits: false;
      initial-value: "initial";
    }
    
  2. Apply the custom property using attr() to target an attribute containing sensitive data (e.g., a CSRF token in href).
  3. Trigger a discrete transition to launder the value.
    a[href] {
      --s: attr(href);
      transition: --s 0.001s allow-discrete;
      background-image: image-set(var(--s) 1x);
    }
    @starting-style {
      a[href] { --s: "initial"; }
    }
    
  4. The transition launders the taint, and the browser incorrectly issues a GET request to the attacker’s server with the sensitive attribute data.

Suggested Fix To resolve this, the taint state (is_attr_tainted) must be preserved when custom properties are resolved into typed CSSValue objects, or the animation system must be updated to retrieve and preserve the original CSSVariableData for registered custom properties when taking keyframe snapshots. Alternatively, CSSValue could be augmented to carry the taint bit, ensuring that it is never lost when converting between unparsed token sequences and typed values.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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