CVE-2025-12428
Overview
Background
- Prototype validity cell
- A cell V8 associates with a prototype’s
Mapthat 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’sMapas a user so its validity cell is invalidated on change. - Wasm object / `WasmObjectMap`
- A GC’d WebAssembly struct/array instance whose
Mapis a Wasm map, not a normal JSis_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 acceptsIsWasmObject, 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.
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
- Build an interleaved chain
Using
--experimental-wasm-custom-descriptors, create a Wasm struct with a custom descriptor holding anexternrefJS prototype, then create a plain JS object whose__proto__is the Wasm object, as the regression testregress-447613211.jsdoes. - Warm up fast prototypes and optimized access
Access a property to make the prototypes fast, defeat constant tracking (
proto.c = 2), and run an accessorfoo(x){return x.c}in a loop so optimized code caches an assumption about the chain through the Wasm object. - 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. - Stale optimized code executes
The optimized
fookeeps 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/objects/js-objects.cc |
modified |
Files Changed
src/objects/js-objects.ccsrc/objects/map-inl.hsrc/objects/map.ccsrc/objects/map.hsrc/objects/objects-inl.h
Audit Directions
- Type-narrow predicates on chain walksLook 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 invariantAudit
is_prototype_map()-only assertions andCast<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 boundariesFor 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.
Patch
From 55496daf90227fb93311c535922f4b2142eeb72c Mon Sep 17 00:00:00 2001
From: Jakob Kummerow <jkummerow@chromium.org>
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 <ishell@chromium.org>
Auto-Submit: Jakob Kummerow <jkummerow@chromium.org>
Commit-Queue: Jakob Kummerow <jkummerow@chromium.org>
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)) &&
Regression Test / PoC
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));
Original Bug Report
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
- https://developer.chrome.com/origintrials/?hl=uk#/view_trial/619807898716864513
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/ic/accessor-assembler.cc;l=1116;drc=1fb4c56b03b105b03c45627871b15b8933ed8a11
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/ic/accessor-assembler.cc;l=1255;drc=1fb4c56b03b105b03c45627871b15b8933ed8a11
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/ic/accessor-assembler.cc;l=1949;drc=1fb4c56b03b105b03c45627871b15b8933ed8a11
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/map.cc;l=2426;drc=1fb4c56b03b105b03c45627871b15b8933ed8a11
- https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/map.cc;l=2432;drc=1fb4c56b03b105b03c45627871b15b8933ed8a11