← All reports

[2] JSString use-after-free via GCOwnedDataScope and atomization swap

HighJSC HeapUAF

A scope guard that pinned the string cell but not the bytes you were reading

e69c479

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::swapToAtomString replaces a StringImpl with its atomized equivalent, the old StringImpl was kept alive only until the next GC via Heap::m_possiblyAccessedStringsFromConcurrentThreads. However if a GCOwnedDataScope is on the stack it's possible for the buffer to get freed before the ~GCOwnedDataScope runs, leaving the buffer as a dangling pointer.

Fix this by:

  1. Renaming m_possiblyAccessedStringsFromConcurrentThreads to m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope and storing (JSString*, String) pairs so we can track ownership.
  2. During conservative root scanning, discover all JSStrings that are still referenced on the stack and record them in m_discoveredAccessedStringsFromGCOwnedDataScope.
  3. At GC finalize, pruning entries whose JSString was not discovered on the stack rather than clearing the list entirely.
  4. Between GCs, clearing the retained list in IncrementalSweeper when 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.
  5. Switching from Vector to SegmentedVector with 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

ALWAYS_INLINE void JSString::swapToAtomString(VM& vm, RefPtr<AtomStringImpl>&& atom) const
{
- // We replace currently held string with new AtomString. But the old string can be accessed from concurrent compilers and GC threads at any time.
- // So, we keep the old string alive by appending it to Heap::m_possiblyAccessedStringsFromConcurrentThreads. And GC clears that list when GC finishes.
+ // When we swap a JSString's value to an AtomString, the old StringImpl can still be accessed
+ // by concurrent JIT compiler threads, GC threads, or via GCOwnedDataScope references on the stack.
+ // We keep the old string alive by appending it (paired with its owning JSString*) to
+ // Heap::m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope.
String target(WTF::move(atom));
WTF::storeStoreFence();
valueInternal().swap(target);
- vm.heap.appendPossiblyAccessedStringFromConcurrentThreads(WTF::move(target));
+ vm.heap.appendPossiblyAccessedStringFromConcurrentThreadsOrGCOwnedDataScope(this, WTF::move(target));
}

Source/JavaScriptCore/heap/Heap.cpp

- m_possiblyAccessedStringsFromConcurrentThreads.clear();
+ m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope.removeAllMatching([&](const auto& iter) {
+ return !m_discoveredAccessedStringsFromGCOwnedDataScope.contains(iter.first);
+ });
+ m_discoveredAccessedStringsFromGCOwnedDataScope.clear();

Source/JavaScriptCore/heap/ConservativeRoots.cpp

- return isLive && !mayHaveIndexingHeader(cellKind);
+ if (isLive && !mayHaveIndexingHeader(cellKind)) {
+ static_assert(!JSString::numberOfLowerTierPreciseCells && !JSRopeString::numberOfLowerTierPreciseCells, ...);
+ if (auto* string = dynamicDowncast<const JSString>(std::bit_cast<const JSCell*>(pointer)))
+ m_heap.m_discoveredAccessedStringsFromGCOwnedDataScope.add(string);
+ return true;
+ }
+ return false;

JSTests/stress/stringProtoFuncAt-GCOwnedDataScope-atomstring-swap.js

+const target = "A".repeat(128);
+const dummy = {};
+Reflect.set(dummy, target, 1);
+const freshRope = "A".repeat(128);
+let nonAtom = "D".repeat(64) + "D".repeat(64);
+String.prototype.at.call(nonAtom, 0);
+let thatObj = {
+ [Symbol.toPrimitive]() {
+ // Trigger atomization via Reflect.set to avoid Inline Cache holding the string
+ Reflect.set(dummy, freshRope, 1);
+ // Overwrite the VM's lastAtomizedIdentifierStringImpl cache
+ Reflect.set(dummy, nonAtom, 1);
+ gc();
+ return 0;
+ }
+};
+// Verify that GCOwnedDataScope/Heap keeps the StringImpl alive across the callback
+String.prototype.at.call(freshRope, thatObj);

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.

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.

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:

  1. const target = "A".repeat(128); Reflect.set(dummy, target, 1) inserts an AtomStringImpl with 128 'A's into the atom table, so an equal atom already exists.
  2. const freshRope = "A".repeat(128) creates a second JSString with its own, non-atom StringImpl of identical content.
  3. String.prototype.at.call(freshRope, thatObj) enters the native function, which first materialises the receiver's characters — obtaining a GCOwnedDataScope whose payload views that non-atom impl's buffer — and only then coerces the index argument, invoking thatObj[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.
  4. Inside the callback, Reflect.set(dummy, freshRope, 1) uses the string as a property key; the lookup finds the pre-existing atom, so swapToAtomString installs the AtomStringImpl and moves the sole remaining reference to the original impl into the retain list. The test's own comment notes Reflect.set is chosen specifically so that no inline cache retains the string.
  5. Reflect.set(dummy, nonAtom, 1) displaces what the test annotates as the VM's lastAtomizedIdentifierStringImpl cache, removing the other reference that would otherwise keep the original impl alive.
  6. gc() runs a collection; pre-fix, finalize's .clear() drops the last reference and the impl plus its 128-character buffer is freed.
  7. The callback returns; the GCOwnedDataScope in 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.