← WebKit — 2026-W36

9849c73d66ed2e811efe39ed0bfe12383e35fbc6  GC-thread use-after-free in root(CSSRule*): the parent-pointer walk races rule teardown

severity high class Race confidence 0.7 signal score 7 Source/WebCore/css patch-now AI review CONFIRMED
Primitive: GC thread reads CSSRule parent union (m_parentRule/m_parentStyleSheet + m_parentIsRule) while main thread mutates it -> torn/stale pointer
Triage note: Adds m_opaqueRootLockForGC taken in JSCSSRuleCustom root(), setParentRule/setParentStyleSheet and CSSStyleSheet::opaqueRootForGCThread, and clears child wrapper parents in ~CSSStyleRule. Serializes the GC-thread opaque-root walk against main-thread parent-pointer mutation on a type-punned union, fixing a data race that could feed GC a stale/wrong opaque root (UAF/type confusion).
Structural signals: null_guard (+1.2), msg_strong (+3.0), msg_medium (+2.0), msg_noise (-2.5), area:Source/WebCore/css (+2.0), surgical (+1.3)

The bug at a glance

A data race in WebCore CSS bindings (Source/WebCore/bindings/js/JSCSSRuleCustom.h, css/CSSRule.{h,cpp}, CSSStyleRule.cpp, CSSStyleSheet.cpp, WebContent process): the JSC GC thread computes a CSSRule’s opaque root by reading rule->parentRule()/parentStyleSheet() — a type-punned union discriminated by the m_parentIsRule bitfield — while the main thread mutates both via setParentRule/setParentStyleSheet with no synchronization, yielding a torn or type-confused parent pointer fed to GC as the opaque root. The fix adds a per-rule Lock (m_opaqueRootLockForGC) taken by the GC-thread reader and the mutators, and clears child wrapper parents in ~CSSStyleRule. The diff proves a genuine concurrent-access/type-confusion read; the escalation to a GC-visibility UAF/type confusion is the standard consequence of a wrong opaque root but is not itself executed in the diff (inferred). Severity high: reachable from untrusted JS via CSSOM mutation racing GC.

The parent pointer and its discriminant live in a union plus a one-bit flag written non-atomically on the main thread, but read concurrently by the GC thread during opaque-root marking — so the GC can read m_parentIsRule=rule yet see a CSSStyleSheet* in the union (or vice versa) and recurse into the wrong type.

Root cause

JSC’s garbage collector computes an opaque root for each CSSOM wrapper so that an entire stylesheet/rule tree is kept alive or collected as a unit. For a CSSRule, root(CSSRule*) in JSCSSRuleCustom.h walks up: if parentRule() return root(parentRule()); else if parentStyleSheet() return root(parentStyleSheet()); else the rule itself. The parent is stored in a union { CSSRule* m_parentRule; CSSStyleSheet* m_parentStyleSheet; } discriminated by the m_parentIsRule bitfield, and this opaque-root walk runs on the GC thread, concurrently with main-thread CSSOM mutation.

Pre-patch sequence: 1. the GC thread enters root(CSSRule*) and reads the parent union / m_parentIsRule with no lock. 2. The main thread, servicing JS (e.g. insertRule/deleteRule/reparenting), calls setParentRule or setParentStyleSheet, which set m_parentIsRule and then the union pointer in two separate non-atomic stores. 3. The GC thread observes a torn state — e.g. m_parentIsRule already flipped to “rule” but the union still holds the old CSSStyleSheet* (or a half-written pointer) — and 4. treats a CSSStyleSheet* as a CSSRule* (or follows a stale/freed pointer), recursing into root() on the wrong type and handing GC a wrong opaque root.

The fix introduces a mutable Lock m_opaqueRootLockForGC on CSSRule (bloating SameSizeAsCSSRule accordingly). root(CSSRule*) takes Locker { rule->opaqueRootLockForGC() } before reading the parent (and reads it into SUPPRESS_UNCOUNTED_LOCAL raw pointers), setParentRule/setParentStyleSheet take the same lock around the flag+pointer update, and CSSStyleSheet::opaqueRootForGCThread takes the owner rule’s lock before reading ownerRule->parentStyleSheet(). ~CSSStyleRule additionally walks m_childRuleCSSOMWrappers and calls setParentRule(nullptr) on each, so a destroyed parent rule does not leave child wrappers pointing at freed memory for a later opaque-root walk.

Key code

GC-thread opaque-root read and the parent-pointer mutators now share a per-rule lock

// JSCSSRuleCustom.h root(CSSRule*)
Locker locker { rule->opaqueRootLockForGC() };
if (SUPPRESS_UNCOUNTED_LOCAL auto* parentRule = rule->parentRule())
    return root(parentRule);
if (SUPPRESS_UNCOUNTED_LOCAL auto* parentStyleSheet = rule->parentStyleSheet())
    return root(parentStyleSheet);
return WebCoreOpaqueRoot { rule };

// CSSRule::setParentRule / setParentStyleSheet
Locker locker { m_opaqueRootLockForGC };
m_parentIsRule = true;  m_parentRule = rule;

Fix approach

A per-CSSRule mutable Lock serializes the GC-thread opaque-root walk against main-thread parent mutation of the type-punned union. root(CSSRule*) and CSSStyleSheet::opaqueRootForGCThread take the lock before reading the parent/discriminant; setParentRule and setParentStyleSheet take it around the paired bitfield+pointer stores, so the GC thread never sees a half-updated union or a flag that disagrees with the pointer. The raw parent pointers read under the lock are annotated SUPPRESS_UNCOUNTED_LOCAL (they are borrowed only inside the locked region). Separately, ~CSSStyleRule now nulls the parent of every child CSSOM wrapper (and already did so for its properties wrapper), closing a related dangling-parent path where a child wrapper outliving its parent rule could feed a freed pointer into the opaque-root walk. The hot GC path thus collapses to a single short critical section per rule level instead of an unsynchronized union read.

Patch walkthrough

  • Source/WebCore/bindings/js/JSCSSRuleCustom.h — root(CSSRule*) takes Locker { rule->opaqueRootLockForGC() } and reads parentRule()/parentStyleSheet() into SUPPRESS_UNCOUNTED_LOCAL locals before recursing, so the parent union is read atomically w.r.t. mutation.
  • Source/WebCore/css/CSSRule.cpp — SameSizeAsCSSRule gains a Lock member to account for the new m_opaqueRootLockForGC in the layout-size assertion.
  • Source/WebCore/css/CSSRule.h — Adds mutable Lock m_opaqueRootLockForGC and the opaqueRootLockForGC() accessor (includes wtf/Lock.h); setParentStyleSheet and setParentRule take a Locker around the m_parentIsRule flag and union-pointer store.
  • Source/WebCore/css/CSSStyleRule.cpp — ~CSSStyleRule iterates m_childRuleCSSOMWrappers and calls setParentRule(nullptr) on each live child wrapper so no child points at the freed parent rule during a later GC walk.
  • Source/WebCore/css/CSSStyleSheet.cpp — opaqueRootForGCThread takes Locker { ownerRule->opaqueRootLockForGC() } before reading ownerRule->parentStyleSheet(), extending the same serialization to the import-rule owner chain.

Background

Opaque root (JSC GC) — A representative object the GC associates with a wrapper so a whole related graph (here the stylesheet/rule tree) is marked live or dead together; a wrong opaque root can let GC collect a still-reachable wrapper or confuse reachability.

GC thread vs main thread — JSC marks on a separate thread while the main thread keeps running script; any DOM/CSSOM state the marker reads (like a rule’s parent) must be synchronized against main-thread mutation.

Type-punned union + discriminant — CSSRule stores m_parentRule/m_parentStyleSheet in a union selected by the m_parentIsRule bit; writing the bit and the pointer non-atomically lets a concurrent reader pair the wrong type with the pointer — a type confusion.

SUPPRESS_UNCOUNTED_LOCAL — A WebKit annotation marking a deliberately raw (uncounted) local reference; here it flags the parent pointers read inside the lock, which are only used within the critical section.

CSSOM wrapper lifetime — JS-visible CSSRule/CSSStyleSheet wrappers reference each other via parent pointers; a parent rule’s destruction must clear children’s parent pointers (the ~CSSStyleRule change) to avoid dangling references the GC walk would follow.

Vulnerability window

  1. Setup — A page builds a stylesheet with nested rules (e.g. grouping/@media rules) so CSSRule parent pointers form a tree with live CSSOM wrappers.
  2. Churn — Script mutates the tree (insertRule/deleteRule/reparenting), repeatedly calling setParentRule/setParentStyleSheet on the main thread.
  3. Race — A GC marking pass runs root(CSSRule*) on the GC thread concurrently with a mutator, reading the parent union mid-update.
  4. Corruption — The GC reads a torn/type-confused parent and computes a wrong opaque root, so reachability is mismarked (potential premature collection or type-confused recursion).

Proof of concept

No test ships in the diff. Conceptual trigger (inferred web path): from JS, build deeply nested CSS rules with live CSSOM wrappers, then in a tight loop reparent rules / insert and delete grouping rules (driving setParentRule/setParentStyleSheet) while allocating to provoke frequent GC, so a marking pass reads the parent union mid-mutation. Observing a crash/mismark requires winning the GC-thread-vs-main-thread race, which is timing-dependent.

Exploitation

  1. Reach — Unprivileged JS in the WebContent process can create and mutate CSSOM rule trees and trigger GC through allocation pressure.
  2. Trigger — Race CSSOM parent mutation against the GC opaque-root walk so the marker reads a torn union / stale pointer.
  3. Primary primitive — GC is fed a wrong opaque root (type-confused recursion into root() on the wrong class, or a stale/freed pointer), i.e. a reachability mismark.
  4. Secondary — A mismarked opaque root can let the collector free a wrapper still reachable from JS (UAF) or follow a CSSStyleSheet* as a CSSRule* (type confusion) — the classic consequences of a corrupted opaque root.
  5. Open question — Escalation past a crash hinges on steering the mismark to free a specific attacker-chosen object and reclaiming it, and on the race being wide/repeatable enough — not shown by the diff (inferred).

Detection & hunting

For defenders and detection engineers:

  • Unlocked parent-union access on GC thread —
  • Opaque-root type mismatch —
  • Child wrapper with freed parent —

Remaining risk in the patch

  • Per-rule lock memory/perf cost and coverage — A Lock per CSSRule is taken on every GC opaque-root walk and every reparent; correctness depends on every reader and writer of the parent union taking it — any other code path that reads/writes m_parentRule/m_parentStyleSheet/m_parentIsRule without the lock re-opens the race, and the diff only shows the three entry points plus the import-owner chain.
  • Child-wrapper clearing scope — ~CSSStyleRule clears m_childRuleCSSOMWrappers parents, but other grouping rule types (e.g. @media/@supports CSSGroupingRule subclasses) with child wrappers would need the same destructor fix; the diff only shows CSSStyleRule, so analogous rule classes are unverified (inferred).

Audit directions

  • Other GC-thread DOM/CSSOM reads —
  • Grouping-rule destructors —
  • Union + discriminant patterns —

Before / after

Loading diff…