
Adaptation of Cassowary CVE-2024-23222 for Linux x86_64
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 purposefriend⊂(◉‿◉)つ
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.
The PoC and crash output in this report were produced in the following environment:
7617.1.17.13jsc shelljsc binaryJSC'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().
The pre-patch tryGetConstantProperty() does three things:
JSValue.// 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.
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:
// 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:
// 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.
Two places in the DFG pipeline unconditionally pass the result of tryGetConstantProperty() to freeze():
ByteCodeParser — during initial bytecode-to-DFG-IR lowering:
// 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:
// 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*:
// 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));