9849c73d66ed2e811efe39ed0bfe12383e35fbc6 GC-thread use-after-free in root(CSSRule*): the parent-pointer walk races rule teardown
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).
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
- 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.
- Churn — Script mutates the tree (insertRule/deleteRule/reparenting), repeatedly calling setParentRule/setParentStyleSheet on the main thread.
- Race — A GC marking pass runs root(CSSRule*) on the GC thread concurrently with a mutator, reading the parent union mid-update.
- 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
- Reach — Unprivileged JS in the WebContent process can create and mutate CSSOM rule trees and trigger GC through allocation pressure.
- Trigger — Race CSSOM parent mutation against the GC opaque-root walk so the marker reads a torn union / stale pointer.
- 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.
- 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.
- 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 —