← 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 lifetime story behind this UAF - which buffer was the GC really protecting, and why did atomization slip past it - is unpacked end-to-end, together with a reachability assessment grounded in the committed regression tests.

Subscribe to read more

🔒

Four reusable audit patterns covering related areas of JSC's heap, string subsystem, and re-entrant builtin paths, with concrete grep starting points for variant discovery.

Subscribe to read more