CVE-2024-27851
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());