← All issues

[3] JSC heap UAF in swapToAtomString across GCOwnedDataScope

Severity: High | Component: JSC heap / string atomization | e69c479

Rated High because the diff and committed tests show that a swapped-out StringImpl referenced through a stack-resident GCOwnedDataScope could be freed at GC finalize and then dereferenced by an in-flight String.prototype.* builtin; the trigger sequence (overridden Symbol.toPrimitive calling gc() mid-call) is fully reachable from web JavaScript and produces a UAF read primitive over an attacker-shaped string buffer.

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 is possible for the buffer to get freed before the ~GCOwnedDataScope runs, leaving the buffer as a dangling pointer.

Source/JavaScriptCore/runtime/JSString.h

ALWAYS_INLINE void JSString::swapToAtomString(VM& vm, RefPtr<AtomStringImpl>&& atom) const
{
ASSERT(!isCompilationThread() && !Thread::mayBeGCThread());
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/ConservativeRoots.cpp

auto tryPointer = [&] (void* pointer) {
bool isLive = candidate->handle().isLiveCell(...);
if (isLive) markFoundGCPointer(pointer, cellKind);
- 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;
};

Source/JavaScriptCore/heap/Heap.cpp

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

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

+const freshRope = "A".repeat(128);
+let nonAtom = "D".repeat(64) + "D".repeat(64);
+String.prototype.at.call(nonAtom, 0);
+let thatObj = { [Symbol.toPrimitive]() {
+ Reflect.set(dummy, freshRope, 1); // atomize freshRope -> swapToAtomString
+ Reflect.set(dummy, nonAtom, 1); // evict lastAtomizedIdentifierStringImpl cache
+ gc(); // finalize used to drop the old StringImpl
+ return 0;
+}};
+String.prototype.at.call(freshRope, thatObj);

The patch renames Heap::m_possiblyAccessedStringsFromConcurrentThreads to m_possiblyAccessedStringsFromConcurrentThreadsOrGCOwnedDataScope and changes its element type from String to (JSString*, String) pairs so the GC can correlate a retained StringImpl to its owning cell. In ConservativeRoots::genericAddPointer, when a live JSString is identified in a MarkedBlock, it is inserted into a new m_discoveredAccessedStringsFromGCOwnedDataScope hash set. In Heap::finalize, the retention list is no longer cleared wholesale — removeAllMatching keeps any entry whose owning JSString was discovered on the stack and only drops the rest. A new Heap::clearConcurrentRetainedDataIfPossible is invoked from IncrementalSweeper::doSweep to drain the list between GCs when !vm().entryScope and JITWorklist::totalOngoingCompilations() is zero. An assert-only m_topGCOwnedDataScope tracker is added on Heap, set and cleared from GCOwnedDataScope's ctor/dtor. The retention list is moved from Vector to SegmentedVector with a new Doubling growth policy and a removeAllMatching overload to avoid copy-resize on a list that grows to 200k+ entries. Four JSTests/stress/ files exercise the bug via String.prototype.{at,endsWith,startsWith,localeCompare} with a custom Symbol.toPrimitive/toString that atomizes the receiver and forces gc() mid-call.

Lifetime mismatch between a stack-cached raw buffer pointer and a GC-finalize policy that only protects the owning JSCell, not the swapped-out backing store.

StringImpl is WTF's refcounted backing for a String; its buffer is freed when the last reference is dropped. AtomStringImpl is a uniqued/interned StringImpl kept alive by the global atom table once any string with that content is atomized. JSString::swapToAtomString swaps the JSString's owned String with one backed by the AtomStringImpl; the previously-owned StringImpl would otherwise be dropped synchronously.

GCOwnedDataScope<T> is a stack-only RAII helper used as the return type of JSString::value / JSString::view and similar accessors. It stores a raw T (typically a String& or StringView) plus an owner JSCell pointer and calls ensureStillAliveHere(owner) in its destructor so the cell is treated as live for the C++ scope — it does NOT itself retain the underlying StringImpl.

Conservative root scanning runs at the start of every GC: JSC walks the C++ stack and treats any word that looks like a pointer into a MarkedBlock containing a live cell as a root. Heap::m_possiblyAccessedStringsFromConcurrentThreads is a side list onto which swapToAtomString pushes the swapped-out String so that concurrent JIT/GC threads holding raw pointers into the old StringImpl cannot observe it being freed mid-collection; the original policy cleared this list at the end of every GC finalize. IncrementalSweeper runs sweep work between collections on the main thread when JS is idle. JITWorklist::totalOngoingCompilations counts in-flight DFG/FTL compilations that may still hold raw StringImpl pointers captured before the swap.

The bug is a use-after-free of a StringImpl buffer across a GCOwnedDataScope. The original retention policy was sound for the list's original purpose — concurrent JIT/GC threads are stopped at GC end, so clearing then is safe. It is unsound for GCOwnedDataScope: a GCOwnedDataScope<StringView> caches a raw pointer/length into the StringImpl's buffer and only relies on ensureStillAliveHere(owner) in its destructor to keep the owning JSString live. While a GCOwnedDataScope is on the stack inside a String.prototype.* builtin, a re-entering JS callback (Symbol.toPrimitive/toString) can atomize the same JSString — replacing its StringImpl — and trigger a full GC. Pre-fix, finalize cleared the entire retention list at the end of that GC even though the on-stack scope still held a raw pointer into the buffer; the buffer is then unref'd and can be reused, and the next dereference through the GCOwnedDataScope reads from freed memory.

The committed test for String.prototype.at walks the exact trigger. (1) Pre-atomize "A".repeat(128) by setting it as a property name. (2) Build a fresh rope freshRope whose resolved content matches the atomized form. (3) Prime String.prototype.at once on a separate non-atom string to force JIT/IC familiarity. (4) Call String.prototype.at.call(freshRope, thatObj) where thatObj[Symbol.toPrimitive] runs Reflect.set(dummy, freshRope, 1) (atomizing freshRope via swapToAtomString and swapping in the existing AtomStringImpl), then Reflect.set(dummy, nonAtom, 1) to evict the VM's lastAtomizedIdentifierStringImpl cache so it no longer pins the swapped-out impl, then gc(). Inside stringProtoFuncAt, the builtin obtains a GCOwnedDataScope into freshRope's string buffer before calling the converter; the scope is still live on the stack across the callback. Finalize used to drop the last reference to the swapped-out StringImpl; the buffer pointer cached in the GCOwnedDataScope would then dereference freed memory after thatObj returned. Post-fix, the conservative stack scan tags freshRope as on-stack and finalize keeps its retention entry.

The immediate, reliably-reachable consequence is a UAF read of a StringImpl buffer of attacker-controlled length and content (the PoC uses 128 bytes; nothing in the mechanism caps this). An attacker who reclaims the freed slot before the post-callback dereference could redirect the in-flight String.prototype.at/startsWith/endsWith/localeCompare to operate on attacker-placed bytes, surfacing them through return values or comparison side channels — a classic information-leak primitive in the renderer. Escalation to a write primitive is not visible from the diff alone.

This vulnerability weakens memory safety inside the WebContent process. The invariant that a StringImpl's buffer remains live for the duration of every GCOwnedDataScope that points into it was not enforced when atomization swapped that StringImpl out and a GC ran before the scope destructed; the bypass is reachable from ordinary web JavaScript through standard String.prototype methods. The fix is interesting in that it does not tighten GCOwnedDataScope itself — instead it teaches the GC to treat any JSString visible on the stack during conservative scan as a reason to retain that string's previously swapped-out StringImpl, extending the swapped-impl's lifetime to span the GCOwnedDataScope window for free.