CVE-2026-93377
Overview
Background
- Wasm object
- a
WebAssemblyGC value (structorarray) exposed to JavaScript as an opaque object with a fixed, immutable memory layout described by itsMap. - `Map` transition
- the process by which V8 changes an object’s hidden class (
Map) to a new one when a property is added, giving the object a different field layout. - Private symbol store
- writing a property keyed by a private symbol (including class
#fieldreturn-override), which normally triggers a layout-extendingMaptransition viaLookupIterator. - `ExtendingNonExtensible`
- the
LookupIteratorpredicate that decides whether adding a private field to a non-extensible receiver is permitted or must be blocked.
Root Cause Analysis
When a private symbol (or a class #field via constructor return-override) was stored on a Wasm struct or array, the store flowed through LookupIterator and reached ExtendingNonExtensible in lookup-inl.h, which only special-cased always-shared-space objects and otherwise allowed the private field to be added. Because Wasm objects have a fixed layout tied to their Map, transitioning that Map to add a field violated the invariant that a Wasm object’s Map must never change, leaving an object whose runtime Map no longer matched its true Wasm type. This produced a type confusion: subsequent code trusting the object’s Wasm type could operate on a mismatched layout.
The fix adds an IsWasmObjectMap(receiver_map) check (guarded by V8_ENABLE_WEBASSEMBLY) that returns true early, treating Wasm objects like shared objects so the store throws a TypeError instead of transitioning the Map. A corresponding DCHECK(!IsWasmObject(*receiver)) in lookup.cc enforces that a Wasm receiver never reaches the transition path.
ExtendingNonExtensible guarded shared-space objects but not Wasm objects, so private-field stores could transition a Wasm object’s fixed Map; the fix closes the gap by disallowing map transitions for any IsWasmObjectMap receiver, matching the existing shared-object rule.Attack Path
- Obtain a Wasm GC object
Instantiate a module exporting functions that return a
structorarrayand get a live Wasm object reference in JavaScript. - Create a private key
Produce a private symbol (or use a class with a
#fieldand aBaseconstructor that returns the Wasm object via return-override). - Store the private field
Assign through the private key, driving the store into
LookupIteratorand reachingExtendingNonExtensible. - Force the illegal transition
With the pre-patch code, the store is permitted and transitions the Wasm object’s
Mapto a new extended layout that no longer matches its Wasm type. - Exploit the confusion
Manipulate the object through code that relies on its original Wasm type/layout, achieving type confusion between the transitioned
Mapand the object’s true fixed layout.
Impact Assessment
Map, which can be leveraged toward memory corruption and further exploitation. The only precondition is the ability to execute script that instantiates a WebAssembly GC module and perform a private-symbol/#field store on a returned object. It requires no special privileges beyond ordinary in-page script execution.Changed Functions
| Function | Change | Notes |
|---|---|---|
Basetest/mjsunit/wasm/gc-js-interop-private-symbols.js |
modified | |
constructortest/mjsunit/wasm/gc-js-interop-private-symbols.js |
modified | |
DerivedWithPrivateFieldtest/mjsunit/wasm/gc-js-interop-private-symbols.js |
modified |
Files Changed
src/objects/lookup.ccsrc/objects/lookup-inl.htest/mjsunit/wasm/gc-js-interop-private-symbols.js
Audit Directions
- Fixed-layout receivers in transition pathsAudit every
LookupIteratorpredicate and transition site that special-cases shared objects to confirm Wasm objects and other fixed-layout receivers are equally excluded. - Private-symbol and return-override storesReview paths that add private fields (private symbols, class
#field, constructor return-override) for objects whoseMapmust remain immutable. - Opaque V8 object typesLook for other object classes with a fixed
Map-tied layout (shared objects, Wasm, sandboxed externals) that could be mutated by property-add operations lacking a guard.
Patch
From 22e5290486f46e9c908dbb840ca7738afb23797b Mon Sep 17 00:00:00 2001 From: Daniel Lehmann <[email protected]> Date: Thu, 17 Sep 2026 15:50:35 +0200 Subject: [PATCH] [objects] Forbid adding private symbols to Wasm objects Wasm objects have a fixed layout and must not transition their maps. Harden LookupIterator::ExtendingNonExtensible by generally disallowing map transitions for Wasm objects. This matches the existing check for shared objects. This causes private symbol stores on Wasm objects to throw a TypeError instead of transitioning their map. [email protected], [email protected] TAG=agy CONV=c66784a6-5c96-4c98-9763-0148a1c0b03d Bug: 560121552 Change-Id: I2c569670746901123359a9abce04fbbd55f1719d Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8421259 Reviewed-by: Leszek Swirski <[email protected]> Auto-Submit: Daniel Lehmann <[email protected]> Commit-Queue: Daniel Lehmann <[email protected]> Reviewed-by: Jakob Kummerow <[email protected]> Cr-Commit-Position: refs/heads/main@{#109922} --- diff --git a/src/objects/lookup-inl.h b/src/objects/lookup-inl.h index c737386..45a4cf4 100644 --- a/src/objects/lookup-inl.h +++ b/src/objects/lookup-inl.h @@ -294,6 +294,13 @@ if (IsAlwaysSharedSpaceJSObjectMap(receiver_map)) { return true; } +#if V8_ENABLE_WEBASSEMBLY + // Wasm objects have a fixed layout and must never transition their map. + if (IsWasmObjectMap(receiver_map)) { + return true; + } +#endif // V8_ENABLE_WEBASSEMBLY + // Extending non-extensible objects with private fields is currently allowed, // but we're disallowing it soon. DCHECK(!receiver_map->is_extensible()); diff --git a/src/objects/lookup.cc b/src/objects/lookup.cc index 07834bc..872d78c 100644 --- a/src/objects/lookup.cc +++ b/src/objects/lookup.cc @@ -672,6 +672,9 @@ DCHECK_IMPLIES(!receiver.is_identical_to(GetStoreTarget<JSReceiver>()), name_for_transition()->IsAnyPrivateName()); DCHECK(!IsAlwaysSharedSpaceJSObject(*receiver)); +#if V8_ENABLE_WEBASSEMBLY + DCHECK(!IsWasmObject(*receiver)); +#endif if (state_ == TRANSITION) return; if (!IsElement() && name_for_transition()->IsAnyPrivate()) { diff --git a/test/mjsunit/wasm/gc-js-interop-private-symbols.js b/test/mjsunit/wasm/gc-js-interop-private-symbols.js new file mode 100644 index 0000000..9215406 --- /dev/null +++ b/test/mjsunit/wasm/gc-js-interop-private-symbols.js @@ -0,0 +1,83 @@ +// 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 + +d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js'); + +// Wasm objects are opaque and have a fixed layout; their maps must never +// transition and no properties may be added to them. + +const builder = new WasmModuleBuilder(); +const $struct = builder.addStruct([makeField(kWasmI64, true)]); +const $array = builder.addArray(kWasmI64); + +builder.addFunction('makeStruct', makeSig([], [wasmRefType($struct)])) + .exportFunc() + .addBody([ + ...wasmI64Const(0), + kGCPrefix, kExprStructNew, $struct, + ]); + +builder.addFunction('makeArray', makeSig([], [wasmRefType($array)])) + .exportFunc() + .addBody([ + ...wasmI64Const(0), + ...wasmI32Const(1), + kGCPrefix, kExprArrayNew, $array, + ]); + +builder.addFunction('isStruct', makeSig([kWasmExternRef], [kWasmI32])) + .exportFunc() + .addBody([ + kExprLocalGet, 0, + kGCPrefix, kExprAnyConvertExtern, + kGCPrefix, kExprRefTest, $struct, + ]); + +builder.addFunction('isArray', makeSig([kWasmExternRef], [kWasmI32])) + .exportFunc() + .addBody([ + kExprLocalGet, 0, + kGCPrefix, kExprAnyConvertExtern, + kGCPrefix, kExprRefTest, $array, + ]); + +const instance = builder.instantiate(); +const privateSymbol = %CreatePrivateSymbol('private'); + +// Structs and arrays must retain their type identity after failed assignment. +const wasmStruct = instance.exports.makeStruct(); +assertEquals(1, instance.exports.isStruct(wasmStruct)); +assertThrows(() => wasmStruct[privateSymbol] = 1, TypeError); +assertEquals(1, instance.exports.isStruct(wasmStruct)); + +const wasmArray = instance.exports.makeArray(); +assertEquals(1, instance.exports.isArray(wasmArray)); +assertThrows(() => wasmArray[privateSymbol] = 1, TypeError); +assertEquals(1, instance.exports.isArray(wasmArray)); + +// Standard JS private fields (#field) on class return-override must also throw +// and preserve type identity. +class Base { + constructor(o) { + return o; + } +} +class DerivedWithPrivateField extends Base { + #field = 42; +} + +assertThrows(() => new DerivedWithPrivateField(wasmStruct), TypeError); +assertEquals(1, instance.exports.isStruct(wasmStruct)); + +assertThrows(() => new DerivedWithPrivateField(wasmArray), TypeError); +assertEquals(1, instance.exports.isArray(wasmArray)); + +// Identity hashes used by WeakMap/WeakSet must continue to work. +const wm = new WeakMap(); +wm.set(wasmStruct, 'struct-value'); +wm.set(wasmArray, 'array-value'); +assertEquals('struct-value', wm.get(wasmStruct)); +assertEquals('array-value', wm.get(wasmArray));
Regression Test / PoC
diff --git a/test/mjsunit/wasm/gc-js-interop-private-symbols.js b/test/mjsunit/wasm/gc-js-interop-private-symbols.js
new file mode 100644
index 0000000..9215406
--- /dev/null
+++ b/test/mjsunit/wasm/gc-js-interop-private-symbols.js
@@ -0,0 +1,83 @@
+// 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
+
+d8.file.execute('test/mjsunit/wasm/wasm-module-builder.js');
+
+// Wasm objects are opaque and have a fixed layout; their maps must never
+// transition and no properties may be added to them.
+
+const builder = new WasmModuleBuilder();
+const $struct = builder.addStruct([makeField(kWasmI64, true)]);
+const $array = builder.addArray(kWasmI64);
+
+builder.addFunction('makeStruct', makeSig([], [wasmRefType($struct)]))
+ .exportFunc()
+ .addBody([
+ ...wasmI64Const(0),
+ kGCPrefix, kExprStructNew, $struct,
+ ]);
+
+builder.addFunction('makeArray', makeSig([], [wasmRefType($array)]))
+ .exportFunc()
+ .addBody([
+ ...wasmI64Const(0),
+ ...wasmI32Const(1),
+ kGCPrefix, kExprArrayNew, $array,
+ ]);
+
+builder.addFunction('isStruct', makeSig([kWasmExternRef], [kWasmI32]))
+ .exportFunc()
+ .addBody([
+ kExprLocalGet, 0,
+ kGCPrefix, kExprAnyConvertExtern,
+ kGCPrefix, kExprRefTest, $struct,
+ ]);
+
+builder.addFunction('isArray', makeSig([kWasmExternRef], [kWasmI32]))
+ .exportFunc()
+ .addBody([
+ kExprLocalGet, 0,
+ kGCPrefix, kExprAnyConvertExtern,
+ kGCPrefix, kExprRefTest, $array,
+ ]);
+
+const instance = builder.instantiate();
+const privateSymbol = %CreatePrivateSymbol('private');
+
+// Structs and arrays must retain their type identity after failed assignment.
+const wasmStruct = instance.exports.makeStruct();
+assertEquals(1, instance.exports.isStruct(wasmStruct));
+assertThrows(() => wasmStruct[privateSymbol] = 1, TypeError);
+assertEquals(1, instance.exports.isStruct(wasmStruct));
+
+const wasmArray = instance.exports.makeArray();
+assertEquals(1, instance.exports.isArray(wasmArray));
+assertThrows(() => wasmArray[privateSymbol] = 1, TypeError);
+assertEquals(1, instance.exports.isArray(wasmArray));
+
+// Standard JS private fields (#field) on class return-override must also throw
+// and preserve type identity.
+class Base {
+ constructor(o) {
+ return o;
+ }
+}
+class DerivedWithPrivateField extends Base {
+ #field = 42;
+}
+
+assertThrows(() => new DerivedWithPrivateField(wasmStruct), TypeError);
+assertEquals(1, instance.exports.isStruct(wasmStruct));
+
+assertThrows(() => new DerivedWithPrivateField(wasmArray), TypeError);
+assertEquals(1, instance.exports.isArray(wasmArray));
+
+// Identity hashes used by WeakMap/WeakSet must continue to work.
+const wm = new WeakMap();
+wm.set(wasmStruct, 'struct-value');
+wm.set(wasmArray, 'array-value');
+assertEquals('struct-value', wm.get(wasmStruct));
+assertEquals('array-value', wm.get(wasmArray));