High watch CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentWasm-custom-desc
Bug ClassType Confusion
Action tierwatch — corruption primitive but gated (flag) — not default-reachable
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.
Tracker447613211
Fix commit55496daf9022 (v8/v8) +106/-14
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedMan Yue Mo of GitHub Security Lab
Reported2025-09-26 · bounty $50000 (per vendor advisory)
Fix landed2025-10-06 (public git)
Disclosed2025-10-28
Patch gap22 days — fix public in source before the advisory

Background

Prototype validity cell
A cell V8 associates with a prototype’s Map that is invalidated whenever the prototype chain changes, so that inline caches / optimized code depending on that chain can be deoptimized.
Prototype chain tracking
The bookkeeping (RegisterPrototypeUser, InvalidatePrototypeChains, validity-cell invalidation) that walks the chain and records each prototype’s Map as a user so its validity cell is invalidated on change.
Wasm object / `WasmObjectMap`
A GC’d WebAssembly struct/array instance whose Map is a Wasm map, not a normal JS is_prototype_map() map, and which can appear in a prototype chain under custom descriptors.
`IsJSObjectThatCanBeTrackedAsPrototype` vs `IsAnyObjectThatCanBeTrackedAsPrototype`
The former accepts only non-shared JSObjects; the latter (added by the patch) also accepts IsWasmObject, so Wasm objects are no longer excluded from tracking.

Root Cause Analysis

For prototype chains that interleave JS and Wasm objects, the pre-patch tracking logic mishandled the Wasm links. In the walker (whose enclosing function name is not shown in the hunk at js-objects.cc:4936), the removed line if (IsWasmObject(*receiver)) where_to_start = kStartAtPrototype; caused the iteration to skip the receiver, and, more importantly, every tracking predicate used IsJSObjectThatCanBeTrackedAsPrototype, which returns false for Wasm objects, so a Wasm prototype was neither registered as a user nor given a PrototypeInfo/validity cell — the walk effectively bailed out at the Wasm object and stopped tracking the JS prototypes reachable through it. Consequently, when the chain beyond a Wasm object changed (e.g. a property added to or deleted from a JS prototype behind the Wasm object), the corresponding validity cell was never invalidated, so optimized code kept using a stale prototype-chain assumption — a type confusion.

The fix broadens the predicates to IsAnyObjectThatCanBeTrackedAsPrototype (JS or Wasm), casts to JSReceiver instead of JSObject, makes GetOrCreatePrototypeInfo accept a JSReceiver, and relaxes the DCHECKs to also allow IsWasmObjectMap; instead of bailing at the Wasm object, the walker now continues past Wasm objects while still tracking the JS prototypes on both sides. This restores the invariant that every prototype whose change must invalidate dependent code is registered and has its validity cell invalidated.

Key insight
The core mistake was treating a Wasm object in a prototype chain as an untrackable terminator (IsJSObjectThatCanBeTrackedAsPrototype excludes Wasm), so chain changes behind it never invalidated the validity cell; the fix tracks through Wasm links (IsAnyObjectThatCanBeTrackedAsPrototype + JSReceiver) and skips only the Wasm object itself rather than the whole tail.

Attack Path

  1. Build an interleaved chain Using --experimental-wasm-custom-descriptors, create a Wasm struct with a custom descriptor holding an externref JS prototype, then create a plain JS object whose __proto__ is the Wasm object, as the regression test regress-447613211.js does.
  2. Warm up fast prototypes and optimized access Access a property to make the prototypes fast, defeat constant tracking (proto.c = 2), and run an accessor foo(x){return x.c} in a loop so optimized code caches an assumption about the chain through the Wasm object.
  3. Mutate the chain behind the Wasm object Change/delete the JS prototype property (delete proto.c) or replace the proto; pre-patch, the validity cell for the affected prototype was never invalidated because the Wasm link stopped tracking.
  4. Stale optimized code executes The optimized foo keeps its outdated prototype-chain assumption, returning a stale value / operating on a mismatched shape — the type confusion. (inferred) Escalation from this type confusion to a concrete read/write primitive is not shown in the diff.

Impact Assessment

An attacker running JavaScript plus WebAssembly custom descriptors in the renderer can construct a JS/Wasm interleaved prototype chain whose changes fail to invalidate dependent optimized code, yielding a type confusion in V8. The advisory classifies this as Type Confusion in V8 and does not state exploited-in-the-wild; the precondition is the experimental custom-descriptors feature, and any escalation to code execution or memory corruption is (inferred).

Changed Functions

FunctionChangeNotes
if
src/objects/js-objects.cc
modified

Files Changed

  • src/objects/js-objects.cc
  • src/objects/map-inl.h
  • src/objects/map.cc
  • src/objects/map.h
  • src/objects/objects-inl.h

Audit Directions

  • Type-narrow predicates on chain walks
    Look for prototype/hidden-class tracking that filters with IsJSObject-style predicates; a non-JS but chain-participating object type (Wasm, and future host objects) can silently truncate invalidation.
  • DCHECKs encoding an exclusive invariant
    Audit is_prototype_map()-only assertions and Cast<JSObject> on prototype values; when a new object kind can be a prototype, these both crash in debug and, worse, skip real tracking in release.
  • Validity-cell coverage across feature boundaries
    For any feature that lets exotic objects sit in prototype chains (custom descriptors, proxies, exotic hosts), verify every mutation point still invalidates the validity cell for the full chain, not just the JS segment.
From 55496daf90227fb93311c535922f4b2142eeb72c Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <[email protected]>
Date: Mon, 06 Oct 2025 19:23:36 +0200
Subject: [PATCH] [wasm-custom-desc] Fix prototype validity cells

For prototype chains consisting of interleaved JS and Wasm objects,
prototype chain tracking must not bail out at the Wasm objects.

Fixed: 447613211
Change-Id: Ibd1a1ffdc7ba1a7540770eecaa908e66b3450268
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7003558
Reviewed-by: Igor Sheludko <[email protected]>
Auto-Submit: Jakob Kummerow <[email protected]>
Commit-Queue: Jakob Kummerow <[email protected]>
Cr-Commit-Position: refs/heads/main@{#102944}
---

diff --git a/src/objects/js-objects.cc b/src/objects/js-objects.cc
index 8d5ad7c..7da6d46 100644
--- a/src/objects/js-objects.cc
+++ b/src/objects/js-objects.cc
@@ -4936,11 +4936,13 @@
                                   WhereToStart where_to_start,
                                   Isolate* isolate) {
   if (!IsJSReceiver(*receiver)) return;
-  if (IsWasmObject(*receiver)) where_to_start = kStartAtPrototype;
   for (PrototypeIterator iter(isolate, Cast<JSReceiver>(receiver),
                               where_to_start);
        !iter.IsAtEnd(); iter.Advance()) {
     DirectHandle<Object> current = PrototypeIterator::GetCurrent(iter);
+#if V8_ENABLE_WEBASSEMBLY
+    if (IsWasmObject(*current)) continue;
+#endif  // V8_ENABLE_WEBASSEMBLY
     if (!IsJSObjectThatCanBeTrackedAsPrototype(*current)) return;
     DirectHandle<JSObject> current_obj = Cast<JSObject>(current);
     Tagged<Map> current_map = current_obj->map();
@@ -5079,7 +5081,11 @@
                                          Isolate* isolate) {
   // Contract: In line with InvalidatePrototypeChains()'s requirements,
   // leaf maps don't need to register as users, only prototypes do.
+#if V8_ENABLE_WEBASSEMBLY
+  DCHECK(user->is_prototype_map() || IsWasmObjectMap(*user));
+#else
   DCHECK(user->is_prototype_map());
+#endif  // V8_ENABLE_WEBASSEMBLY
 
   DirectHandle<Map> current_user = user;
   DirectHandle<PrototypeInfo> current_user_info =
@@ -5099,8 +5105,8 @@
     // change, so they don't need to be tracked as prototypes
     // anyway. Additionally, registering users of shared objects is not
     // threadsafe.
-    if (!IsJSObjectThatCanBeTrackedAsPrototype(*maybe_proto)) continue;
-    auto proto = Cast<JSObject>(maybe_proto);
+    if (!IsAnyObjectThatCanBeTrackedAsPrototype(*maybe_proto)) continue;
+    DirectHandle<JSReceiver> proto = Cast<JSReceiver>(maybe_proto);
     DirectHandle<PrototypeInfo> proto_info =
         Map::GetOrCreatePrototypeInfo(proto, isolate);
     Handle<Object> maybe_registry(proto_info->prototype_users(), isolate);
@@ -5173,7 +5179,12 @@
 // AccessorAssembler::InvalidateValidityCellIfPrototype() which does pre-checks
 // before jumping here.
 void InvalidateOnePrototypeValidityCellInternal(Tagged<Map> map) {
+#if V8_ENABLE_WEBASSEMBLY
+  DCHECK(map->is_prototype_map() || IsWasmObjectMap(map));
+#else
   DCHECK(map->is_prototype_map());
+#endif  // V8_ENABLE_WEBASSEMBLY
+
   if (v8_flags.trace_prototype_users) {
     PrintF("Invalidating prototype map %p 's cell\n",
            reinterpret_cast<void*>(map.ptr()));
diff --git a/src/objects/map-inl.h b/src/objects/map-inl.h
index 23736fc..c8bcf98 100644
--- a/src/objects/map-inl.h
+++ b/src/objects/map-inl.h
@@ -75,7 +75,11 @@
   Tagged<UnionOf<Smi, PrototypeInfo>> value =
       TaggedField<UnionOf<Smi, PrototypeInfo>,
                   kTransitionsOrPrototypeInfoOffset>::load(cage_base, *this);
+#if V8_ENABLE_WEBASSEMBLY
+  DCHECK(this->is_prototype_map() || IsWasmObjectMap(*this));
+#else
   DCHECK(this->is_prototype_map());
+#endif  // V8_ENABLE_WEBASSEMBLY
   return value;
 }
 RELEASE_ACQUIRE_ACCESSORS(Map, prototype_info,
@@ -588,7 +592,11 @@
 }
 
 bool Map::TryGetPrototypeInfo(Tagged<PrototypeInfo>* result) const {
+#if V8_ENABLE_WEBASSEMBLY
+  DCHECK(is_prototype_map() || IsWasmObjectMap(*this));
+#else
   DCHECK(is_prototype_map());
+#endif  // V8_ENABLE_WEBASSEMBLY
   Tagged<Object> maybe_proto_info = prototype_info();
   if (!PrototypeInfo::IsPrototypeInfoFast(maybe_proto_info)) return false;
   *result = Cast<PrototypeInfo>(maybe_proto_info);
@@ -608,10 +616,10 @@
   Tagged<Object> maybe_prototype =
       map->GetPrototypeChainRootMap(isolate)->prototype();
 
-  if (!IsJSObjectThatCanBeTrackedAsPrototype(maybe_prototype)) {
+  if (!IsAnyObjectThatCanBeTrackedAsPrototype(maybe_prototype)) {
     return false;
   }
-  *out_validity_cell_holder_map = Cast<JSObject>(maybe_prototype)->map();
+  *out_validity_cell_holder_map = Cast<JSReceiver>(maybe_prototype)->map();
   return true;
 }
 
diff --git a/src/objects/map.cc b/src/objects/map.cc
index d536848..1428c1c 100644
--- a/src/objects/map.cc
+++ b/src/objects/map.cc
@@ -2382,8 +2382,8 @@
 
 // static
 DirectHandle<PrototypeInfo> Map::GetOrCreatePrototypeInfo(
-    DirectHandle<JSObject> prototype, Isolate* isolate) {
-  DCHECK(IsJSObjectThatCanBeTrackedAsPrototype(*prototype));
+    DirectHandle<JSReceiver> prototype, Isolate* isolate) {
+  DCHECK(IsAnyObjectThatCanBeTrackedAsPrototype(*prototype));
   {
     Tagged<PrototypeInfo> prototype_info;
     if (prototype->map()->TryGetPrototypeInfo(&prototype_info)) {
diff --git a/src/objects/map.h b/src/objects/map.h
index 0059008..2ff17d2 100644
--- a/src/objects/map.h
+++ b/src/objects/map.h
@@ -208,7 +208,7 @@
 // |               |   - is_deprecated (bit 24)                      |
 // |               |   - is_unstable (bit 25)                        |
 // |               |   - is_migration_target (bit 26)                |
-// |               |   - is_extensible (bit 28)                      |
+// |               |   - is_extensible (bit 27)                      |
 // |               |   - may_have_interesting_properties (bit 28)    |
 // |               |   - construction_counter (bit 29..31)           |
 // |               |                                                 |
@@ -219,6 +219,7 @@
 // | TaggedPointer | [prototype]                                     |
 // +---------------+-------------------------------------------------+
 // | TaggedPointer | [constructor_or_back_pointer_or_native_context] |
+// |               | [WasmTypeInfo] (if Wasm map)                    |
 // +---------------+-------------------------------------------------+
 // | TaggedPointer | [instance_descriptors] (if JS object)           |
 // |               | [custom_descriptor]    (if WasmStruct)          |
@@ -509,7 +510,7 @@
   // PrototypeInfo is created lazily using this helper (which installs it on
   // the given prototype's map).
   static DirectHandle<PrototypeInfo> GetOrCreatePrototypeInfo(
-      DirectHandle<JSObject> prototype, Isolate* isolate);
+      DirectHandle<JSReceiver> prototype, Isolate* isolate);
   static DirectHandle<PrototypeInfo> GetOrCreatePrototypeInfo(
       DirectHandle<Map> prototype_map, Isolate* isolate);
   inline bool should_be_fast_prototype_map() const;
diff --git a/src/objects/objects-inl.h b/src/objects/objects-inl.h
index 93f6111..0076c0e 100644
--- a/src/objects/objects-inl.h
+++ b/src/objects/objects-inl.h
@@ -95,11 +95,6 @@
          TaggedIndex::IsValid(Tagged<TaggedIndex>(obj.ptr()).value());
 }
 
-bool IsJSObjectThatCanBeTrackedAsPrototype(Tagged<Object> obj) {
-  return IsHeapObject(obj) &&
-         IsJSObjectThatCanBeTrackedAsPrototype(Cast<HeapObject>(obj));
-}
-
 #define IS_TYPE_FUNCTION_DEF(type_)                                          \
   bool Is##type_(Tagged<Object> obj) {                                       \
     return IsHeapObject(obj) && Is##type_(Cast<HeapObject>(obj));            \
@@ -506,6 +501,11 @@
 #endif
 }
 
+bool IsJSObjectThatCanBeTrackedAsPrototype(Tagged<Object> obj) {
+  return IsHeapObject(obj) &&
+         IsJSObjectThatCanBeTrackedAsPrototype(Cast<HeapObject>(obj));
+}
+
 bool IsJSObjectThatCanBeTrackedAsPrototype(Tagged<HeapObject> obj) {
   // Do not optimize objects in the shared heap because it is not
   // threadsafe. Objects in the shared heap have fixed layouts and their maps
@@ -513,6 +513,19 @@
   return IsJSObject(obj) && !HeapLayout::InWritableSharedSpace(*obj);
 }
 
+bool IsAnyObjectThatCanBeTrackedAsPrototype(Tagged<Object> obj) {
+  return IsHeapObject(obj) &&
+         IsAnyObjectThatCanBeTrackedAsPrototype(Cast<HeapObject>(obj));
+}
+
+bool IsAnyObjectThatCanBeTrackedAsPrototype(Tagged<HeapObject> obj) {
+  // Do not optimize objects in the shared heap because it is not
+  // threadsafe. Objects in the shared heap have fixed layouts and their maps
+  // never change.
+  return (IsJSObject(obj) || IsWasmObject(obj)) &&
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/regress/wasm/regress-447613211.js b/test/mjsunit/regress/wasm/regress-447613211.js
new file mode 100644
index 0000000..aebd2b85
--- /dev/null
+++ b/test/mjsunit/regress/wasm/regress-447613211.js
@@ -0,0 +1,55 @@
+// Copyright 2025 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: --experimental-wasm-custom-descriptors
+
+d8.file.execute("test/mjsunit/wasm/wasm-module-builder.js");
+
+let builder = new WasmModuleBuilder();
+
+builder.startRecGroup();
+let $desc0 = builder.nextTypeIndex() + 1;
+let $struct0 = builder.addStruct({ descriptor: $desc0 });
+/* $desc0 */ builder.addStruct({
+  describes: $struct0,
+  fields: [makeField(kWasmExternRef, false)],
+});
+builder.endRecGroup();
+
+builder.addFunction("make", makeSig([kWasmExternRef], [kWasmAnyRef]))
+  .exportFunc()
+  .addBody([
+    kExprLocalGet, 0,
+    kGCPrefix, kExprStructNew, $desc0,
+    kGCPrefix, kExprStructNew, $struct0,
+  ]);
+
+let instance = builder.instantiate();
+
+let proto = {};
+proto.inobj0 = 1;
+proto.inobj1 = 1;
+proto.inobj2 = 1;
+proto.inobj3 = 1;
+proto.c = 1;
+
+let wasm_obj = instance.exports.make(proto);
+wasm_obj.a;   // Make prototypes fast.
+proto.c = 2;  // Defeat constant tracking.
+
+let obj = {__proto__: wasm_obj};
+
+function foo(x) {
+  return x.c;
+}
+
+for (let i = 0; i < 20; i++) {
+  foo(obj);
+}
+
+assertEquals(2, foo(obj));
+delete proto.c;
+assertEquals(undefined, foo(obj));
+obj.__proto__ = {};
+assertEquals(undefined, foo(obj));
Loading diff…

Original Bug Report

reported by [Deleted User]

Type confusion in inline cache prototype loading with Webassembly object prototype

Vulnerability details

When loading properties from prototype, inline cache caches the prototype property holder and accesses its properties via optimize handlers. In order to avoid map changes in the prototype which may invalidate assumptions in the optimized handlers, inline cache also stores the PrototypeChainValidityCell, which gets invalidated when a map change happens in the prototype chain. The GetOrCreatePrototypeChainValidityCell function calls TryGetValidityCellHolderMap, which returns Map::kNoValidityCellSentinel when the prototype is not a JSObject.

bool Map::TryGetValidityCellHolderMap(
    Tagged<Map> map, Isolate* isolate,
    Tagged<Map>* out_validity_cell_holder_map) {
  ...
  if (!IsJSObjectThatCanBeTrackedAsPrototype(maybe_prototype)) {   //<--- returns false if prototype is not a JSObject
    return false;
  }
  *out_validity_cell_holder_map = Cast<JSObject>(maybe_prototype)->map();
  return true;
}
...
Handle<UnionOf<Smi, Cell>> Map::GetOrCreatePrototypeChainValidityCell(
    DirectHandle<Map> map, Isolate* isolate,
    DirectHandle<PrototypeInfo>* out_prototype_info) {
  DirectHandle<Map> validity_cell_holder_map;
  {
    Tagged<Map> holder_map;
    if (!TryGetValidityCellHolderMap(*map, isolate, &holder_map)) {
      // Prototype value is not a JSObject.
      return handle(Map::kNoValidityCellSentinel, isolate);  //<------ returns Map::kNoValidityCellSentinel
    }
  ...

This becomes problematic with the custom descriptors proposal in WebAssembly, which allows Wasm objects to have prototypes. Consider an object with the following prototype chain:

obj ({}) -> wasm_obj -> obj_1 ({a : 1})

When accessing the property a, the prototype chain will be followed, which then fetches the property a from obj_1. The prototype obj_1 is then cached in the inline cache handler, but because wasm_obj is not a JSObject, the validity_cell in the handler is going to be Map::kNoValidityCellSentinel, allowing the prototype validity cell check to be bypassed:

TNode<MaybeObject> AccessorAssembler::CheckPrototypeValidityCell(
    TNode<Object> maybe_validity_cell, Label* miss) {
  TVARIABLE(MaybeObject, var_cell_value,
            SmiConstant(Map::kNoValidityCellSentinel));

  Label done(this);
  GotoIf(TaggedEqual(maybe_validity_cell,
                     SmiConstant(Map::kNoValidityCellSentinel)),
         &done);
  ...

In particular, any change in the map of obj_1 will not invalidate the inline cache handler, causing type confusion.

As there is currently an origin trial for the Webassembly custom descriptors, anyone can register for the origin trial and host a website that can trigger this bug.

Thank you very much for your help and please let me know if there is anything I can help.

REPRODUCTION CASE

To test locally, run the poc.js with the --experimental-wasm-custom-descriptors flag and the --allow-natives-syntax flag to print out debug message. The flag --experimental-wasm-custom-descriptors is only needed for local testing to emulate the origin trial. It should cause an OOB access which then loads an object from outside of the boundary of proto (A heap number map in my case).

VERSION v8 commit d32e674 OS: Ubuntu 24.04 LTS

CREDIT INFORMATION Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited?

Reporter credit: Man Yue Mo of GitHub Security Lab

View on issue tracker