Critical CVSS 8.8 webkit UAF 🔧 Commit mapped

Overview

Critical
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing web content may lead to arbitrary code execution
ComponentJSC Bytecode
Bug ClassUAF
Tracker268221
Fix commitb25150796310 (WebKit/WebKit) +94/-0
CWECWE-786
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedLukas Bernhard of CISPA Helmholtz Center for Information Security
Disclosed2024-05-13

Background

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.

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.

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.

Changed Functions

FunctionChangeNotes
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

  • JSTests/stress/decouple-calllinkinfo-from-access-case.js
  • Source/JavaScriptCore/bytecode/AccessCase.cpp
  • Source/JavaScriptCore/bytecode/AccessCase.h
  • Source/JavaScriptCore/bytecode/GetByStatus.cpp
  • Source/JavaScriptCore/bytecode/GetterSetterAccessCase.cpp
  • Source/JavaScriptCore/bytecode/GetterSetterAccessCase.h
  • Source/JavaScriptCore/bytecode/InlineCacheCompiler.cpp
  • Source/JavaScriptCore/bytecode/InlineCacheCompiler.h
  • Source/JavaScriptCore/bytecode/ProxyObjectAccessCase.cpp
  • Source/JavaScriptCore/bytecode/ProxyObjectAccessCase.h
  • Source/JavaScriptCore/bytecode/PutByStatus.cpp
  • Source/JavaScriptCore/bytecode/StructureStubInfo.cpp
  • Source/JavaScriptCore/bytecode/StructureStubInfo.h
  • Source/JavaScriptCore/jit/GCAwareJITStubRoutine.cpp
  • Source/JavaScriptCore/jit/GCAwareJITStubRoutine.h
  • Source/JavaScriptCore/jit/JITStubRoutine.cpp
  • Source/JavaScriptCore/jit/JITStubRoutine.h

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.
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;
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.