Released May 13, 2024

CVE-2024-27851

CVSS 8.8 severity high CWE-119 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Overview

Impact:

Description: Processing maliciously crafted web content may lead to arbitrary code execution

Researchers: Nan Wang (@eternalsakura13) of 360 Vulnerability Research Institute

Key insight. Wasm call inlining hardcoded the inlinee as having no exception handlers and emitted invalid call-site indices, so an exception thrown through an inlined EH-bearing function dispatched on wrong metadata; propagating the real hasExceptionHandlers state and valid call-site indices fixes it.

Patch Details

diff --git a/JSTests/wasm/stress/inlinee-may-have-exception-handlers.js b/JSTests/wasm/stress/inlinee-may-have-exception-handlers.js
new file mode 100644
index 000000000000..686066ec9274
--- /dev/null
+++ b/JSTests/wasm/stress/inlinee-may-have-exception-handlers.js
@@ -0,0 +1,84 @@
+import * as assert from '../assert.js';
+import { instantiate } from "../wabt-wrapper.js";
+
+let wat = `
+(module
+    (func $main (export "main")
+        call $catcher
+        i64.const 3127057505886423800
+        i64.const -5049743701649469475
+        i32.const 1279394412
+        i32.const 1249280136
+        i32.const -851957055
+        i32.div_s
+        i32.div_s
+        select (result i64)
+        loop (result i64)
+        i64.const 7052281334997434446
+        f64.const 0x1.f0094d7063744p+967
+        i64.trunc_sat_f64_u
+        i64.rem_s
+        end
+        i64.add
+        f64.convert_i64_s
+        i32.const 993640798
+        i32.const -291103156
+        i32.atomic.rmw.xor offset=36014
+        f32.const 0x1.2e5784p-38
+        i64.const 8981315711995315489
+        i64.const 5932917051412947299
+        i64.const 6207621187208520631
+        i32.const 1636976966
+        select (result i64)
+        i64.ne
+        br 0
+        i32.trunc_sat_f32_u
+        i32.div_s
+        f64.const 0x1.7c35ecf56d865p-116
+        i32.const 1987656988
+        i64.const -1947434429939530388
+        f64.const -0x1.ca05a27b00c85p+372
+        i64.const 5169929820610505455
+        block (param f64 i32 f64 i32 i64 f64 i64)
+        drop
+        drop
+        drop
+        drop
+        drop
+        drop
+        drop
+        end
+    )
+    (func $empty (param i32))
+    (func $catcher (type 0)
+        try
+        i32.const 1
+        call $empty
+        catch_all
+        throw $exc
+        end
+        call $main
+    )
+    (memory 32 64)
+    (tag $exc)
+)
+`;
+
+async function test() {
+    let instance = await instantiate(wat, {}, {threads: true, exceptions: true});
+
+    let caughtCount = 0;
+    for (let i = 0; i < 10; i ++) {
+        try {
+            instance.exports.main();
+        } catch (e) {
+            // We expect to either overflow the stack, or end up throwing $exc back into JavaScript.
+            assert.truthy(e instanceof RangeError || e instanceof WebAssembly.Exception);
+            caughtCount ++;
+        }
+    }
+
+    assert.eq(caughtCount, 10);
+}
+
+assert.asyncTest(test());
diff --git a/JSTests/wasm/stress/rethrow-should-set-callsite-index.js b/JSTests/wasm/stress/rethrow-should-set-callsite-index.js
new file mode 100644
index 000000000000..07519a62e491
--- /dev/null
+++ b/JSTests/wasm/stress/rethrow-should-set-callsite-index.js
@@ -0,0 +1,40 @@
+import * as assert from '../assert.js';
+import { instantiate } from "../wabt-wrapper.js";
+
+let wat = `
+(module
+    (type (func))
+    (tag $exc (type 0))
+    (func $empty)
+    (func $rethrower
+        try
+            call $empty
+            throw $exc
+        catch_all
+            rethrow 0
+        end
+    )
+    (func $call-rethrower
+        call $rethrower
+    )
+    (export "call_rethrower" (func $call-rethrower))
+)
+`;
+
+async function test() {
+    let instance = await instantiate(wat, {}, {exceptions: true});
+
+    let caughtCount = 0;
+    for (let i = 0; i < 10000; i ++) {
+        try {
+            instance.exports.call_rethrower();
+        } catch (e) {
+            assert.instanceof(e, WebAssembly.Exception);
+            caughtCount ++;
+        }
+    }
+
+    assert.eq(caughtCount, 10000);
+}
+
+assert.asyncTest(test());
diff --git a/JSTests/wasm/stress/throw-should-set-callsite-index.js b/JSTests/wasm/stress/throw-should-set-callsite-index.js
new file mode 100644
index 000000000000..ee752f63010b
--- /dev/null
+++ b/JSTests/wasm/stress/throw-should-set-callsite-index.js
@@ -0,0 +1,39 @@
+import * as assert from '../assert.js';
+import { instantiate } from "../wabt-wrapper.js";
+
+let wat = `
+(module
+    (type (func))
+    (tag $exc (type 0))
+    (func $empty)
+    (func $thrower
+        try
+            call $empty
+        catch_all
+        end
+        throw $exc
+    )
+    (func $call-thrower
+        call $thrower
+    )
+    (export "call_thrower" (func $call-thrower))
+)
+`;
+
+async function test() {
+    let instance = await instantiate(wat, {}, {exceptions: true});
+
+    let caughtCount = 0;
+    for (let i = 0; i < 10000; i ++) {
+        try {
+            instance.exports.call_thrower();
+        } catch (e) {
+            assert.instanceof(e, WebAssembly.Exception);
+            caughtCount ++;
+        }
+    }
+
+    assert.eq(caughtCount, 10000);
+}
+
+assert.asyncTest(test());
diff --git a/Source/JavaScriptCore/wasm/WasmB3IRGenerator.cpp b/Source/JavaScriptCore/wasm/WasmB3IRGenerator.cpp
index 7f1d5feadd84..9ceede266fb0 100644
--- a/Source/JavaScriptCore/wasm/WasmB3IRGenerator.cpp
+++ b/Source/JavaScriptCore/wasm/WasmB3IRGenerator.cpp
@@ -345,8 +345,8 @@ public:
         return m_callSiteIndex;
     }
 
-    B3IRGenerator(const ModuleInformation&, OptimizingJITCallee&, Procedure&, Vector<UnlinkedWasmToWasmCall>&, unsigned& osrEntryScratchBufferSize, MemoryMode, CompilationMode, unsigned functionIndex, std::optional<bool> hasExceptionHandlers, unsigned loopIndexForOSREntry, TierUpCount*);
-    B3IRGenerator(B3IRGenerator& inlineCaller, B3IRGenerator& inlineRoot, unsigned functionIndex, BasicBlock* returnContinuation, Vector<Value*> args);
+    B3IRGenerator(CalleeGroup&, const ModuleInformation&, OptimizingJITCallee&, Procedure&, Vector<UnlinkedWasmToWasmCall>&, unsigned& osrEntryScratchBufferSize, MemoryMode, CompilationMode, unsigned functionIndex, std::optional<bool> hasExceptionHandlers, unsigned loopIndexForOSREntry, TierUpCount*);
+    B3IRGenerator(B3IRGenerator& inlineCaller, B3IRGenerator& inlineRoot, CalleeGroup&, unsigned functionIndex, std::optional<bool> hasExceptionHandlers, BasicBlock* returnContinuation, Vector<Value*> args);
 
     void computeStackCheckSize(bool& needsOverflowCheck, int32_t& checkSize);
 
@@ -899,6 +899,7 @@ private:
     void traceCF(Args&&... info);
 
     FunctionParser<B3IRGenerator>* m_parser { nullptr };

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2024-27851
Bugzilla 272106
Component WebCore
Bug Class OOB
Severity critical
Commit 1e58c9386ed9b508…
Advisory Apple Advisory

Terminology

  • OMG / B3IRGenerator — JSC’s optimizing wasm tier and its B3 IR generator, which can inline direct calls between wasm functions.
  • Call-site index — An index identifying a call site used to look up exception-handling metadata during unwinding.
  • hasExceptionHandlers — Per-function state indicating whether a wasm function contains try/catch handlers, needed to emit correct EH metadata when inlined.
  • CalleeGroup — The per-module registry of compiled wasm callees, used here to look up an inlinee’s real exception-handler state.

Root Cause Analysis

When JavaScriptCore’s optimizing wasm compiler (OMG/B3) inlines a direct call, the inlinee’s B3IRGenerator must know whether the inlined function contains exception handlers, because that determines the call-site index metadata emitted for exception dispatch. Pre-patch, the inlining constructor B3IRGenerator(parentCaller, rootCaller, functionIndex, returnContinuation, args) hardcoded m_hasExceptionHandlers(false) and never consulted the actual inlinee, and emitInlineDirectCall used a shortcut bool mayHaveExceptionHandlers = !m_hasExceptionHandlers || m_hasExceptionHandlers.value(); to decide whether to emit a real call-site index or PatchpointExceptionHandle::s_invalidCallSiteIndex. So when a function that does have exception handlers was inlined, the generator believed it had none and emitted invalid/placeholder call-site indices for the inlined region. If an exception was then thrown across that inlined frame, the unwinder resolved the wrong (invalid) call-site index and used incorrect exception-handling metadata — an out-of-bounds / type-confused read of handler tables that WebKit’s advisory describes as leading to arbitrary code execution. The fix threads the module’s CalleeGroup& into the generators and, in emitInlineDirectCall, looks up the real inlinee (wasmEntrypointCalleeFromFunctionIndexSpace(...).hasExceptionHandlers()) to pass a correct std::optional<bool> hasExceptionHandlers into the inlinee generator; it also propagates the parent’s exception-handler state (if (parentCaller.m_hasExceptionHandlers && *parentCaller.m_hasExceptionHandlers) m_hasExceptionHandlers = { true }), removes the invalid-call-site-index shortcut (always emitting proper indices), and extends PatchpointExceptionHandle to carry a callSiteIndex. The restored invariant is that inlined wasm frames always carry accurate has-exception-handlers state and valid call-site indices, so exception unwinding indexes the correct handler metadata.

Attack Path

1. Craft a wasm module with EH + inlining

Build a module where a small function containing exception handlers is a hot inlining target of a caller compiled by OMG/B3.

2. Force optimizing compilation + inlining

Warm the functions so OMG inlines the EH-bearing callee via emitInlineDirectCall, which pre-patch records the inlinee as having no exception handlers.

3. Emit invalid call-site metadata

The inlined region is compiled with placeholder/invalid call-site indices because m_hasExceptionHandlers was false.

4. Throw across the inlined frame

Trigger an exception in the inlined callee so the unwinder resolves the wrong call-site index and reads incorrect exception-handler metadata.

5. Corrupt / hijack control flow

The mismatched handler metadata yields an out-of-bounds/type-confused dispatch that the advisory rates as arbitrary code execution in WebContent.

Impact Assessment

A JIT exception-metadata correctness bug: inlining an exception-handler-bearing wasm function while recording it as having none produced invalid call-site indices, so throwing across the inlined frame dispatches on wrong handler metadata. This is a powerful primitive — the advisory rates it arbitrary code execution — reachable from a crafted wasm module, though it requires shaping inlining and an exception. Confined to the WebContent process. Rated critical.

Audit Directions

Other inlining-propagated state

Audit B3IRGenerator’s inlining constructor for other fields defaulted/hardcoded rather than derived from the inlinee (stack checks, memory mode, tier-up).

Call-site index emission

grep wasm/ for s_invalidCallSiteIndex and mayHaveExceptionHandlers to find other places that substitute placeholder call-site indices based on possibly-wrong EH state.

hasExceptionHandlers plumbing

Trace hasExceptionHandlers/CalleeGroup::hasExceptionHandlers through OMG/OSR/BBQ compilation to ensure every inlined or tiered path uses the callee’s true value.

Changed Functions

Function File Change Note
B3IRGenerator inlining constructor Source/JavaScriptCore/wasm/WasmB3IRGenerator.cpp modified Takes CalleeGroup& and an explicit std::optional<bool> hasExceptionHandlers, initializes m_hasExceptionHandlers from it, and inherits the parent’s true exception-handler state instead of hardcoding false.
B3IRGenerator::emitInlineDirectCall Source/JavaScriptCore/wasm/WasmB3IRGenerator.cpp modified Looks up the real inlinee via m_calleeGroup.wasmEntrypointCalleeFromFunctionIndexSpace(…).hasExceptionHandlers() and always emits valid call-site indices (removes the s_invalidCallSiteIndex shortcut).
parseAndCompileB3 Source/JavaScriptCore/wasm/WasmB3IRGenerator.cpp modified Signature gains CalleeGroup& so the generator can resolve inlinee callees; OMGPlan/OSREntryPlan updated to pass m_calleeGroup.
PatchpointExceptionHandle Source/JavaScriptCore/wasm/WasmIRGeneratorHelpers.h modified Now carries a callSiteIndex alongside hasExceptionHandlers so exception patchpoints reference the correct site.

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/JSTests/wasm/stress/inlinee-may-have-exception-handlers.js b/JSTests/wasm/stress/inlinee-may-have-exception-handlers.js
new file mode 100644
index 000000000000..686066ec9274
--- /dev/null
+++ b/JSTests/wasm/stress/inlinee-may-have-exception-handlers.js
@@ -0,0 +1,84 @@
+import * as assert from '../assert.js';
+import { instantiate } from "../wabt-wrapper.js";
+
+let wat = `
+(module
+    (func $main (export "main")
+        call $catcher
+        i64.const 3127057505886423800
+        i64.const -5049743701649469475
+        i32.const 1279394412
+        i32.const 1249280136
+        i32.const -851957055
+        i32.div_s
+        i32.div_s
+        select (result i64)
+        loop (result i64)
+        i64.const 7052281334997434446
+        f64.const 0x1.f0094d7063744p+967
+        i64.trunc_sat_f64_u
+        i64.rem_s
+        end
+        i64.add
+        f64.convert_i64_s
+        i32.const 993640798
+        i32.const -291103156
+        i32.atomic.rmw.xor offset=36014
+        f32.const 0x1.2e5784p-38
+        i64.const 8981315711995315489
+        i64.const 5932917051412947299
+        i64.const 6207621187208520631
+        i32.const 1636976966
+        select (result i64)
+        i64.ne
+        br 0
+        i32.trunc_sat_f32_u
+        i32.div_s
+        f64.const 0x1.7c35ecf56d865p-116
+        i32.const 1987656988
+        i64.const -1947434429939530388
+        f64.const -0x1.ca05a27b00c85p+372
+        i64.const 5169929820610505455
+        block (param f64 i32 f64 i32 i64 f64 i64)
+        drop
+        drop
+        drop
+        drop
+        drop
+        drop
+        drop
+        end
+    )
+    (func $empty (param i32))
+    (func $catcher (type 0)
+        try
+        i32.const 1
+        call $empty
+        catch_all
+        throw $exc
+        end
+        call $main
+    )
+    (memory 32 64)
+    (tag $exc)
+)
+`;
+
+async function test() {
+    let instance = await instantiate(wat, {}, {threads: true, exceptions: true});
+
+    let caughtCount = 0;
+    for (let i = 0; i < 10; i ++) {
+        try {
+            instance.exports.main();
+        } catch (e) {
+            // We expect to either overflow the stack, or end up throwing $exc back into JavaScript.
+            assert.truthy(e instanceof RangeError || e instanceof WebAssembly.Exception);
+            caughtCount ++;
+        }
+    }
+
+    assert.eq(caughtCount, 10);
+}
+
+assert.asyncTest(test());
diff --git a/JSTests/wasm/stress/rethrow-should-set-callsite-index.js b/JSTests/wasm/stress/rethrow-should-set-callsite-index.js
new file mode 100644
index 000000000000..07519a62e491
--- /dev/null
+++ b/JSTests/wasm/stress/rethrow-should-set-callsite-index.js
@@ -0,0 +1,40 @@
+import * as assert from '../assert.js';
+import { instantiate } from "../wabt-wrapper.js";
+
+let wat = `
+(module
+    (type (func))
+    (tag $exc (type 0))
+    (func $empty)
+    (func $rethrower
+        try
+            call $empty
+            throw $exc
+        catch_all
+            rethrow 0
+        end
+    )
+    (func $call-rethrower
+        call $rethrower
+    )
+    (export "call_rethrower" (func $call-rethrower))
+)
+`;
+
+async function test() {
+    let instance = await instantiate(wat, {}, {exceptions: true});
+
+    let caughtCount = 0;
+    for (let i = 0; i < 10000; i ++) {
+        try {
+            instance.exports.call_rethrower();
+        } catch (e) {
+            assert.instanceof(e, WebAssembly.Exception);
+            caughtCount ++;
+        }
+    }
+
+    assert.eq(caughtCount, 10000);
+}
+
+assert.asyncTest(test());
diff --git a/JSTests/wasm/stress/throw-should-set-callsite-index.js b/JSTests/wasm/stress/throw-should-set-callsite-index.js
new file mode 100644
index 000000000000..ee752f63010b
--- /dev/null
+++ b/JSTests/wasm/stress/throw-should-set-callsite-index.js
@@ -0,0 +1,39 @@
+import * as assert from '../assert.js';
+import { instantiate } from "../wabt-wrapper.js";
+
+let wat = `
+(module
+    (type (func))
+    (tag $exc (type 0))
+    (func $empty)
+    (func $thrower
+        try
+            call $empty
+        catch_all
+        end
+        throw $exc
+    )
+    (func $call-thrower
+        call $thrower
+    )
+    (export "call_thrower" (func $call-thrower))
+)
+`;
+
+async function test() {
+    let instance = await instantiate(wat, {}, {exceptions: true});
+
+    let caughtCount = 0;
+    for (let i = 0; i < 10000; i ++) {
+        try {
+            instance.exports.call_thrower();
+        } catch (e) {
+            assert.instanceof(e, WebAssembly.Exception);
+            caughtCount ++;
+        }
+    }
+
+    assert.eq(caughtCount, 10000);
+}
+
+assert.asyncTest(test());