CVE-2026-13839
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/css/css_keyframe_rule.cc |
modified | |
is_prefixed_third_party/blink/renderer/core/css/css_keyframes_rule.cc |
modified | |
ifthird_party/blink/renderer/core/css/css_keyframes_rule.cc |
modified | |
StyleRuleKeyframesthird_party/blink/renderer/core/css/css_keyframes_rule.h |
modified |
Files Changed
third_party/blink/renderer/core/css/css_keyframe_rule.ccthird_party/blink/renderer/core/css/css_keyframes_rule.ccthird_party/blink/renderer/core/css/css_keyframes_rule.hthird_party/blink/renderer/core/css/style_rule.ccthird_party/blink/web_tests/external/wpt/css/css-animations/keyframes-rule-caching.html
Patch
From c7161f4f44e19f10820ec7d75543b2b8b9018c28 Mon Sep 17 00:00:00 2001
From: Rune Lillesveen <futhark@chromium.org>
Date: Fri, 22 May 2026 01:03:46 -0700
Subject: [PATCH] Properly clone and reattach @keyframes rules
The rules not being properly deep-cloned as CSSOM wrappers reattached
lead to modifications being applied to multiple stylesheets initially
sharing a StyleSheetContents instance.
Bug: 514449396
Change-Id: Ia78191430974e6263ad3afca19d9a0d2a39f170b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7865248
Commit-Queue: Rune Lillesveen <futhark@chromium.org>
Reviewed-by: Kevin Babbitt <kbabbitt@microsoft.com>
Cr-Commit-Position: refs/heads/main@{#1634779}
---
diff --git a/third_party/blink/renderer/core/css/css_keyframe_rule.cc b/third_party/blink/renderer/core/css/css_keyframe_rule.cc
index d7b6a59..74e906a 100644
--- a/third_party/blink/renderer/core/css/css_keyframe_rule.cc
+++ b/third_party/blink/renderer/core/css/css_keyframe_rule.cc
@@ -72,9 +72,11 @@
return properties_cssom_wrapper_.Get();
}
-void CSSKeyframeRule::Reattach(StyleRuleBase*) {
- // No need to reattach, the underlying data is shareable on mutation.
- NOTREACHED();
+void CSSKeyframeRule::Reattach(StyleRuleBase* rule) {
+ keyframe_ = To<StyleRuleKeyframe>(rule);
+ if (properties_cssom_wrapper_) {
+ properties_cssom_wrapper_->Reattach(keyframe_->MutableProperties());
+ }
}
void CSSKeyframeRule::Trace(Visitor* visitor) const {
diff --git a/third_party/blink/renderer/core/css/css_keyframes_rule.cc b/third_party/blink/renderer/core/css/css_keyframes_rule.cc
index b1407bd..80914b4 100644
--- a/third_party/blink/renderer/core/css/css_keyframes_rule.cc
+++ b/third_party/blink/renderer/core/css/css_keyframes_rule.cc
@@ -48,6 +48,17 @@
StyleRuleKeyframes::StyleRuleKeyframes(const StyleRuleKeyframes& o) = default;
+StyleRuleKeyframes::StyleRuleKeyframes(
+ HeapVector<Member<StyleRuleKeyframe>>&& keyframes,
+ const AtomicString& name,
+ unsigned version,
+ bool is_vendor_prefixed)
+ : StyleRuleBase(kKeyframes),
+ keyframes_(std::move(keyframes)),
+ name_(name),
+ version_(version),
+ is_prefixed_(is_vendor_prefixed) {}
+
StyleRuleKeyframes::~StyleRuleKeyframes() = default;
void StyleRuleKeyframes::ParserAppendKeyframe(StyleRuleKeyframe* keyframe) {
@@ -230,6 +241,14 @@
void CSSKeyframesRule::Reattach(StyleRuleBase* rule) {
DCHECK(rule);
keyframes_rule_ = To<StyleRuleKeyframes>(rule);
+ CHECK_EQ(child_rule_cssom_wrappers_.size(),
+ keyframes_rule_->Keyframes().size());
+ for (unsigned i = 0; i < child_rule_cssom_wrappers_.size(); ++i) {
+ if (child_rule_cssom_wrappers_[i]) {
+ child_rule_cssom_wrappers_[i]->Reattach(
+ keyframes_rule_->Keyframes()[i].Get());
+ }
+ }
}
void CSSKeyframesRule::Trace(Visitor* visitor) const {
diff --git a/third_party/blink/renderer/core/css/css_keyframes_rule.h b/third_party/blink/renderer/core/css/css_keyframes_rule.h
index 32cf292..f8323b2 100644
--- a/third_party/blink/renderer/core/css/css_keyframes_rule.h
+++ b/third_party/blink/renderer/core/css/css_keyframes_rule.h
@@ -42,7 +42,11 @@
class StyleRuleKeyframes final : public StyleRuleBase {
public:
StyleRuleKeyframes();
- explicit StyleRuleKeyframes(const StyleRuleKeyframes&);
+ StyleRuleKeyframes(HeapVector<Member<StyleRuleKeyframe>>&& keyframes,
+ const AtomicString& name,
+ unsigned version,
+ bool is_vendor_prefixed);
+ StyleRuleKeyframes(const StyleRuleKeyframes&);
~StyleRuleKeyframes();
const HeapVector<Member<StyleRuleKeyframe>>& Keyframes() const {
diff --git a/third_party/blink/renderer/core/css/style_rule.cc b/third_party/blink/renderer/core/css/style_rule.cc
index e15d01bb..f4a449c5 100644
--- a/third_party/blink/renderer/core/css/style_rule.cc
+++ b/third_party/blink/renderer/core/css/style_rule.cc
@@ -786,9 +786,18 @@
To<StyleRuleFontFeature>(*this));
case kImport:
return MakeGarbageCollected<StyleRuleImport>(To<StyleRuleImport>(*this));
- case kKeyframes:
+ case kKeyframes: {
+ StyleRuleKeyframes* keyframes_rule = To<StyleRuleKeyframes>(this);
+ HeapVector<Member<StyleRuleKeyframe>> new_keyframes;
+ for (const Member<StyleRuleKeyframe>& keyframe :
+ keyframes_rule->Keyframes()) {
+ new_keyframes.push_back(To<StyleRuleKeyframe>(
+ keyframe->Clone(new_parent, mixin_parameter_bindings)));
+ }
return MakeGarbageCollected<StyleRuleKeyframes>(
- To<StyleRuleKeyframes>(*this));
+ std::move(new_keyframes), keyframes_rule->GetName(),
+ keyframes_rule->Version(), keyframes_rule->IsVendorPrefixed());
+ }
case kLayerStatement:
return MakeGarbageCollected<StyleRuleLayerStatement>(
To<StyleRuleLayerStatement>(*this));
@@ -801,9 +810,13 @@
counter_style->GetName(),
counter_style->Properties().ImmutableCopyIfNeeded());
}
- case kKeyframe:
+ case kKeyframe: {
+ auto* keyframe_rule = To<StyleRuleKeyframe>(this);
+ std::unique_ptr<Vector<KeyframeOffset>> keys =
+ std::make_unique<Vector<KeyframeOffset>>(keyframe_rule->Keys());
return MakeGarbageCollected<StyleRuleKeyframe>(
- To<StyleRuleKeyframe>(*this));
+ std::move(keys), keyframe_rule->Properties().ImmutableCopyIfNeeded());
+ }
case kCharset:
return MakeGarbageCollected<StyleRuleCharset>(
To<StyleRuleCharset>(*this));
diff --git a/third_party/blink/web_tests/external/wpt/css/css-animations/keyframes-rule-caching.html b/third_party/blink/web_tests/external/wpt/css/css-animations/keyframes-rule-caching.html
new file mode 100644
index 0000000..b6d7fcf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-animations/keyframes-rule-caching.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<title>Tests that given two identical sheets containing the same @keyframes rule, modifying one sheet doesn't affect the other sheet</title>
+<link rel="help" href="https://drafts.csswg.org/css-animations/#interface-csskeyframesrule">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style id="style1">
+ @keyframes --anim {
+ from { color: red; }
+ to { color: black; }
+ }
+</style>
+<style id="style2">
+ @keyframes --anim {
+ from { color: red; }
+ to { color: black; }
+ }
+</style>
+<script>
+ test(() => {
+ style1.sheet.cssRules[0].cssRules[1].style.color = "green";
+ assert_equals(style1.sheet.cssRules[0].cssRules[1].style.color, "green", "sheet1 keyframe changed");
+ assert_equals(style2.sheet.cssRules[0].cssRules[1].style.color, "black", "sheet2 keyframe did not change");
+ }, "Modifying one sheet should not affect the other");
+</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/css/css-animations/keyframes-rule-caching.html b/third_party/blink/web_tests/external/wpt/css/css-animations/keyframes-rule-caching.html
new file mode 100644
index 0000000..b6d7fcf
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/css/css-animations/keyframes-rule-caching.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<title>Tests that given two identical sheets containing the same @keyframes rule, modifying one sheet doesn't affect the other sheet</title>
+<link rel="help" href="https://drafts.csswg.org/css-animations/#interface-csskeyframesrule">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style id="style1">
+ @keyframes --anim {
+ from { color: red; }
+ to { color: black; }
+ }
+</style>
+<style id="style2">
+ @keyframes --anim {
+ from { color: red; }
+ to { color: black; }
+ }
+</style>
+<script>
+ test(() => {
+ style1.sheet.cssRules[0].cssRules[1].style.color = "green";
+ assert_equals(style1.sheet.cssRules[0].cssRules[1].style.color, "green", "sheet1 keyframe changed");
+ assert_equals(style2.sheet.cssRules[0].cssRules[1].style.color, "black", "sheet2 keyframe did not change");
+ }, "Modifying one sheet should not affect the other");
+</script>
Original Bug Report
Potential SOP Bypass via Same-Process CSS Poisoning in @keyframes COW
Flapjack, 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 potential logic flaw in Blink’s CSS Copy-on-Write (COW) implementation for @keyframes rules allows same-process CSS poisoning. Shallow cloning of keyframes and incomplete CSSOM wrapper reattachment causes mutations to affect shared StyleSheetContents. This could enable a Same-Origin Policy (SOP) bypass between documents sharing a renderer process.
Affected files:
third_party/blink/renderer/core/css/css_keyframes_rule.ccthird_party/blink/renderer/core/css/style_rule.ccthird_party/blink/renderer/core/css/css_keyframe_rule.cc
Estimated timestamp from git blame: 2025-09-16
Overview
A potential vulnerability exists in the Copy-on-Write (COW) implementation for CSS @keyframes rules. When a shared stylesheet is mutated via the CSS Object Model (CSSOM), Blink attempts to create a private fork to prevent modifying the globally cached version. However, due to broken cloning and wrapper reattachment logic, modifications to keyframes can leak into the shared StyleSheetContents, poisoning the stylesheet for other documents in the same renderer process.
Note: These findings are based on static code analysis by an AI tooling agent. The steps below are theoretical, as an executable proof-of-concept has not yet been developed.
Technical Details
The vulnerability stems from two implementation flaws in how Blink handles @keyframes during a CSSStyleSheet COW fork:
1. Shallow Copying of Keyframes
During a COW fork, StyleSheetContents::Copy() duplicates all rules via StyleRuleBase::Clone(). For @keyframes rules, this invokes the StyleRuleKeyframes copy constructor:
// third_party/blink/renderer/core/css/css_keyframes_rule.cc
StyleRuleKeyframes::StyleRuleKeyframes(const StyleRuleKeyframes& o) = default;
Because the copy constructor is defaulted, it performs a shallow copy of its members, including the keyframes_ vector (HeapVector<Member<StyleRuleKeyframe>>). Consequently, the newly cloned StyleRuleKeyframes object contains pointers to the exact same StyleRuleKeyframe instances as the original, cached stylesheet.
2. Missing Child Reattachment
After cloning the StyleSheetContents, Blink must update existing CSSOM wrappers to point to the newly allocated C++ rules.
While CSSKeyframesRule::Reattach updates its own internal keyframes_rule_ pointer, it fails to iterate through and reattach its child_rule_cssom_wrappers_ (unlike other grouping rules such as CSSGroupingRule::Reattach).
Furthermore, the reattachment method for the child wrapper itself assumes keyframes are immutable:
// third_party/blink/renderer/core/css/css_keyframe_rule.cc
void CSSKeyframeRule::Reattach(StyleRuleBase*) {
// No need to reattach, the underlying data is shareable on mutation.
NOTREACHED();
}
The Resulting Flaw
If an attacker holds a CSSKeyframeRule wrapper when a COW fork is triggered (e.g., by calling setProperty), the wrapper maintains a stale pointer to the original, shared StyleRuleKeyframe. Because StyleRuleKeyframe::MutableProperties() mutates the object in place, the change directly corrupts the shared cache.
Potential Attacker Steps
- An Attacker document (Origin A) and a Victim document (Origin B) are loaded into the same renderer process (e.g., same-site cross-origin on Desktop, or cross-site on platforms with relaxed site isolation like Android).
- Both documents load the same cross-origin stylesheet (e.g., a CSS framework from a CDN) containing a
@keyframesrule. - The Attacker uses JavaScript to traverse the CSSOM and obtain a reference to a nested
CSSKeyframeRule. - The Attacker mutates the rule via
keyframeRule.style.setProperty(...). - Because of the shallow copy and lack of wrapper reattachment, the mutation is applied to the globally shared
StyleSheetContentsin theMemoryCache. - The Victim document observes the poisoned CSS rules. The attacker can use this to spoof UI elements or perform cross-origin data exfiltration (e.g., injecting attribute selectors that trigger network requests via
background-image).
Suggested Fix
- Deep Copy Keyframes: Update the
StyleRuleKeyframescopy constructor to perform a deep copy of itskeyframes_array. It should create newStyleRuleKeyframeinstances based on the original data. - Reattach Children: Modify
CSSKeyframesRule::Reattachto iterate overchild_rule_cssom_wrappers_and invokeReattachon any instantiated child wrappers. - Implement Reattachment: Remove the
NOTREACHED()fromCSSKeyframeRule::Reattachand implement it to properly update its internalkeyframe_pointer to the newly cloned C++ object.
Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0
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.