32f1bfb841 [JSC] Defer GC while using the direct eval CacheLookupKey
Triage note: Adds DeferGC because cacheKey holds a rope fiber's StringImpl raw and rope flattening plus sweep can deref it, a use-after-free.
Contents
The bug at a glance
High. eval() constructs a DirectEvalCodeCache::CacheLookupKey holding a raw StringImpl* (no ref) taken from the flattened contents of the program string. When the program string is an on-stack rope, rope flattening can free the fiber JSString whose contents that raw pointer references, and a GC/sweep during cache lookup then dereferences freed memory, a use-after-free reachable from ordinary script via direct eval. Backed by a stress test with slowPathAllocsBetweenGCs and rdar.
CacheLookupKey deliberately stores a raw StringImpl* for speed rather than reffing it. That StringImpl can be the buffer of a fiber of a rope JSString. Even though the rope JSString is on the stack and kept alive, resolving the program source flattens the rope in place, replacing its fibers; the old fiber JSString becomes unreachable and can be swept by a GC that fires during the cache miss path, freeing the StringImpl the key still points at.
Root cause
In Interpreter.cpp’s eval(), the program to evaluate arrives as programStr, a JSString that may be a rope (an unflattened concatenation such as a + b). The code builds ‘auto cacheKey = DirectEvalCodeCache::CacheLookupKey(programStr.data.impl(), bytecodeIndex);’. CacheLookupKey stores the StringImpl* raw, without taking a reference, for performance. That StringImpl* is obtained from programStr’s current representation.
On a cache miss (the !eval branch), the code proceeds to obtain programSource = programStr.data and further processes the source. Resolving/using the rope’s data triggers rope flattening: JSRopeString flattening walks the fibers, allocates a contiguous backing store, and rewrites the rope JSString in place to be a resolved string, dropping references to its constituent fiber JSStrings. One of those fibers’ StringImpl is exactly what cacheKey’s raw pointer references. Once flattening detaches the fiber JSString and no other root retains it, that fiber becomes garbage.
If a garbage collection (which the test forces via a low slowPathAllocsBetweenGCs and explicit gc()) runs while the CacheLookupKey is still live on the stack, the sweeper can destroy the now-unreachable fiber JSString and free its StringImpl. The CacheLookupKey, and any subsequent hashing/comparison against it during the lookup and cache insertion, then dereferences a freed StringImpl, a use-after-free. Because the key participates in hashtable operations (hash, equal), the freed memory is read and potentially the basis for cache decisions.
The fix wraps the miss path in ‘DeferGC deferGC(vm);’ immediately inside the if (!eval) block. DeferGC suppresses garbage collection for the scope in which the raw-pointer key is live and the rope is being flattened, guaranteeing the fiber’s StringImpl cannot be swept out from under the raw pointer until the key is no longer needed. This is a minimal, correct lifetime fix: rather than change CacheLookupKey to ref the impl (a perf regression), it brackets the dangerous window with GC deferral.
Key code
DeferGC brackets the live raw-pointer CacheLookupKey (Interpreter.cpp)
auto cacheKey = DirectEvalCodeCache::CacheLookupKey(programStr.data.impl(), bytecodeIndex);
DirectEvalExecutable* eval = callerBaselineCodeBlock->directEvalCodeCache().get(cacheKey);
if (!eval) {
// GC needs to be deferred as it's possible cacheKey holds one of programString's fibers'
// contents as a raw StringImpl*. Even though programString is on the stack, rope
// flattening could cause that fiber JSString to be unreachable by the GC, causing its
// content StringImpl to be deref'd if when the JSString is swept.
DeferGC deferGC(vm);
auto programSource = programStr.data;
Patch walkthrough
Source/JavaScriptCore/interpreter/Interpreter.cpp— Inside eval(), on the cache-miss branch (if (!eval)), a ‘DeferGC deferGC(vm);’ is added at the top of the block, before ‘auto programSource = programStr.data;’ and the source-profiler/flattening work. This defers GC for the lifetime of the block so the rope flattening that detaches programStr’s fiber JSString cannot lead to that fiber’s StringImpl (raw-held by cacheKey) being swept and freed while the key is in use. A comment documents that cacheKey may hold a fiber’s contents as a raw StringImpl* and that flattening plus sweep would otherwise deref freed memory.
Background
Direct eval cache — DirectEvalCodeCache memoizes compiled DirectEvalExecutables keyed by the program source string and calling context, so repeated eval of the same source is fast.
CacheLookupKey raw StringImpl* — For performance the key stores a StringImpl* without reffing it, relying on the program string staying alive for the key’s lifetime, an assumption broken by rope flattening + GC.
Rope JSString flattening — A rope (lazily concatenated string) is resolved on demand by allocating a contiguous buffer and rewriting the rope in place, dropping its fiber JSStrings which may then become garbage.
DeferGC — An RAII scope that suppresses garbage collection while alive, used to keep raw, unreferenced pointers valid across an operation that could otherwise collect their backing objects.
Vulnerability window
- Build rope — Script produces a rope program string, e.g. a + b, and passes it to eval().
- Key creation — eval() builds a CacheLookupKey holding a raw StringImpl* from the rope’s current data.
- Miss path — On a cache miss, programStr.data is resolved, flattening the rope and detaching its fiber JSStrings.
- GC fires — A collection (forced by slowPathAllocsBetweenGCs=10 and gc()) sweeps the now-unreachable fiber JSString, freeing its StringImpl.
- UAF — Continued use/hashing of the still-live CacheLookupKey dereferences the freed StringImpl.
- Fix — DeferGC in the miss block prevents the sweep until the key is done.
Proof of concept
Full verbatim dfg-ensure… no, this is direct-eval-cache-rope.js. getRope returns a rope (a + b) whose fibers are freshly allocated; the [][a] access and construction shape the rope so that eval(s) creates a CacheLookupKey over a fiber’s StringImpl. With slowPathAllocsBetweenGCs=10 and explicit gc(), a collection sweeps the detached fiber during the eval miss path, exercising the UAF. The test simply must not crash under ASAN.
// @requireOptions("--slowPathAllocsBetweenGCs=10")
function getRope(index) {
const a = "[" + index + ',[]'.repeat(0x100) + "]";
const b = "()";
[][a];
return a + b;
}
function main() {
for (let i = 0; i < 1000; i++) {
const s = getRope(i);
getRope(0);
gc();
try {
eval(s);
} catch {
}
}
}
main();
Exploitation
- Rope grooming — Craft eval program strings as ropes whose fiber StringImpl backs the raw key, and size fibers to land in a controllable size class.
- Force collection — Drive allocation so a GC/sweep fires precisely during the eval cache-miss window while the key is live.
- Reclaim — Reallocate the freed StringImpl’s memory with attacker-controlled data so the key’s hash/equal reads attacker bytes.
- Leverage — A UAF over a StringImpl used in hashtable operations can be steered toward controlled reads/type confusion of the reclaimed object.
Detection & hunting
For defenders and SOC / detection engineers:
- ASAN use-after-free on StringImpl in eval —
- Raw StringImpl held across flattening* —
Audit directions
- Other raw StringImpl/JSString caches* —
- Rope flattening side effects —
- DeferGC coverage —