Ephemeron handling and the marking loop
Ephemerons — the key-value relationship used by WeakMap — force the garbage collector to defer decisions about reachability. A value in a WeakMap should only be collected if neither its key nor the value itself has any other strong references. The GC cannot know this until it has processed all other reachable objects, so it defers the final determination.
During marking, when the GC visits an EPHEMERON_HASH_TABLE_TYPE object — the internal representation of a WeakMap's backing store — it uses the VisitEphemeronHashTable method. The logic there is deliberately conditional:
- If a key has already been marked (black or grey) by the time the table is visited, the corresponding value is considered reachable and is visited as a strong pointer.
- If the key isn't marked yet, both key and value are placed into the
discovered_ephemeronslist for later processing.
At the end of the main marking pass, the GC processes all discovered ephemerons through the ProcessEphemeronMarking method. This runs an iterative loop, analogous to the main marking routine:
- Process each ephemeron in the current worklist via
ProcessEphemeron. - If a key now appears in the mark bitmap, mark the corresponding value as grey and add it to the regular marking worklist.
- Otherwise, queue the ephemeron in
next_ephemeronsfor another pass.
The loop terminates when no new objects are marked, or when it reaches a fixed maximum number of iterations. If that maximum is hit, the GC switches to a different, more conservative marking strategy to avoid an infinite loop.
Within the main loop of ProcessEphemeronMarking, the logic found in ProcessEphemerons governs whether each deferred pair is finally resolved as reachable or not. A value whose key was never marked — meaning no live path to it ever appeared — will be collected along with its key when the sweep phase runs.
Where concurrent marking is enabled, these phases are interleaved with the mutator thread. That adds inherent timing uncertainty: an ephemeron key may be observed as unmarked by the concurrent marker even if the application re-establishes a strong reference immediately afterward. In such a case, the GC can freely collect objects that are logically reachable from JavaScript, creating the classic preconditions for a use-after-free. This is exactly the class of defect behind CVE-2021-37975: unresolvable ordering combined with an intra-cycle update leads the GC to reclaim memory for objects the application still holds live references to.
Several V8 flags help debug these intervals:
trace-gcprints a line for every minor (scavenge) and major (mark-sweep) collection.trace-concurrent-markingshows when the concurrent marker starts, pauses and finishes.trace-unmapperreveals what happens to pages after collection, when the memory allocator removes access permissions or returns pages to the OS.trace-gc-verbosegives finer-grained per-phase diagnostics.
The UnmapFreeMemoryJob tasks that these logs describe are important for exploitation. Careful timing of allocation and GC triggering — such as waiting after a full collection for the unmap calls to complete — is necessary for reliable reproduction and for keeping freed objects around long enough to be reallocated.
The vulnerability
The patch includes changes across several files, but the crucial one is in the ephemeron marking logic. After the change, the algorithm doesn't merely stop when local_marking_worklists becomes stable; it also checks whether any object in that list was processed. The updated comment explains why: if an object in local_marking_worklists gets processed, it can potentially mark keys of previously unreachable ephemerons. If the algorithm terminates at that point, those ephemerons' statuses may not be updated, and their values could be collected.
Consider the final iteration. If at the start of an iteration all key-value pairs in current_ephemerons are white, but processing local_marking_worklists marks one key k1 (paired with value v1), and no new ephemeron is discovered, then discovered_ephemerons stays empty. The iteration ends with all pairs in current_ephemerons still unmarked. The value v1 is collected even though it remains reachable via k1, since k1 itself is marked and survives.
To reconstruct the exact state that leads here, work backwards from the final iteration. At the start of the ephemeron processing algorithm, all non-ephemeron objects are already handled, and local_marking_worklists is empty. The only populated list is discovered_ephemerons, from which current_ephemerons gets filled.
For the final iteration to add new objects to local_marking_worklists:
- All keys in
current_ephemeronsmust be white — otherwise processing them would mark something and require another iteration. - Since
current_ephemeronsdoesn't feedlocal_marking_worklists, the list must already contain objects from prior processing. discovered_ephemeronsis empty at the start of the iteration but may gain entries afterlocal_marking_worklistsis drained — and those new entries must have white keys to avoid another iteration.
This figure illustrates a possible state where draining local_marking_worklists marks k1 (from current_ephemerons) at the exit of the iteration, leaving its value v1 unreachable and unmarked. The scenario requires an object like v3 in the worklist that strongly references k1. This v3 comes from a pair (k3, v3) that was in discovered_ephemerons in the previous iteration.
Constructing the previous iteration
For the worklist to have objects, discovered_ephemerons must have contained at least one pair (k3, v3) with v3 unmarked and k3 marked. Only a marked key allows ProcessEphemeron to push the value into local_marking_worklists. That implies the iteration must satisfy:
local_marking_worklistscontains aWeakMapso that its key-value pairs are added todiscovered_ephemerons.- One pair
(k3, v3)has a marked key and an unmarked value.
The apparent contradiction is that when a WeakMap is visited via VisitEphemeronHashTable, a pair is added to discovered_ephemerons only if the key is unmarked at that moment. Condition two, however, is assessed after the whole worklist is drained. The resolution lies in the order entries are processed within the hash table. If wm holds pairs (k3, v3) and (k4, k3), where k3 and v3 are currently unmarked but k4 is already marked, visiting (k3, v3) before (k4, k3) adds the former to discovered_ephemerons while both are white. Visiting the latter then marks k3. The result: discovered_ephemerons holds (k3, v3) with a marked key and unmarked value.
For wm to end up on local_marking_worklists, an ephemeron pair (kwm, wm) must be in current_ephemerons with kwm marked, so the value is pushed when that list is processed. A minimal JavaScript construction follows.
var rootWm = new WeakMap();
var kwm = {};
var k4 = {};
{
let k1 = {};
let k2 = {};
let k3 = {};
let v3 = k1;
wm.set(k3, v3);
wm.set(k4, k3);
rootWm.set(k1, v1);
rootWm.set(k2, v2);
let wm = new WeakMap();
rootWm.set(kwm, wm);
}
//v1 can be retrieved as follows:
let wm = rootWm.get(kwm);
let k1 = wm.get(wm.get(k4));
let v1 = rootWm.get(k1);
Why the simple trigger fails
The straightforward construction does not yield a use-after-free for two reasons. First, the marking routine runs ProcessEphemeronMarking twice. By the end of the first pass, pairs (k1, v1) and (k2, v2) end up in current_ephemerons with k1 marked. For exploiting the second pass, this state is the starting point: repeat the construction, treating (k1, v1) as the new (kwm, wm) and (k2, v2) as the new key-value pair whose value must be left reachable but unmarked. That yields the desired bug in the second pass.
Second, concurrent marking occurs before the final stop-the-world garbage collection. The solution is to create an object graph that returns to a similar "trigger-ready" state after each iteration of concurrent marking. Nesting WeakMaps achieves this. A top-level wm1 contains several nested maps. When an iteration begins, current_ephemerons holds the entries of wm1 (assuming wm1 is reachable). Processing that list pushes map wm2 into local_marking_worklists (since its key k2 is marked), while the remaining pairs queue for the next iteration. Draining the worklist then marks k3 because the value k5 was marked. The next iteration starts with (k3, wm3) and (k4, wm4) where k3 is marked — structurally the same state as before, one pair lighter.
A sufficiently deep nested WeakMap structure guarantees that by the time concurrent marking finishes, current_ephemerons is in the correct vulnerable state — as long as concurrent marking doesn't exhaust the chain. The entries inside an EphemeronHashTable are ordered by key hash, which is non-deterministic. That ordering affects whether the trigger works on a given attempt, but a failed attempt only skips the bug without corrupting memory, so repeated trials will eventually succeed.
From type confusion to stable primitives
The bug described in the previous section yields a use-after-free on any chosen JavaScript object. The immediate hurdle is that triggering garbage collection frees most other objects and relocates survivors, making direct reclamation of the freed slot unpredictable. A better strategy is a partial reclaim: after GC, the freed object stays in place with its data intact, unless a concurrent job unmaps the underlying page. This lets me target objects whose references live in a more stable heap.
Two promising candidates emerged:
TypedArray/ArrayBuffer, whose backing store lives in PartitionAlloc — the established route from Operation Wizard Opium.- Large
JSArray, where the backing store is allocated in the large object space, a rarely used area that survives GC without compaction.
Since the second path had not been publicly demonstrated, I pursued that approach.
Forcing a controllable GC
Initial tests with --expose-gc succeeded—the freed TypedArray's backing store contents were replaceable. However, in normal operation, triggering GC with a large allocation caused SEGV_ACCERR when accessing the freed object. The cause is that large allocations spawn UnmapFreeMemoryJob tasks, which either uncommit or free memory chunks entirely, wiping the data alongside the page. Systematically allocating large objects after GC makes the freed page accessible again, but the contents are now clean. To avoid this, GC must be triggered with the smallest possible allocation threshold, so pages are only uncommitted, not reused and flushed:
new ArrayBuffer(0x7fe00000);
Using a modest allocation that still forces GC, then spraying large JSArrays to reclaim the freed slot, allowed a large double array's backing store to be swapped for that of a large object array. The resulting type confusion gives two views of the same memory:
Any object in the object array is read as a double in the double array. This yields the addressof primitive, then the fakeobj primitive via inspecting the backing store layout of an inlined JSArray, which lies adjacent to the array object itself.
d8> x = [1.1,1.1,1.1]
[1.1, 1.1, 1.1]
d8> %DebugPrint(x)
DebugPrint: 0x360308049471: [JSArray]
- map: 0x360308203ae1 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x3603081cc0f9 <JSArray[0]>
- elements: 0x360308049451 <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
Finding the offset of the inline backing store is straightforward with%DebugPrint. After crafting a fake double array whose elements pointer is attacker-controlled, arbitrary read/write over compressed pointers becomes feasible. The main remaining obstacle is obtaining a valid double array map value to use as the fake object's header. Instead of leaking it, I observed that the compressed map pointer is constant across launches, devices, and even reboots—0x8203ae1 for d8 and a version-dependent but stable value in Chrome. This is likely due to heap snapshots shipped with the binary, which place built-in maps at a fixed offset.
Code execution and a Linux-specific twist
With a leaked map address, I crafted a fake double array to read and write arbitrary compressed addresses. From there, the remaining steps are:
- Read the 64-bit pointer to the
RWXwasm code page from aWebAssembly::Instance. - Overwrite a
TypedArray's backing store pointer (also 64-bit) to that wasm page. - Write shellcode through the
TypedArray, then invoke the wasm function for arbitrary code execution.
On Chrome for Linux and ChromeOS, the wasm code protection feature kWebAssemblyCodeProtectionPku is in field trial. When active, it only leaves the wasm memory marked RWX on the page table, while a hardware memory protection key enforces write-protection at SIG_SEGV level. The workaround involves overwriting the FLAG_wasm_memory_protection_keys variable, which has a fixed offset relative to the Chrome renderer's libchrome.so base. Leaking one fixed address from the library—for instance, the WrapperTypeInfo pointer inside an OfflineAudioContext object—makes calculating the flag's address trivial:
//%DebugPrint(new OfflineAudioContext(1,4000,400))
Thread 1 "chrome" hit Breakpoint 1, v8::internal::DebugPrintImpl (maybe_object=...)
at ../../v8/src/runtime/runtime-test.cc:850
850 StdoutStream os;
(gdb) p/x maybe_object
$1 = {<v8::internal::TaggedImpl<v8::internal::HeapObjectReferenceType::WEAK, unsigned long>> = {static kIsFull = 0x1,
static kCanBeWeak = 0x1, ptr_ = 0x131f080a4ee9}, <No data fields>}
(gdb) p/x 0x131f080a4ee8
$2 = 0x131f080a4ee8
(gdb) x/8x 0x131f080a4ee8
0x131f080a4ee8: 0x08249cd9 0x0800222d 0x0800222d [0x60e9e648]
0x131f080a4ef8: [0x00005555] 0x003a85a8 0x00001620 0x08002699
(gdb) x/x 0x555560e9e648
0x555560e9e648 <_ZN5blink21V8OfflineAudioContext18wrapper_type_info_E>: 0x00000001
With that flag cleared, writes flow to the wasm RWX page, and the exploit completes. The working version is published in the GitHub Security Lab repository.
A Post-Mortem of CVE-2021-37975
The analysis of CVE-2021-37975 demonstrates that logic errors within a garbage collector can produce vulnerabilities with significant impact. The complexity stems from how the collector handles weak references and ephemerons, which are inherently special cases that introduce additional state and potential for edge-case faults. This specific bug is a direct result of that complexity.
Why the Garbage Collector is a Target
The mechanics of exploiting garbage-collected objects are often thought to introduce prohibitive uncertainty. However, the findings show that, despite the nondeterminism inherent in collection cycles, an attacker can still achieve reliable exploitation. The study of this bug highlights that the GC is a fertile and often under-appreciated area of the V8 attack surface.
By treating the collector as an ordinary, if intricate, component for security auditing, researchers can uncover and address entire classes of bugs. The development of reliable primitives from GC-induced logic errors suggests that similar flaws are likely to exist and warrant dedicated attention.



