[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);
Patch Details
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.
Background
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.
Analysis
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.
Aaa Aaaaaaaaa Aaaa Aaa Aaaaaaaaaaaaaaaaaaaaa Aaaaa Aaa Aaaaa Aaaaaaaa Aaa Aaaaaaaaaaa Aaaaaaaaaaaaaaaaa Aa Aaaaaaa Aa Aa a Aaaaaaaa Aaaaa Aaa Aaaaa a Aaaaa Aaaa Aaaaaaaaaaa Aaaaa Aaaaaaaa Aaaaaaa Aaaaaaa Aaa Aaaaaaaa Aaaaa Aaa Aaaaa Aaaaaaaaaaaaaaaaaaaaa Aaaa Aa a Aaaaaaaa Aaaaaaaa Aaaaaa Aa Aaaaa Aaaaaa Aaaaaaaaaaaa Aaa Aaaa Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Aaaaaaaaa Aaaaa Aaaaaaaaaaaaaaaaaaaaaaaaaaaaa Aaaa Aaaaaaaaaaaaaaaaaaa Aaaaaaaaaa Aaa Aaaaaaaaaa Aaaaaaaaaaa Aaa Aaaaaaaaaaaaaaaaaa Aaa Aaaaaaaa Aa Aaa Aaaaaaaa Aaaaaaaaaaaaaaaaaa Aaaa Aaaaaaaaaaaaaaaaaaa Aaaaaaaa Aaa Aa Aaaaa Aaa Aaaa Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Aaaaa Aa Aa Aa Aaaaaa Aaaa Aaa Aaaaaaaaaaa Aaaaa Aaaa Aaaaaaa Aaaaaa Aaaaaaaaaaaaaaaaaaaa Aaa Aaaaaaa Aaaaaaa a Aaaaaaaaaaaaaaaaaa Aaaa Aaaaaaaaaaaaa Aaaaaa Aaaaaa Aaaaaaaa Aaaaaaa Aaa Aaaaaaaaaa Aaa Aaaaa Aa Aaaaa Aaaa Aa Aaa Aaaaa Aaaaaa Aaa Aaaaaaaaa Aaaaaaaa Aaaa Aa Aaaa Aaa Aaaa Aaaaaaaaa Aa Aaa Aaaaaaaaaaa Aaaaaaaaaaaaa Aaa Aaaaaa Aaaaaaa Aaaaaa Aa Aaa Aaaaaaaaaaaaaaaaaa Aaaaa Aaaa Aaaaaaaaaaa Aaaaa Aaaaaa Aaaaa Aaaaaaaaa Aaaaaaaaa Aaaaaaaaa Aaa Aaaaaaaaaaaa Aaaaa Aaaa Aaaa Aaaaaaaaaaa Aa Aaaaaaaa Aaa Aaaaaaaa Aaaaa Aaa Aaaaaaaaa Aaaaaa
Aaa Aaaaaaaaaa Aaaaaaaaaaaaaaaaaa Aaaaaaaaaaa Aa a Aaa Aaaa Aa a Aaaaaaaaaaaa Aaaaaa Aa Aaaaaaaaaaaaaaaaaaa Aaaaaa Aaa Aaaaaaa Aaaa Aaa Aaaa Aaa Aaaaaa Aaaaaaa Aa Aaa Aaaaaaaaa Aaaa Aaaaaa Aa Aaaaaaaa Aaa Aaaaaaaa Aaa Aaaaa Aaaa Aaaaaa Aaa Aaaaaaaaaaaaa Aaaaaaaaaaa Aaaaa Aaaaaaaa Aaa Aaaaaaaaa Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Aa Aaaaaaa Aa Aaaaaaaaaaaaaaa Aaaaaa Aaaaaaaaa Aaaa Aaaaaaa Aaaaaa Aaaaaa Aa Aaaaaaaaaa Aaaa Aaaaaaaa a a Aaaaaaa Aaaaaaaaaaaaaaaa Aaaaaaaaa Aa Aaa Aaaaaaaaa Aaaaaaaaaa Aa a Aaaaa Aaaaaaaaa Aa Aaa Aaaaaaa Aaaa Aaa Aaaa Aaaaaa
Aaaa Aaaaaaaaaaaaa Aaaaaaa Aaaaaa Aaaaaa Aaaaaa Aaa Aaaaaaaaaa Aaaaaaaa Aaa Aaaaaaaaa Aaaa a Aaaaaaaaaaaaaa Aaaaaa Aaaaaaa Aaaa Aaa Aaa Aaaaaaaa Aa Aaaaa Aaaaaaaaaaaaaaaaaa Aaaa Aaaaaa Aaaa Aa Aaa Aaa Aaaaaaaa Aaaa Aaaaaaaaaaa Aaaaaaa Aaaa Aaaaaaaaaaaa Aaa Aaa a Aa Aaa Aaaaaa Aaa Aaaaa Aaaaaaaaaaa Aaa Aaaaaa Aa Aaaaaaaaa Aaaa Aaaaaaaa Aaa Aaaaaaaaaa Aaaaaaa Aaaaaaaa Aaaaaaaaaaaaaaaaaa Aaaaaaaa Aaa Aaa Aa Aaaaaaaaaaa Aa Aaaa Aa Aaaa Aaa Aaaaaaa Aaaaaaaaaaaaaaaaaa Aaaaaa a Aaaaaaa Aa Aaaaaaa Aaa Aa Aa Aaaaa Aaa Aaaaaaaaaa Aaaaaaa Aa Aaa Aaaaa Aaaaaa Aaaaaaaaaaaa Aaaa Aa a Aaaaaa Aa Aaaaaa Aaaa Aaaaaaaa Aaaaaaaaaa Aaaaaaaaaaa Aaaaaaaaaaaaa Aaaaaaaaa Aaa Aaaaaaaaaaaaaa Aaaaaaaa Aa Aaaa Aaa Aaaaaaaaaaaaaaaaaa Aaaaaa Aaa Aaaaa
🔒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
Audit directions
a Aaaaaaaaaaaaaa Aaa Aaaaa Aaaa Aaaaaaaaaaaa Aaaaaaa Aaaaaaaaaa Aaaaa Aaaaa Aaaaaa Aaaa Aaaaaaaa Aa Aaaaa Aa Aaaaaaaaaaaaaaaaaaaaa Aaaaaaaaaaaa Aaa Aaaa Aaaaaa Aaaaa Aaa Aaaaaaaa Aaaaa Aaaaaa Aaaaaaaaaa Aaaa Aaa Aaaa Aa Aaaaaa Aaa Aaaaaaaaaa Aaaaaaa Aaaaaaa Aaaaaaaaaa Aaa Aaaa a Aaaaaaaaaa Aaaaaaa Aaaa Aaaaaaaaaa Aaaa Aaaaaaaa Aaaaaaaa Aaaaaaaa Aaaaaaaaaaaaa Aaaaaaaaaaaaaaaa Aaaaaaaaaaaa Aaaaaaaaaa Aaa Aaaaaaaaaaa Aaaaa Aaaaaaaaaa Aaa Aaaaa Aaaaa Aaaaaaa a Aaaaaaaaaa Aaaa Aaaaaaa a Aaaaaaaa Aaaaaaa Aaaaa Aaa Aaaaaaaaaa Aaaaa Aaaa Aaaaa Aa Aaaaaaaaaaaaaaaa Aaaaaaaaaaaaaaaaaaaaaa Aaa Aaaaaa Aaaa Aaaaaaaaa
a Aaaa Aaaaaaaaa Aaaaa Aaaaaaa Aa a Aaaaaa Aaaaaa Aaaaa Aaaaaa Aa Aaaaaaaaa Aaaa Aa Aaaaa Aaaaaaaa Aaaaaaaaaaaa Aaaa Aaaa Aaaaaaaa Aaaaaaaaa Aaaaa Aaaaa Aaaaaaaa Aaaa Aaaaa Aaa Aaa Aaaa Aaaaa a a Aaaa Aa Aaaaaaa Aaaaaaaa Aa Aaaaaaaaa Aaaa Aaa Aaaaaaa Aa Aa Aaa a Aaa Aaaaaa Aaa Aaaaa Aaaaaa Aaaaaaa Aaa Aaaaaaa Aaaaaa Aaa Aaaaaaaa Aaaaa Aaa Aaaaa Aaaaaa Aaa Aaaa Aaa Aaaaaaaaaaaaaaaaa Aaaaaaa Aaaa Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Aaa Aaaaaaaaaaaaaaaaaaaaaaaaaa Aaaaaaa Aaaaaaa Aa Aaaaaaaaaaaaaaaaa
a Aaaaaaaaaaaa Aa Aaaaaaaaa Aaaaaaa Aaaaaaaaaaaaaa Aaaaa Aaaaa Aaaaaaaaaaaaaaaaaaaa Aaa Aaaaaaaaaa Aaaaaaaaa Aaaa Aaaaa a Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Aaa Aaaa Aaaaaaa a Aaaaaaaaaaaaaaaaa Aaaaaaaaa Aaaaaaaaaaaaaaa Aaaaaaaaaaa Aaaaaaaaaa Aaaaaaaa Aaaaa Aaaaaa Aaaaaaaaa Aaa Aaaaa Aaa Aaaa Aaaaaa Aaaaa Aa Aaaa Aaaaaa Aaaaaa Aaaaaaaaaaa Aaaaaaaaaaaaa Aaaaaaaaaaaaaaaa Aaa Aaa Aaaaaaaaaaa Aaaaaaaaaa a Aaaaa Aaaaaaaaaaaaaaaaaaaaaaaaa Aaaaaaaaaaaaaaaaaaaaaaa Aaaaaaaaaaaaaaaaaaaaaaaaa Aaaaa Aaaaa Aaaaaa Aaa Aaa Aaaaaaa Aaaa Aaaaaaaa a Aaaa Aaa Aaaa Aaaaa Aaaa Aa Aa Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
a Aaaaaaaa Aaaaa Aaaaaaa Aaa Aaa Aaaa Aaaaa Aaaaaaa a Aaaaaaaaaaa Aaaaaaaaaaaa Aaaaa Aaaaaa Aa Aaaaaaaaaaaaaa Aaa Aaa Aaaaaaaaaa Aaaaaa Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Aaa a Aaaaaa Aaaaaaaaaaaaaa Aaaaaaaaaaa Aaaaa Aaaaa Aaaaaaaa Aaaaaa Aaaaaaa Aaaaaaa Aa Aaaaaa Aaaaaaaaaaa Aaaaaaa Aaa Aaaaaaaaaaaaa Aaa Aaaaaaa Aaaaaaaaaaaaaaaaaaa Aaaaaaaa Aa Aaa Aa Aaaa Aaaaaaaa Aaa Aaaa Aaaaaaa Aaaa Aaaaaaaaaaaaaa Aaa Aaaaaaaaaaaaaaaa Aa Aaaaaaaaaa Aaa Aaaaaaa Aaaaaaaaaaa Aaaaa
🔒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