[2] JSString use-after-free via GCOwnedDataScope and atomization swap
A scope guard that pinned the string cell but not the bytes you were reading
High. Pure web content reaches this — the four regression tests are plain JavaScript — and the outcome is engine code reading freed heap and handing the bytes back as an observable JS value. What holds it below Critical is that the primitive is read-only: escalation to anything beyond disclosure needs a separate bug.
JSC hands native runtime code direct pointers into a string's character buffer, and it needs some way to guarantee those pointers stay valid while the native function runs. The mechanism is GCOwnedDataScope, an RAII helper that pins the owning garbage-collected cell on the stack so that conservative stack scanning finds it and the collector cannot reclaim it. Separately, when a string is used as a property key and an identical "atom" string already exists in the VM's interning table, the engine replaces the string cell's backing buffer in place with the shared atom. The contract GCOwnedDataScope advertises is that the data it hands you stays valid for the entire lexical scope.
The angle: script can make a native string function read a freed heap buffer and return its bytes as a JS string — a renderer-side disclosure primitive usable for defeating address-space layout randomization.
When
JSString::swapToAtomStringreplaces aStringImplwith its atomized equivalent, the oldStringImplwas kept alive only until the next GC viaHeap::m_possiblyAccessedStringsFromConcurrentThreads. However if aGCOwnedDataScopeis on the stack it's possible for the buffer to get freed before the~GCOwnedDataScoperuns, leaving the buffer as a dangling pointer.Fix this by:
- Renaming
m_possiblyAccessedStringsFromConcurrentThreadstom_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScopeand storing(JSString*, String)pairs so we can track ownership.- During conservative root scanning, discover all
JSStrings that are still referenced on the stack and record them inm_discoveredAccessedStringsFromGCOwnedDataScope.- At GC finalize, pruning entries whose
JSStringwas not discovered on the stack rather than clearing the list entirely.- Between GCs, clearing the retained list in
IncrementalSweeperwhen no JS is executing and no JIT compilations are in progress. Previously the list was only cleared during GC finalize, so it could grow unboundedly between collections. Without this Speedometer appeared to be regressed, with this it seems like a .2% progression.- Switching from
VectortoSegmentedVectorwith a new doubling growth policy to avoid copying entries when resizing. Since this list gets very big, 200,000+ entries, avoiding copies is valuable.
Source/JavaScriptCore/runtime/JSString.h
Source/JavaScriptCore/heap/Heap.cpp
Source/JavaScriptCore/heap/ConservativeRoots.cpp
JSTests/stress/stringProtoFuncAt-GCOwnedDataScope-atomstring-swap.js
Patch Details
The change reworks the retain list's identity and lifetime rules, extends conservative stack scanning to feed reclamation decisions, and adds a between-GC drain path plus the container support it needs.
On the retain list itself: Heap::m_possiblyAccessedStringsFromConcurrentThreads (a Vector<String>) is renamed to m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope and retyped as a SegmentedVector<std::pair<const JSString*, String>, 256, 10, SegmentedVectorGrowthPolicy::Doubling>, so each retained old String is paired with the JSString* that used to own it. appendPossiblyAccessedStringFromConcurrentThreads(String&&) becomes appendPossiblyAccessedStringFromConcurrentThreadsOrGCOwnedDataScope(const JSString* owner, String&&), and JSString::swapToAtomString passes this.
On the scanning side, ConservativeRoots::genericAddPointer's tryPointer lambda is restructured: where it previously returned isLive && !mayHaveIndexingHeader(cellKind), it now, inside that branch, dynamicDowncasts the found cell to const JSString and inserts it into the new Heap::m_discoveredAccessedStringsFromGCOwnedDataScope hash set before returning true, guarded by a static_assert that JSString/JSRopeString have no lower-tier precise cells. Heap::finalize correspondingly stops calling .clear() on the retain list and instead calls removeAllMatching to drop only entries whose paired JSString* was not discovered on the stack, then clears the discovered set.
The remaining cluster is drain and container plumbing. A new Heap::clearConcurrentRetainedDataIfPossible() is called from IncrementalSweeper::doSweep; it bails if vm().entryScope is set, asserts !m_topGCOwnedDataScope, and clears the whole list only when JITWorklist::totalOngoingCompilations() is zero (a new public overload). Debug-only bookkeeping tracks Heap::m_topGCOwnedDataScope via new out-of-line setTopGCOwnedDataScopeIfNeeded/clearTopGCOwnedDataScopeIfNeeded in a new GCOwnedDataScope.cpp. And WTF::SegmentedVector gains a SegmentedVectorGrowthPolicy template parameter (Constant/Doubling), a segmentLocationFor() clz-based index decomposition, sizeOfSegment(), addressAt-based grow/resize, and a new removeAllMatching(). Four JSTests regression tests cover String.prototype.at, startsWith, endsWith, and localeCompare.
Deferred reclamation keyed to only one class of reader — a replaced buffer released at the end of a collection cycle while stack-held views derived from it outlive that cycle.
Background
Strings in WTF.
StringImpl is the refcounted object holding a string's length, flags, and character buffer; String is a RefPtr<StringImpl> wrapper, and StringView is a non-owning pointer+length pair over an impl's buffer.
Atomization.
JSC keeps a per-thread table of unique ("atom") strings so that identifiers and property keys can be compared by pointer; converting a string to a property key looks up or inserts into that table. JSString is the GC cell wrapping a string value, its internal fiber either a resolved StringImpl pointer or a rope. When a JSString is used as a property key and an equal atom already exists, JSString::swapToAtomString replaces the cell's fiber in place with the existing AtomStringImpl so future key operations are pointer-fast; the previously held impl is handed to the Heap for delayed release, because concurrent JIT and GC threads may still be reading the fiber they loaded earlier.
GCOwnedDataScope<T>.
An RAII struct carrying {const JSCell* owner, T data} whose destructor calls ensureStillAliveHere(owner), forcing the compiler to keep the owning cell referenced on the stack for the scope's lifetime so conservative scanning finds it. It exists because StringPrototype functions need raw character access without paying a refcount bump per access.
Conservative stack scanning.
At the start of a stop-the-world collection, JSC walks machine stacks and registers, and for each word that looks like a pointer into a MarkedBlock or PreciseAllocation it checks liveness and treats the cell as a root. This is what makes ensureStillAliveHere sufficient.
Heap::finalize.
The end-of-collection phase where per-cycle caches — stringSplitCache, jsonAtomStringCache, immutableButterflyToStringCache, and until this patch the possibly-accessed-strings list — are cleared.
Between-collection machinery.
IncrementalSweeper is a timer-driven task that sweeps marked blocks incrementally between collections. VM::entryScope is non-null while JavaScript execution is in progress in the VM, and JITWorklist::totalOngoingCompilations() is the count of compilations currently running on background JIT threads — the two signals the new drain path uses as a stand-in for "no reader is holding a view."
SegmentedVector.
A vector storing elements in fixed-size heap segments so element addresses stay stable across growth; this commit adds a Doubling policy where each successive segment is twice the previous size.
Analysis
The root cause is two liveness models that do not compose. GCOwnedDataScope pins the owning cell; the retain list frees on a collection-cycle boundary. Neither one covers a buffer that the cell no longer references but a stack frame still views.
Native frame (StringPrototype) JS callback (Symbol.toPrimitive)
────────────────────────────── ────────────────────────────────
scope = view over impl_A ──┐
(pins JSString S, not impl_A)
│ Reflect.set(dummy, S, 1)
│ S.fiber: impl_A -> atom_A
│ retain list <- impl_A
│ gc()
│ finalize(): list.clear()
│ free(impl_A) <-- last ref gone
read scope.data[i] ◄──────┘ ← UAF read of freed buffer
swapToAtomString moves the last strong reference to the old StringImpl into the retain list, which existed solely to cover concurrent JIT and GC threads. Its retention window was therefore defined as "until the end of the next GC," and Heap::finalize unconditionally cleared it. Keeping the owning JSString alive says nothing about the old impl, because after the swap the JSString no longer references it. The missing input was never a lock or a check — it was that reclamation of the displaced buffer was keyed only to collector-thread liveness, never to whether any stack frame still held a view derived from it. The fix supplies that input by recording every JSString found on the stack during conservative scanning and pruning only entries whose owning JSString was not seen.
The four regression tests are pure JavaScript with no special flags beyond gc(), which script can approximate by allocation pressure. Walking stringProtoFuncAt:
const target = "A".repeat(128); Reflect.set(dummy, target, 1)inserts anAtomStringImplwith 128'A's into the atom table, so an equal atom already exists.const freshRope = "A".repeat(128)creates a secondJSStringwith its own, non-atomStringImplof identical content.String.prototype.at.call(freshRope, thatObj)enters the native function, which first materialises the receiver's characters — obtaining aGCOwnedDataScopewhose payload views that non-atom impl's buffer — and only then coerces the index argument, invokingthatObj[Symbol.toPrimitive]. That ordering is what all four regression tests exercise and what the fix is shaped around; it is the sequence, not the individual calls, that the bug depends on.- Inside the callback,
Reflect.set(dummy, freshRope, 1)uses the string as a property key; the lookup finds the pre-existing atom, soswapToAtomStringinstalls theAtomStringImpland moves the sole remaining reference to the original impl into the retain list. The test's own comment notesReflect.setis chosen specifically so that no inline cache retains the string. Reflect.set(dummy, nonAtom, 1)displaces what the test annotates as the VM'slastAtomizedIdentifierStringImplcache, removing the other reference that would otherwise keep the original impl alive.gc()runs a collection; pre-fix,finalize's.clear()drops the last reference and the impl plus its 128-character buffer is freed.- The callback returns; the
GCOwnedDataScopein the native frame is still live and its view still addresses the freed buffer, which the function then indexes.
Escalation past the stale read depends on reclamation, and every step is conditional. If the attacker sizes the victim string so its impl lands in a fastMalloc size class they can refill, and allocates replacement objects inside the callback after gc() returns but before the native read, then the read would land on attacker-chosen or attacker-adjacent heap contents. Because String.prototype.at returns the indexed code unit as a JS string, and the index is bounded only by the length captured before the callback, that could provide a bounded relative disclosure of the reclaimed allocation's bytes — including any pointer values occupying it, which would be usable for heap-address and binary-base inference. The startsWith/endsWith/localeCompare variants are weaker but still useful: they would expose the same memory as a comparison oracle rather than as direct characters. No write primitive follows from this shape — the retained String is only ever read through the scope's view.
Discovery looks like targeted pattern auditing of the GCOwnedDataScope contract rather than blind fuzzing. The four tests are near-identical templates applied to at, startsWith, endsWith, and localeCompare — the signature of someone who identified the shape (obtain a cell-owned view, then coerce an argument through user JS) and enumerated the StringPrototype functions matching it. The tests also encode non-obvious engine internals — choosing Reflect.set "to avoid Inline Cache holding the string", and a second Reflect.set purely to displace the last-atomized-identifier cache — which a fuzzer does not synthesise.
This vulnerability weakens memory safety inside the WebContent process. The security-model assumption at stake is that data handed to native code under a GCOwnedDataScope stays valid for the entire lexical scope — exactly the guarantee the class exists to provide, and one every StringPrototype consumer of JSString::value/view relies on. Before the fix, script could make that guarantee fail for buffers displaced by atomization, so a purely JavaScript attacker could get engine code to read a freed heap allocation and hand the bytes back as an observable JS value or comparison result. In practice that amounts to a heap-content disclosure primitive usable for defeating ASLR and staging a later corruption bug; it does not by itself cross the sandbox boundary.
Insight: the fix's second half is a heuristic, not a proof. Heap::clearConcurrentRetainedDataIfPossible decides no GCOwnedDataScope can be live by testing vm().entryScope and the JIT worklist count, while the actual invariant ASSERT(!m_topGCOwnedDataScope) is compiled out of release builds — and the in-tree FIXME already admits WebCore testing/debugger code drives the runloop mid-JS-stack, which is why the entryScope bail exists at all. The commit also widens ConservativeRoots's job: a scan that previously only produced marking roots now also produces a reclamation-decision set, so any GC path that prunes the list must be guaranteed to have run a full stack scan first.
Audit directions
-
Objects whose last reference lives in a container emptied on a fixed schedule while unrelated code holds derived raw views of them. The invariant is a deferred-reclamation scheme must enumerate every category of reader, not just the category it was designed for. Narrow: read
Heap::finalizeand audit the other per-cycle clears sitting immediately next to the changed line —vm().stringSplitCache.clear(),vm().jsonAtomStringCache.clearJSStrings(),immutableButterflyToStringCache.clear()— asking for each whether the container holds the lastRef/RefPtrto a buffer that a stack frame can still be viewing. The tell is aclear()on a container of owning references where the owned object is also reachable through a non-owning view. Wider: the same class appears in any "keep alive until X" list built for one reader and later inherited by a second — grep for other JSC members namedpossiblyAccessed*,pending*Release, ordeferred*and check their clearing conditions against all present consumers. Widest: deferred reclamation with unenumerated readers, carrying to Chromium's Oilpan pre-finalizers, V8'sKeepDuringJobset, Linux RCU grace periods, and Rust's crossbeam-epoch — whenever a scheme frees on "nobody I know about is reading", ask who reads without registering. -
An interior pointer handed out under a guard that pins the container rather than the buffer version, then held across a re-entrancy point. The invariant is pinning an object does not pin storage the object can swap out in place. Narrow: audit
StringPrototype.cpp/StringPrototypeInlines.hfor the shape the four new tests target — aGCOwnedDataScopeobtained fromJSString::value/viewon the receiver, followed by argument coercion (toPrimitive,toString,toIntegerOrInfinityon an object) or an ICU call, followed by a use of the earlier view. In code review, the tell isauto x = ...->value(globalObject)or->view(globalObject)with any call that can enter user JS between that line and the last use ofx. Wider: enumerate everyGCOwnedDataScope<T>instantiation in JSC and, for each, identify which in-place mutations the owner cell supports — the shape to notice is a scope-guard type whose destructor touches only the owner while itsdatamember is an unowned pointer or view. Widest: any runtime exposing interior pointers into relocatable or replaceable storage must version the storage, not just the handle — carry the question to JNI'sGetStringCritical/GetPrimitiveArrayCritical, CPython'sPy_buffer, and V8'sString::GetFlatContentwith itsDisallowGarbageCollectionscope; the tell is an API returning a pointer plus a guard whose documented contract is about collection rather than about mutation. -
A reclamation safety condition enforced by a debug-only assertion and approximated by a heuristic proxy in release builds. The invariant is if release-build memory safety depends on a condition, the condition must be checked in release builds or provably implied by something that is. Investigate
Heap::clearConcurrentRetainedDataIfPossible: the real precondition isASSERT(!m_topGCOwnedDataScope), compiled out underNDEBUG, while release safety rests onvm().entryScopebeing null plusJITWorklist::totalOngoingCompilations() == 0. Trace every caller path intoIncrementalSweeper::doSweepand look for a way to reach it with a liveGCOwnedDataScopeand a nullentryScope— the in-tree FIXME names WebCore testing/debugger code that drives the runloop from inside a JS stack as the known-shaky case. Also confirm no GC configuration prunes the retain list without having first populatedm_discoveredAccessedStringsFromGCOwnedDataScopefrom a conservative stack scan (check eden vs. full collections and theVerifierSlotVisitorpath); the tell is any call intofinalize-side pruning not dominated by aConservativeRootsscan underworldIsStopped(). Wider: audit other counter-gated or flag-gated release-time frees in JSC's heap and JIT worklists where "no work in flight" stands in for "no reader holds a pointer". Widest: applies to any codebase where anassert-guarded contract backs a release-build free — kernel refcount debug checks, Rustdebug_assert!-guarded unsafe invariants. -
New index arithmetic introduced into a shared container type, where an off-by-one in the segment/offset decomposition silently changes which memory an element access reaches. Examine the new
SegmentedVectorGrowthPolicy::Doublingpaths inSource/WTF/wtf/SegmentedVector.h—segmentLocationFor(clz-basedsegmentIndex/offsetsplit),sizeOfSegment, the rewrittenensureSegmentsFor(nowsegmentLocationFor(size - 1).segmentIndex + 1, which needssize > 0),allocateSegmentsizing bym_segments.size(), and the newremoveAllMatchingcompaction loop — against the inline-capacity variant, sinceaddressAtnow subtractsInlineCapacitybefore decomposing. Verify by property-testing the index → segment → address mapping for boundary indices around each doubling step rather than by inspection; the tell for a bug is any index whose computedsegmentIndexreaches a segment allocated with a smallersizeOfSegmentthan the offset requires. Wider: whenever a container gains a second growth or layout policy behind a template parameter, ask whether every helper that assumed the old layout (entries(), capacity math, iterators,shrinkToFit) was updated — grep for remainingSegmentSize-modulo arithmetic in the same header. Widest: a layout assumption duplicated across helpers and updated in only some of them, recurring in slab and arena allocators, chunked buffers, and rope/piece-table implementations — a layout policy must have exactly one decomposition function that every accessor calls.