← WebKit — 2026-W38

0916f620f0428e1fb2b41717c77e84f5f1ef2f6a  [WebCore] Out-of-bounds write in CSSStyleSheet::insertRule — replaceSync fast path mutates shared style sheet contents

severity high class UAF confidence 0.68 signal score 8.67 Source/WebCore/css patch-now AI review CONFIRMED
Primitive: in-place mutation of shared/cached StyleSheetContents via CSSStyleSheet.replaceSync()
Triage note: RC_REMOVE_SEC_ACCEL_3 brackets the fix: the old path reused m_contents directly, while the new #else path parses into fresh StyleSheetContents when m_contents hasOneClient() is false or it isInMemoryCache(), with the comment that shared/published contents must stay immutable. Mutating rules in place on contents referenced by other sheets or the cache corrupts state held by other clients; the RC macro marker weights this as a genuine silent fix.
Structural signals: smartptr_member_added (+4.87), msg_strong (+3.0), msg_noise (-2.5), area:Source/WebCore/css (+2.0), surgical (+1.3)

The bug at a glance

CSSStyleSheet::replaceSync() on a detached constructed stylesheet could mutate a StyleSheetContents object in place even when that StyleSheetContents was shared by other CSSStyleSheet clients or had been published to the global StyleSheetContentsCache. This runs in the WebContent (renderer) process and is reachable from ordinary script via the CSSOM new CSSStyleSheet() + replaceSync() path. The diff conclusively proves a shared-mutable-state / immutability-invariant violation (state corruption of rule data observed by unrelated sheets and the cache); escalation to a true use-after-free of StyleRule objects via stale CSSOM rule wrappers on co-clients is plausible but is (inferred) — the diff does not itself show a freed object being reused, so the proven defect is the lifetime/logic corruption rather than a demonstrated UAF.

Constructable stylesheets can share one StyleSheetContents (deduped through the in-memory cache or via adoptedStyleSheets), yet the fast cache-miss path in replaceSync() rewrote the rule list of that shared object directly instead of forking a private copy.

Root cause

The invariant is that a StyleSheetContents that is reachable by more than one client (hasOneClient() is false) or that lives in Style::StyleSheetContentsCache (isInMemoryCache()) must be treated as immutable: its rule vector may be read concurrently by every CSSStyleSheet that registered as a client and by future cache lookups, so no single sheet may edit it in place. CSSStyleSheet::replaceSync() takes a detached fast path when isDetached() && m_childRuleCSSOMWrappers.isEmpty(), building a StyleSheetContentsCache::Key from the new text and the existing m_contents->parserContext().

Pre-patch (the RC_REMOVE_SEC_ACCEL_3 branch) the cache-miss case reused m_contents directly: it cleared and re-parsed the rules on the existing, possibly-shared StyleSheetContents and then re-published that same object to the cache. Because m_contents can be the exact object another CSSStyleSheet still holds (via registerClient/unregisterClient refcounting) or the object other documents will fetch from StyleSheetContentsCache::singleton(), the in-place clearRules()+parseString() swaps the rule set out from under those other clients. 1. replaceSync() computes key and misses the cache; 2. it calls clearRules()/parseString() on m_contents itself; 3. other CSSStyleSheet clients that registered against that same StyleSheetContents, and any cache hit that already handed the object out, now observe a different rule set; 4. StyleRule objects that backed the previous rules are dropped while cross-sheet CSSOM wrappers (StyleRuleCSSOMWrapper) on those other clients may still reference them (inferred escalation).

StyleSheetContents is Ref/RefPtr-managed (not Oilpan): registerClient/unregisterClient track live clients and hasOneClient() reports whether exactly one CSSStyleSheet owns it. The bug is therefore a sharing/immutability violation — mutating ref-counted state that other owners assume is stable — not a refcount-underflow. The triage labels this UAF/high; the diff supports a shared-state corruption and a lifetime hazard, and I flag that a demonstrated freed-then-reused object is not present in the supplied hunk.

Key code

Fork fresh contents when shared or cached instead of mutating in place

// Contents that are shared with other stylesheets or published to the cache have to stay
// immutable, so parse into fresh contents instead of replacing the rules in place.
auto contentsForReplacement = [&] -> Ref<StyleSheetContents> {
    if (m_contents->hasOneClient() && !m_contents->isInMemoryCache()) {
        protect(m_contents)->clearRules();
        return m_contents;
    }
    Ref contents = StyleSheetContents::create(m_contents->parserContext());
    protect(m_contents)->unregisterClient(this);
    m_contents = contents.copyRef();
    contents->registerClient(this);
    contents->checkLoaded();
    return contents;
}();
contentsForReplacement->parseString(WTF::move(text));

Fix approach

The patch keeps the in-place edit only when it is provably safe — m_contents->hasOneClient() && !m_contents->isInMemoryCache() — in which case it clearRules() on m_contents as before. Otherwise it forks: it creates a brand-new StyleSheetContents from the same parser context, unregisters this sheet from the old shared contents, points m_contents at the fresh object, registers this sheet as its only client, and checkLoaded() it, so the new rules are parsed into a private object no other client sees. Only after parsing does it re-publish to StyleSheetContentsCache when isCacheable(). Semantically, a replaceSync() on a shared/cached sheet now performs copy-on-write instead of destructive in-place mutation, and the cache keeps handing out immutable snapshots.

Patch walkthrough

  • Source/WebCore/css/CSSStyleSheet.cpp — In the detached fast path of replaceSync(), the cache-miss branch no longer reuses m_contents unconditionally. It first retries the cache as a RefPtr, then chooses between clearing m_contents in place (only when it has a single client and is not in the in-memory cache) and forking a fresh StyleSheetContents, so shared or cached contents remain immutable. The new object is parsed and only then re-added to the cache if cacheable.

Background

CSSStyleSheet / constructable stylesheets — A CSSOM object created by script via new CSSStyleSheet(); replace()/replaceSync() set its rules from a string. It is ‘detached’ until adopted into a document/shadow root via adoptedStyleSheets.

StyleSheetContents — The ref-counted (Ref/RefPtr) backing store holding the parsed rule list for one or more CSSStyleSheet wrappers; multiple sheets can register as clients of one contents object.

StyleSheetContentsCache — A process-global singleton that memoizes parsed StyleSheetContents keyed by {text, parserContext} so identical stylesheets share one immutable parse result.

register/unregisterClient & hasOneClient() — StyleSheetContents tracks how many CSSStyleSheet wrappers reference it; hasOneClient() returning false means the object is shared and must not be mutated in place.

CSSOM rule wrappers — StyleRuleCSSOMWrapper objects expose individual parsed rules to script; they hold pointers into a StyleSheetContents rule list and can dangle if that list is rebuilt out from under a sheet that still owns wrappers.

Vulnerability window

  1. Setup — Script constructs two CSSStyleSheet objects (or relies on the cache) so a single StyleSheetContents is shared by more than one client, or lets replaceSync() publish contents to StyleSheetContentsCache.
  2. Trigger — Script calls replaceSync(newText) on a still-detached sheet while m_childRuleCSSOMWrappers is empty, taking the fast cache path.
  3. Corruption — Pre-patch, the cache-miss branch clears and re-parses the shared/cached StyleSheetContents in place, replacing the rule list other clients and the cache rely on.
  4. Observation — Other CSSStyleSheet clients (or a subsequent cache hit) read the mutated rule list; CSSOM wrappers on those clients may reference dropped StyleRule objects (inferred).

Proof of concept

No regression test ships in the diff. Conceptual trigger (web path): create a constructed sheet, call replaceSync() with text that gets published to the StyleSheetContentsCache, then create/obtain a second sheet that resolves to the same cached StyleSheetContents, and call replaceSync() again on the first detached sheet so the cache-miss branch rewrites the shared contents in place; inspect cssRules on the second sheet to observe the corrupted/changed rule set. The precise reclaim of dropped StyleRule memory via co-client CSSOM wrappers is (inferred).

Exploitation

  1. Reach — Ordinary untrusted script in WebContent via CSSOM: new CSSStyleSheet() + replaceSync(); no special permissions needed.
  2. Trigger — Drive a cache-miss on a detached sheet whose m_contents is shared/cached so the in-place rewrite path runs.
  3. Primitive (primary) — Deterministic corruption of a StyleSheetContents rule list observed by unrelated sheets and future cache consumers — a cross-object state-confusion primitive.
  4. Primitive (secondary, gating) — Whether dropped StyleRule objects can be reclaimed and reused through a co-client’s stale StyleRuleCSSOMWrapper to reach a real UAF, and whether reclaimed bytes are attacker-controlled, is the open question that gates escalation beyond corruption (inferred).

Detection & hunting

For defenders and detection engineers:

  • replaceSync() mutating shared contents — Audit/log CSSStyleSheet::replaceSync() cache-miss paths where m_contents->hasOneClient() is false or isInMemoryCache() is true and rules are cleared in place.
  • Cross-sheet rule-set divergence — In tests, assert that two sheets sharing a cached StyleSheetContents never see each other’s rule mutations after a replaceSync() on one.
  • Dangling CSSOM rule wrappers — ASan/debug builds flagging StyleRule access after a StyleSheetContents rule rebuild across clients.

Remaining risk in the patch

  • In-place branch still edits m_contents — When m_contents->hasOneClient() && !isInMemoryCache() the patch still clears rules in place; correctness now rests entirely on those two predicates being accurate at this instant — if a client can be added between the check and clearRules() the window reopens (not shown closed in the diff).
  • checkLoaded() / parserContext semantics — The forked contents inherits m_contents->parserContext() and calls checkLoaded(); the supplied diff does not show these bodies, so any load-state or base-URL difference between the shared and forked object is unverified.

Audit directions

  • Other StyleSheetContents mutators — Review every call site that clears/re-parses or inserts/deletes rules on StyleSheetContents (insertRule/deleteRule paths, CSSStyleSheet::replace async variant) for the same shared/isInMemoryCache guard.
  • Cache publication — Check all StyleSheetContentsCache::add() callers to ensure only immutable, single-client contents are published and never later mutated in place.
  • CSSOM wrapper lifetime — Look for CSSOM wrapper collections (m_childRuleCSSOMWrappers and analogues) that assume the underlying rule list is stable for the life of the wrapper.

Before / after

Loading diff…