Chrome · V8 › WASM
CVE-2026-95306
Type Confusion in V8 (WASM)
Overview
High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
Background
- `WASM_LIFTOFF_SETUP` frame
- a transient V8 stack frame built by
Generate_WasmLiftoffFrameSetupto run the Liftoff feedback-vector setup builtin before a wasm function body executes. - PAC (Pointer Authentication)
- an arm64 control-flow-integrity feature where return addresses / code pointers are signed and must be authenticated before use; authenticating an unsigned value aborts the process.
- `ComputeCallerState()`
- a
Framemethod that reconstructs the caller’sState, includingcallee_pc, while the stack profiler walks frames. - `HasTaggedOutgoingParams()`
- the sole consumer of this frame’s
callee_pc, which uses it only as a lookup key to decide GC-tagging of outgoing parameters, never as a jump/return target.
Root Cause Analysis
Generate_WasmLiftoffFrameSetup writes the WASM_LIFTOFF_SETUP type marker into the frame several instructions before it signs and spills the calling PC into kCallingPCOffset. This opens a window in which the frame is already correctly typed but its calling-PC slot still holds an unsigned value. If a sampling-profiler tick interrupts execution inside that window, the profiler walks the stack, matches the frame type, and calls WasmLiftoffSetupFrame::ComputeCallerState(). The old code set state->callee_pc = CallingPC(), which authenticates the slot as a signed pointer. Because the slot is not yet signed, PAC authentication fails and aborts the process on arm64. The tracker records this as a type confusion in the frame’s PC-slot state: a frame whose type says “signed PC present” while the slot actually holds a raw, unsigned value.Key insight
The calling-PC slot is only ever used as a lookup key in
HasTaggedOutgoingParams(), never dereferenced or branched to, so it can be read unauthenticated with unauthenticated_pc() and the missing/invalid signature is harmless.Attack Path
- Run Liftoff-tiered wasm
Execute many wasm functions so each enters
Generate_WasmLiftoffFrameSetupand repeatedly hits the feedback-vector setup builtin. - Hit the unsigned window
For each call, the frame is marked
WASM_LIFTOFF_SETUPbefore the calling PC is signed and spilled tokCallingPCOffset. - Land a profiler tick A stack-sampling profiler fires inside that window and walks the interrupted stack.
- Authenticate an unsigned slot
ComputeCallerState()callsCallingPC(), which PAC-authenticates the still-unsigned slot and fails. - Abort on arm64 The failed authentication triggers a CFI abort, crashing the process.
Impact Assessment
On arm64 with PAC enabled, the observable effect is a control-flow-integrity abort (process crash) triggered when a profiler samples during the narrow setup window, so the practical impact is denial of service / reliability rather than a demonstrated exploitable primitive. The tracker classifies it as a type confusion because the frame’s declared type implies a signed PC while the slot still holds an unsigned value. The condition requires both profiling to be active and precise timing against the setup builtin, which constrains reachability.
Changed Functions
| Function | Change | Notes |
|---|---|---|
fortest/mjsunit/regress/wasm/regress-562455563.js |
modified |
Files Changed
src/execution/frames.cctest/mjsunit/regress/wasm/regress-562455563.jstest/mjsunit/mjsunit.status
Audit Directions
- Type-marker vs. field-init orderingAudit builtins/frame constructors that set a frame type marker before fully populating the frame’s fields; a profiler or GC walking that window may consume half-initialized state.
- Authenticated reads of signable slotsLook for other
ComputeCallerState()/ frame accessors that call authenticating helpers (e.g.CallingPC(), signed-PC reads) on slots that may be transiently unsigned, and switch tounauthenticated_pc()where the value is only a lookup key. - Profiler-reentrancy on transient framesReview asynchronously sampled frame types for windows where the stack-walk assumptions (signed PCs, populated slots) do not yet hold.
Patch
From 65acd1f04328e65e9eecd67f8119d123c35c0600 Mon Sep 17 00:00:00 2001
From: Daniel Lehmann <dlehmann@chromium.org>
Date: Thu, 17 Sep 2026 12:09:13 +0200
Subject: [PATCH] Reland "[wasm] Don't authenticate LiftoffSetupFrame calling PC"
This is a reland of commit 3640a862c8e6638fe42235df945b29003d000bd2
Skip the new regression test under `sandbox_hardware_support` in
mjsunit.status, as it uses `console.profile()` which is unsupported
with PKU on Linux kernels older than 6.12 (crbug.com/429173713).
Original change's description:
> [wasm] Don't authenticate LiftoffSetupFrame calling PC
>
> Generate_WasmLiftoffFrameSetup sets the WASM_LIFTOFF_SETUP frame marker
> a few instructions before it signs and spills the calling PC. A profiler
> tick in that window sees a correctly typed frame whose calling PC slot
> is still unsigned, and ComputeCallerState() authenticated it, which
> aborts on arm64 with control flow integrity.
>
> Read the slot unauthenticated instead. It only serves as a lookup key in
> HasTaggedOutgoingParams(), never as a branch target.
>
> Bug: 560536735
> Fixed: 562455563
TAG=agy
CONV=b2d96b5c-8e0b-4e74-8be9-54627a91f304
Bug: 560536735
Change-Id: I878562ab376eee8ca6c52accb4e3905bb6ffc266
Fixed: 562455563
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8420943
Auto-Submit: Daniel Lehmann <dlehmann@chromium.org>
Commit-Queue: Matthias Liedtke <mliedtke@chromium.org>
Owners-Override: Matthias Liedtke <mliedtke@chromium.org>
Reviewed-by: Jakob Kummerow <jkummerow@chromium.org>
Commit-Queue: Daniel Lehmann <dlehmann@chromium.org>
Reviewed-by: Matthias Liedtke <mliedtke@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109909}
---
diff --git a/src/execution/frames.cc b/src/execution/frames.cc
index 329e0bb..51055f1 100644
--- a/src/execution/frames.cc
+++ b/src/execution/frames.cc
@@ -3976,7 +3976,13 @@
void WasmLiftoffSetupFrame::ComputeCallerState(State* state) const {
TypedFrame::ComputeCallerState(state);
- state->callee_pc = CallingPC();
+ // Unlike {CallingPC}, which only runs at safepoints, this can run on a
+ // profiler tick: the setup builtin sets the frame marker a few instructions
+ // before it signs and spills the calling PC, so the slot can still be
+ // unsigned here. Don't authenticate it; {callee_pc} is only a lookup key,
+ // never a branch target.
+ state->callee_pc = unauthenticated_pc(reinterpret_cast<Address*>(
+ fp() + WasmLiftoffSetupFrameConstants::kCallingPCOffset));
}
void WasmLiftoffSetupFrame::Iterate(RootVisitor* v) const {
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index d36924d..c202b92 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -3305,6 +3305,7 @@
'regress/wasm/regress-392928805': [SKIP],
'regress/wasm/regress-406043349': [SKIP],
'regress/wasm/regress-413963387': [SKIP],
+ 'regress/wasm/regress-562455563': [SKIP],
'wasm/interrupt-worker-with-perf': [SKIP],
'wasm/liftoff-trap-handler': [SKIP],
'wasm/log-code-after-post-message': [SKIP],
diff --git a/test/mjsunit/regress/wasm/regress-562455563.js b/test/mjsunit/regress/wasm/regress-562455563.js
new file mode 100644
index 0000000..28f82f9
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-562455563.js
@@ -0,0 +1,40 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --cpu-profiler-sampling-interval=1
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+// Profile while many Wasm functions run the Liftoff feedback vector setup
+// builtin for the first time. That builtin sets the WASM_LIFTOFF_SETUP frame
+// marker a few instructions before it spills its calling PC, and a tick landing
+// in that window used to abort on arm64 with control flow integrity.
+
+const kNumFunctions = 100;
+
+const builder = new WasmModuleBuilder();
+const $sig_v_v = builder.addType(kSig_v_v);
+// Callee of the unreachable call below, which is what makes the functions
+// require a feedback vector in the first place.
+const $dummy = builder.addFunction('dummy', $sig_v_v).addBody([]);
+
+let main_body = [];
+for (let i = 0; i < kNumFunctions; i++) {
+ const callee = builder.addFunction(undefined, $sig_v_v).addBody([
+ kExprI32Const, 0,
+ kExprIf, kWasmVoid,
+ // An unreachable call nevertheless requires a feedback vector entry.
+ kExprCallFunction, $dummy.index,
+ kExprEnd,
+ ]);
+ main_body.push(kExprCallFunction, ...wasmUnsignedLeb(callee.index));
+}
+builder.addFunction('main', $sig_v_v).exportFunc().addBody(main_body);
+
+const instance = builder.instantiate();
+
+// Note: no {console.profileEnd()}, which would dump the profile into a
+// "v8.prof" file in the current working directory.
+console.profile();
+instance.exports.main();
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index d36924d..c202b92 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -3305,6 +3305,7 @@
'regress/wasm/regress-392928805': [SKIP],
'regress/wasm/regress-406043349': [SKIP],
'regress/wasm/regress-413963387': [SKIP],
+ 'regress/wasm/regress-562455563': [SKIP],
'wasm/interrupt-worker-with-perf': [SKIP],
'wasm/liftoff-trap-handler': [SKIP],
'wasm/log-code-after-post-message': [SKIP],
diff --git a/test/mjsunit/regress/wasm/regress-562455563.js b/test/mjsunit/regress/wasm/regress-562455563.js
new file mode 100644
index 0000000..28f82f9
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-562455563.js
@@ -0,0 +1,40 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --cpu-profiler-sampling-interval=1
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+// Profile while many Wasm functions run the Liftoff feedback vector setup
+// builtin for the first time. That builtin sets the WASM_LIFTOFF_SETUP frame
+// marker a few instructions before it spills its calling PC, and a tick landing
+// in that window used to abort on arm64 with control flow integrity.
+
+const kNumFunctions = 100;
+
+const builder = new WasmModuleBuilder();
+const $sig_v_v = builder.addType(kSig_v_v);
+// Callee of the unreachable call below, which is what makes the functions
+// require a feedback vector in the first place.
+const $dummy = builder.addFunction('dummy', $sig_v_v).addBody([]);
+
+let main_body = [];
+for (let i = 0; i < kNumFunctions; i++) {
+ const callee = builder.addFunction(undefined, $sig_v_v).addBody([
+ kExprI32Const, 0,
+ kExprIf, kWasmVoid,
+ // An unreachable call nevertheless requires a feedback vector entry.
+ kExprCallFunction, $dummy.index,
+ kExprEnd,
+ ]);
+ main_body.push(kExprCallFunction, ...wasmUnsignedLeb(callee.index));
+}
+builder.addFunction('main', $sig_v_v).exportFunc().addBody(main_body);
+
+const instance = builder.instantiate();
+
+// Note: no {console.profileEnd()}, which would dump the profile into a
+// "v8.prof" file in the current working directory.
+console.profile();
+instance.exports.main();
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page