Medium watch CVSS 6.5 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Animation
DescriptionInappropriate implementation in Animation
ComponentAnimation
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker464173573
Fix commit18889dd338aa (chromium/src) +104/-48
CWECWE-200, CWE-352 (Information exposure, CSRF)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedBrendan Draper
Reported2025-11-28 · bounty $2000 (per vendor advisory)
Fix landed2025-12-12 (public git)
Disclosed2026-02-10
Patch gap60 days — fix public in source before the advisory

Background

`KeyframeEffect`
the Web Animations object exposed to script whose target is the animated element and whose pseudoElement optionally names a pseudo-element of that element.
`effect_target_` vs. `target()`
Blink stores the script-visible originating element (target()) separately from the internal EffectTarget(), which GetStyledPseudoElement() may resolve to a PseudoElement or, for part-like pseudos such as ::placeholder, to an element inside the user-agent shadow root.
UA shadow DOM
the closed, browser-internal shadow tree that implements built-in controls such as <input>; its nodes are never supposed to be reachable from page script.
`new KeyframeEffect(source)`
the copy constructor that creates a new effect from an existing one, implemented by a KeyframeEffect::Create overload taking a source effect.

Root Cause Analysis

The copy-constructor overload of KeyframeEffect::Create (its opening signature line is outside the hunk; it is the overload that reads source->SpecifiedTiming()) built the clone with MakeGarbageCollected<KeyframeEffect>(source->EffectTarget(), ...), i.e. it passed the internal effect target as the clone’s script-visible target. When the source effect had pseudoElement: '::placeholder' on an <input>, EffectTarget() is an element in the input’s UA shadow root (as the new ResolveTargetFromEffectTarget() comment describes), so copy.target handed page script a reference into UA shadow DOM.

The fix constructs the clone with source->target(), then copies effect_target_ and pseudoElement explicitly, and adds DCHECK_EQ(ResolveTargetFromEffectTarget(...), ...) checks (also in the options-based constructor path) asserting that an effect target always maps back to the originating element. Separately, the patch tightens pseudo-element validation: the old ValidateAndCanonicalizePseudo() accepted any string starting with :: (e.g. ::abc) and the caller skipped validation for the empty string, while the new ValidatePseudoElement() parses via CSSSelectorParser::ParsePseudoElement(), throws kSyntaxError for kPseudoIdInvalid/kPseudoIdNone, and lowercases the canonical form (fixing ::placeHOLDER).

Key insight
The copy constructor conflated the internal effect target with the public target, so cloning a ::placeholder effect leaked a UA-shadow-DOM element to script; the fix clones from source->target() and keeps the internal target private.

Attack Path

  1. Create a pseudo-element effect Page script creates an <input> with a placeholder and calls new KeyframeEffect(input, [], { pseudoElement: '::placeholder' }), which is accepted and whose internal EffectTarget() resolves into the input’s UA shadow root.
  2. Clone the effect Script calls new KeyframeEffect(effect); pre-patch the clone’s target was set from source->EffectTarget().
  3. Read the leaked node copy.target returns the UA-shadow placeholder element instead of the <input>, exactly what the new WPT Copied KeyframeEffect does not expose UA-shadow DOM asserts against.
  4. Manipulate UA internals With a script reference to a UA-shadow node, the page can inspect or mutate browser-internal DOM that other code assumes is unreachable, which may enable further bugs (inferred).

Impact Assessment

The vendor advisory rates this medium (“Inappropriate implementation in Animation”); no exploited-in-the-wild status is given in the supplied metadata. Ordinary web content in a stock build reaches it with no user gesture, gaining a JavaScript reference to an element inside a built-in control’s UA shadow DOM in the renderer, which breaks the encapsulation boundary. Any memory-safety or further security consequence of mutating UA shadow nodes is (inferred) and not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
switch
third_party/blink/renderer/core/animation/keyframe_effect.cc
modified
if
third_party/blink/renderer/core/animation/keyframe_effect.cc
modified

Files Changed

  • third_party/blink/renderer/core/animation/keyframe_effect.cc
  • third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt

Audit Directions

  • Internal vs. script-visible targets
    Audit other Web Animations paths (Animation.effect, getAnimations(), CSSAnimation/CSSTransition effect.target, event targets) for places that return EffectTarget() or a PseudoElement-resolved node instead of the originating element.
  • Part-like pseudo-elements
    Check every API that accepts a pseudo-element string (getComputedStyle, animate, KeyframeEffect, view transitions) for pseudos like ::placeholder, ::file-selector-button, ::-webkit-* that resolve to UA shadow nodes.
  • Copy/clone constructors
    Review clone paths that copy internal fields into public constructor arguments, since they can bypass the conversions performed by the normal constructor.
From 18889dd338aac93fa4f9b375744ff1688c307a36 Mon Sep 17 00:00:00 2001
From: Kevin Ellis <[email protected]>
Date: Fri, 12 Dec 2025 09:46:51 -0800
Subject: [PATCH] Prevent exposing UA shadow-DOM in KeyframeEffect constructor

Bug: 464173573
Change-Id: I241cf3e1d22e95a694590d207018c2503ad65166
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7247696
Reviewed-by: Vladimir Levin <[email protected]>
Commit-Queue: Kevin Ellis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1558126}
---

diff --git a/third_party/blink/renderer/core/animation/keyframe_effect.cc b/third_party/blink/renderer/core/animation/keyframe_effect.cc
index 0639626a..f32cba57 100644
--- a/third_party/blink/renderer/core/animation/keyframe_effect.cc
+++ b/third_party/blink/renderer/core/animation/keyframe_effect.cc
@@ -48,6 +48,7 @@
 #include "third_party/blink/renderer/core/css/resolver/style_resolver.h"
 #include "third_party/blink/renderer/core/dom/element.h"
 #include "third_party/blink/renderer/core/dom/pseudo_element.h"
+#include "third_party/blink/renderer/core/dom/shadow_root.h"
 #include "third_party/blink/renderer/core/frame/web_feature.h"
 #include "third_party/blink/renderer/core/paint/paint_layer.h"
 #include "third_party/blink/renderer/core/style/computed_style.h"
@@ -63,26 +64,69 @@
 
 namespace {
 
-// Verifies that a pseudo-element selector lexes and canonicalizes legacy forms
-bool ValidateAndCanonicalizePseudo(String& selector) {
-  if (selector.IsNull()) {
-    return true;
-  } else if (selector.StartsWith("::")) {
-    return true;
-  } else if (selector == ":before") {
-    selector = "::before";
-    return true;
-  } else if (selector == ":after") {
-    selector = "::after";
-    return true;
-  } else if (selector == ":first-letter") {
-    selector = "::first-letter";
-    return true;
-  } else if (selector == ":first-line") {
-    selector = "::first-line";
+bool ValidatePseudoElement(String& pseudo, ExceptionState& exception_state) {
+  // https://www.w3.org/TR/web-animations-1/#dom-keyframeeffect-pseudoelement
+  if (pseudo.IsNull()) {
     return true;
   }
-  return false;
+
+  AtomicString pseudo_argument = g_null_atom;
+  PseudoId pseudo_id = pseudo.StartsWith(":")
+                           ? CSSSelectorParser::ParsePseudoElement(
+                                 pseudo, /*parent=*/nullptr, pseudo_argument)
+                           : kPseudoIdInvalid;
+
+  switch (pseudo_id) {
+    case kPseudoIdInvalid:
+    case kPseudoIdNone: {
+      StringBuilder sb;
+      sb.Append(pseudo);
+      sb.Append(" is a syntactically invalid pseudo-element");
+      exception_state.ThrowDOMException(DOMExceptionCode::kSyntaxError,
+                                        sb.ToString());
+      return false;
+    }
+
+      // From the spec:
+      // Syntactically invalid pseudo-elements as well as pseudo-elements for
+      // which the user agent has no usable level of support are both deemed
+      // invalid.
+      // TODO(kevers): Are there any pseudos that belong in this second bucket?
+      // Currently input::placeholder is not animated on any of the major web
+      // platforms, though handled inconsistently. Failure to animate
+      // ::placeholder seems like a bug rather than a technical limitation.
+
+    default:
+      // Convert to canonical form.
+      if (!pseudo.StartsWith("::")) {
+        StringBuilder sb;
+        sb.Append(":");
+        sb.Append(pseudo);
+        pseudo = sb.ToString();
+      }
+      pseudo = pseudo.LowerASCII();
+      return true;
+  }
+}
+
+Element* ResolveTargetFromEffectTarget(Element* effect_target) {
+  if (!effect_target) {
+    return nullptr;
+  }
+
+  if (PseudoElement* pseudo = DynamicTo<PseudoElement>(effect_target)) {
+    return &pseudo->UltimateOriginatingElement();
+  }
+
+  // A pseudo-element for a part-like piece (e.g. input placeholder) might have
+  // previously resolved to an element inside UA-shadow DOM. In this case, the
+  // host is effect target.
+  if (effect_target->IsInUserAgentShadowRoot()) {
+    ShadowRoot* shadow_root = effect_target->ContainingShadowRoot();
+    return &shadow_root->host();
+  }
+
+  return effect_target;
 }
 
 enum class KeyframeOrderStrategy { kSpecifiedOrdering, kCssKeyframeOrdering };
@@ -148,14 +192,9 @@
     auto* effect_options = options->GetAsKeyframeEffectOptions();
     composite = EffectModel::EnumToCompositeOperation(
         effect_options->composite().AsEnum());
-    if (!effect_options->pseudoElement().empty()) {
+    if (!effect_options->pseudoElement().IsNull()) {
       pseudo = effect_options->pseudoElement();
-      if (!ValidateAndCanonicalizePseudo(pseudo)) {
-        // TODO(gtsteel): update when
-        // https://github.com/w3c/csswg-drafts/issues/4586 resolves
-        exception_state.ThrowDOMException(
-            DOMExceptionCode::kSyntaxError,
-            "A valid pseudo-selector must be null or start with ::.");
+      if (!ValidatePseudoElement(pseudo, exception_state)) {
         return nullptr;
       }
     }
@@ -187,6 +226,14 @@
           pseudo, element, pseudo_argument);
       effect->effect_target_ =
           element->GetStyledPseudoElement(pseudo_id, pseudo_argument);
+      if (effect->effect_target_) {
+        DCHECK_EQ(ResolveTargetFromEffectTarget(effect->effect_target_),
+                  element);
+      }
+      // TODO(crbug.com/468323247): GetStyledPseudoElement can return null even
+      // though the pseudo name is valid. Resolve how to handle this case. An
+      // example is setting up a KeyframeEffect on element::after before setting
+      // the content property on the pseudo.
     }
   }
   return effect;
@@ -209,9 +256,18 @@
                                        ExceptionState& exception_state) {
   Timing new_timing = source->SpecifiedTiming();
   KeyframeEffectModelBase* model = source->Model()->Clone();
-  return MakeGarbageCollected<KeyframeEffect>(source->EffectTarget(), model,
-                                              new_timing, source->GetPriority(),
-                                              source->GetEventDelegate());
+  // As we already have both target and EffectTarget, we can short-circuit
+  // the conversion.
+  KeyframeEffect* clone = MakeGarbageCollected<KeyframeEffect>(
+      source->target(), model, new_timing, source->GetPriority(),
+      source->GetEventDelegate());
+  clone->effect_target_ = source->EffectTarget();
+  clone->setPseudoElement(source->pseudoElement(), exception_state);
+  if (source->EffectTarget()) {
+    DCHECK_EQ(ResolveTargetFromEffectTarget(source->EffectTarget()),
+              source->target());
+  }
+  return clone;
 }
 
 KeyframeEffect::KeyframeEffect(Element* target,
@@ -257,16 +313,10 @@
 
 void KeyframeEffect::setPseudoElement(String pseudo,
                                       ExceptionState& exception_state) {
-  if (ValidateAndCanonicalizePseudo(pseudo)) {
+  if (ValidatePseudoElement(pseudo, exception_state)) {
     target_pseudo_ = pseudo;
-  } else {
-    exception_state.ThrowDOMException(
-        DOMExceptionCode::kSyntaxError,
-        "A valid pseudo-selector must be null or start with ::.");
-    return;
+    RefreshTarget();
   }
-
-  RefreshTarget();
 }
 
 void KeyframeEffect::RefreshTarget() {
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
deleted file mode 100644
index 73e2f5f..0000000
--- a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-This is a testharness.js-based test.
-Found 2 FAIL, 0 TIMEOUT, 0 NOTRUN.
-[FAIL] animate() with a non-null invalid pseudoElement '' throws a SyntaxError
-  assert_throws_dom: function "() => {\n      div.animate(null, {pseudoElement: pseudo});\n    }" did not throw
-[FAIL] animate() with a non-null invalid pseudoElement '::abc' throws a SyntaxError
-  assert_throws_dom: function "() => {\n      div.animate(null, {pseudoElement: pseudo});\n    }" did not throw
-Harness: the test ran to completion.
-
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
deleted file mode 100644
index 73e2f5f..0000000
--- a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/Animatable/animate-expected.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-This is a testharness.js-based test.
-Found 2 FAIL, 0 TIMEOUT, 0 NOTRUN.
-[FAIL] animate() with a non-null invalid pseudoElement '' throws a SyntaxError
-  assert_throws_dom: function "() => {\n      div.animate(null, {pseudoElement: pseudo});\n    }" did not throw
-[FAIL] animate() with a non-null invalid pseudoElement '::abc' throws a SyntaxError
-  assert_throws_dom: function "() => {\n      div.animate(null, {pseudoElement: pseudo});\n    }" did not throw
-Harness: the test ran to completion.
-
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/copy-constructor.html b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/copy-constructor.html
index e3bc0db0..3126f110 100644
--- a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/copy-constructor.html
+++ b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/copy-constructor.html
@@ -20,6 +20,24 @@
 }, 'Copied KeyframeEffect has the same target');
 
 test(t => {
+  const target = createElement(t, 'input');
+  target.setAttribute('type', 'input');
+  target.setAttribute('placeholder', "hello");
+  let effect = undefined;
+  try {
+    // Pseudo-elements for which the user agent has no usable level of support
+    // are deemed invalid. Test passes if no effect is generated.
+    effect = new KeyframeEffect(target, [], { pseudoElement: '::placeholder' });
+  } catch (e) {}
+  if (effect) {
+    // If an effect is generated (UA supports the pseudo), then the copy
+    // constructor is to retain the correct target.
+    const copy = new KeyframeEffect(effect);
+    assert_equals(copy.target, target);
+  }
+}, 'Copied KeyframeEffect does not expose UA-shadow DOM');
+
+test(t => {
   const effect =
     new KeyframeEffect(null,
                        [ { marginLeft: '0px' },
diff --git a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/target-expected.txt b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/target-expected.txt
index 49e7547..0ccf8eb 100644
--- a/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/target-expected.txt
+++ b/third_party/blink/web_tests/external/wpt/web-animations/interfaces/KeyframeEffect/target-expected.txt
@@ -11,9 +11,5 @@
   assert_equals: Value of 2nd element (currently targeted) after changing the effect target expected "50px" but got "20px"
 [FAIL] Change target from a non-existing to a different non-existing pseudo-element by setting pseudoElement.
   assert_equals: Value of 2nd element (currently targeted) after changing the effect target expected "50px" but got "20px"
-[FAIL] Changing pseudoElement to a non-null invalid pseudo-selector '::abc' throws a SyntaxError
-  assert_throws_dom: function "() => effect.pseudoElement = pseudo" did not throw
-[FAIL] Changing pseudoElement to ::placeHOLDER works
-  assert_equals: expected "::placeholder" but got "::placeHOLDER"
 Harness: the test ran to completion.
Loading diff…

Original Bug Report

reported by [email protected]

KeyframeEffect constructor leaks UA shadow root.

Security Bug

Important: Please do not change the component of this bug manually.

Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md

Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs

Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp

NOTE: Security bugs are normally made public once a fix has been widely deployed.


VULNERABILITY DETAILS

When a Keyframe is constructed with the pseudoElement option, animations are applied to the corresponding pseudo-element, but the target property still refers to the originating element:

effect = new KeyframeEffect(element, [], {pseudoElement: "::placeholder"});
effect.target == inputElement;

But when cloning from another KeframeEffect, the element the animations are applied to is used as the target too:

effect = new KeyframeEffect(effect);
effect.target != inputElement;
```.

This element may be a pseudo element or a "styled" pseudo element residing in a target element's shadow tree. The placeholder attribute rendered within an &lt;input&gt; element's shadow tree is a an example of an element that can be animated but should not be accessible.

Attached example observers and logs changes to an input's ::placeholder pseudo element. If the user has an saved address in chrome, hovering over the suggested address will disclose it without confirmation.

Many other internal elements may be accessed this way. They can be identified in blink source by use of the `Element::SetShadowPseudoId` method.

VERSION
Chrome Version: Version 144.0.7533.0 (Developer Build) (64-bit)
Operating System: Linux

REPRODUCTION CASE
Visit chrome://settings/addresses and add an address with at least a street address
Open poc.html in chrome served from an HTTP server.
Click on or tab to the input under the Address header.
Hover over suggested address or press down arrow.
Observe the street address is printed to the output.


CREDIT INFORMATION
Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited?
Reporter credit: Brendan Draper
View on issue tracker