High chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType confusion in V8
DescriptionType confusion in V8
ComponentV8
Bug ClassType Confusion
Tracker541251902
Fix commitc69bace37492 (v8/v8) +86/-16
CISA KEVNot listed
Creditedywatanabee
Disclosed2026-08-18

Changed Functions

FunctionChangeNotes
if
src/compiler/access-info.cc
modified
Base
test/mjsunit/compiler/regress-541251902.js
modified
constructor
test/mjsunit/compiler/regress-541251902.js
modified
P
test/mjsunit/compiler/regress-541251902.js
modified
for
test/mjsunit/compiler/regress-541251902.js
modified

Files Changed

  • src/compiler/access-info.cc
  • src/compiler/access-info.h
  • test/mjsunit/compiler/regress-541251902.js
From c69bace374926ba465a755787cdefaf2c6785e29 Mon Sep 17 00:00:00 2001
From: Marco Vitale <mrcvtl@chromium.org>
Date: Fri, 07 Aug 2026 10:18:05 +0200
Subject: [PATCH] [compiler] Ensure dictionary load fast-path only target JSObjects

AccessInfoFactory::ComputePropertyAccessInfo previously returned a
DictionaryDataField for any receiver map with is_dictionary_map() set.
This assumption is invalid for JSProxy objects as all non-callable proxies
share the context-wide proxy_map, but their properties_or_hash_ field
may hold a raw Smi identity hash rather than a NameDictionary.

This CL restricts the dictionary load fast-path to genuine JSObject
maps (excluding global objects, access checks, and interceptors).

Fixed: 541251902
Change-Id: Ic2077e96c152091139c72447b6aa351e52497c43
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8214019
Reviewed-by: Arash Kazemi <arashk@chromium.org>
Reviewed-by: Leszek Swirski <leszeks@chromium.org>
Auto-Submit: Marco Vitale <mrcvtl@chromium.org>
Commit-Queue: Marco Vitale <mrcvtl@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109121}
---

diff --git a/src/compiler/access-info.cc b/src/compiler/access-info.cc
index f82da93..a316a72 100644
--- a/src/compiler/access-info.cc
+++ b/src/compiler/access-info.cc
@@ -994,6 +994,43 @@
   return true;
 }
 
+namespace {
+
+PropertyAccessInfo TryComputeDictionaryDataFieldAccessInfo(
+    Zone* zone, MapRef map, NameRef name, OptionalObjectRef handler) {
+  if constexpr (V8_ENABLE_SWISS_NAME_DICTIONARY_BOOL) {
+    return PropertyAccessInfo::Invalid(zone);
+  }
+  if (!handler.has_value() || !handler->IsSmi()) {
+    return PropertyAccessInfo::Invalid(zone);
+  }
+
+  // Receiver map must be a standard JSObject without access checks or
+  // interceptors.
+  if (!map.is_dictionary_map() || !map.IsJSObjectMap() ||
+      IsJSGlobalObjectMap(*map.object()) || map.is_access_check_needed() ||
+      map.has_named_interceptor()) {
+    return PropertyAccessInfo::Invalid(zone);
+  }
+
+  const auto smi_handler = Cast<Smi>(*handler->object());
+  const int smi_value = smi_handler.value();
+  if (LoadHandler::GetHandlerKind(smi_handler) != LoadHandler::Kind::kNormal ||
+      !LoadHandler::IsDataPropertyBits::decode(smi_value)) {
+    return PropertyAccessInfo::Invalid(zone);
+  }
+
+  uint32_t index = LoadHandler::DictionaryIndexBits::decode(smi_value);
+  if (index == LoadHandler::DictionaryIndexBits::kMax) {
+    return PropertyAccessInfo::Invalid(zone);
+  }
+
+  return PropertyAccessInfo::DictionaryDataField(
+      zone, map, OptionalJSObjectRef(), InternalIndex(index), name);
+}
+
+}  // namespace
+
 PropertyAccessInfo AccessInfoFactory::ComputePropertyAccessInfo(
     MapRef map, NameRef name, AccessMode access_mode,
     OptionalObjectRef handler) const {
@@ -1004,22 +1041,11 @@
 
   JSHeapBroker::MapUpdaterGuardIfNeeded mumd_scope(broker());
 
-  if (map.is_dictionary_map() && access_mode == AccessMode::kLoad &&
-      handler.has_value() && handler->IsSmi()) {
-    if constexpr (V8_ENABLE_SWISS_NAME_DICTIONARY_BOOL) {
-      return Invalid();
-    }
-    auto smi_handler = Cast<Smi>(*handler->object());
-    if (LoadHandler::GetHandlerKind(smi_handler) ==
-        LoadHandler::Kind::kNormal) {
-      if (LoadHandler::IsDataPropertyBits::decode(smi_handler.value())) {
-        uint32_t index =
-            LoadHandler::DictionaryIndexBits::decode(smi_handler.value());
-        if (index != LoadHandler::DictionaryIndexBits::kMax) {
-          return PropertyAccessInfo::DictionaryDataField(
-              zone(), map, OptionalJSObjectRef(), InternalIndex(index), name);
-        }
-      }
+  if (access_mode == AccessMode::kLoad) {
+    PropertyAccessInfo access_info =
+        TryComputeDictionaryDataFieldAccessInfo(zone(), map, name, handler);
+    if (!access_info.IsInvalid()) {
+      return access_info;
     }
   }
 
diff --git a/src/compiler/access-info.h b/src/compiler/access-info.h
index d51bebad..7402929 100644
--- a/src/compiler/access-info.h
+++ b/src/compiler/access-info.h
@@ -314,6 +314,8 @@
   PropertyAccessInfo ComputeAccessorDescriptorAccessInfo(
       MapRef receiver_map, NameRef name, MapRef map, OptionalJSObjectRef holder,
       InternalIndex descriptor, AccessMode access_mode) const;
+  PropertyAccessInfo ComputeDictionaryDataFieldAccessInfo(
+      MapRef map, NameRef name, OptionalObjectRef handler) const;
 
   PropertyAccessInfo Invalid() const {
     return PropertyAccessInfo::Invalid(zone());
diff --git a/test/mjsunit/compiler/regress-541251902.js b/test/mjsunit/compiler/regress-541251902.js
new file mode 100644
index 0000000..6d53ebc
--- /dev/null
+++ b/test/mjsunit/compiler/regress-541251902.js
@@ -0,0 +1,42 @@
+// 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: --allow-natives-syntax
+
+class Base {
+  constructor(x) { return x; }
+}
+
+class P extends Base {
+  #t = 42;
+  static get_t(o) {
+    return o.#t;
+  }
+}
+
+const warm = new Proxy({}, {});
+new P(warm);
+
+%PrepareFunctionForOptimization(P.get_t);
+assertEquals(42, P.get_t(warm));
+assertEquals(42, P.get_t(warm));
+
+%OptimizeFunctionOnNextCall(P.get_t);
+assertEquals(42, P.get_t(warm));
+
+// Non-callable proxies share the context-wide proxy_map. When an identity hash
+// is assigned (e.g. via Map/Set), properties_or_hash_ contains a Smi hash instead
+// of a NameDictionary. Accessing private fields on such a proxy must safely throw
+// a TypeError instead of assuming all objects with proxy_map have a dictionary.
+const map = new Map();
+const victims = [];
+for (let i = 0; i < 25; i++) {
+  const victim = new Proxy({}, {});
+  map.set(victim, 1);
+  victims.push(victim);
+}
+
+for (const victim of victims) {
+  assertThrows(() => P.get_t(victim), TypeError);
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/compiler/regress-541251902.js b/test/mjsunit/compiler/regress-541251902.js
new file mode 100644
index 0000000..6d53ebc
--- /dev/null
+++ b/test/mjsunit/compiler/regress-541251902.js
@@ -0,0 +1,42 @@
+// 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: --allow-natives-syntax
+
+class Base {
+  constructor(x) { return x; }
+}
+
+class P extends Base {
+  #t = 42;
+  static get_t(o) {
+    return o.#t;
+  }
+}
+
+const warm = new Proxy({}, {});
+new P(warm);
+
+%PrepareFunctionForOptimization(P.get_t);
+assertEquals(42, P.get_t(warm));
+assertEquals(42, P.get_t(warm));
+
+%OptimizeFunctionOnNextCall(P.get_t);
+assertEquals(42, P.get_t(warm));
+
+// Non-callable proxies share the context-wide proxy_map. When an identity hash
+// is assigned (e.g. via Map/Set), properties_or_hash_ contains a Smi hash instead
+// of a NameDictionary. Accessing private fields on such a proxy must safely throw
+// a TypeError instead of assuming all objects with proxy_map have a dictionary.
+const map = new Map();
+const victims = [];
+for (let i = 0; i < 25; i++) {
+  const victim = new Proxy({}, {});
+  map.set(victim, 1);
+  victims.push(victim);
+}
+
+for (const victim of victims) {
+  assertThrows(() => P.get_t(victim), TypeError);
+}
Loading diff…

Original Bug Report

reported by yu...@gmail.com

V8 Maglev/TurboFan: JSProxy identity-hash Smi confused with NameDictionary, leading to in-cage AAR/AAW

Executive summary

The new optimized dictionary-load path assumes that a receiver with Map::is_dictionary_map() has a NameDictionary in JSReceiver::properties_or_hash_. That implication is false. V8 explicitly permits the field to contain a raw Smi identity hash, including when the map is a dictionary map.

This is reachable with two ordinary, non-callable JSProxy objects that share the same context-wide proxy_map:

  • the training Proxy receives a class-private property and therefore has a real NameDictionary;
  • the victim Proxy is used as a Set key and therefore stores a raw Smi identity hash in the same field;
  • both objects retain the same map, so the optimized CheckMaps accepts the victim.

The JIT then dereferences the victim’s Smi as a FixedArrayBase/NameDictionary. Under pointer compression this converts the Smi into an attacker-influenced address relative to the cage base. The generated code performs a length read followed by key, value and details reads without first checking IsSmi(properties) or the backing object’s instance type.

The attached PoC drives that confusion to reusable 32-bit and 64-bit in-cage read/write functions. It writes an attacker-selected value with AAW(address, value), reads the same address back through AAR(address), and independently observes the changed word through a normal JavaScript array. It then uses the same primitive to modify and read back a JSArray length field.

Representative result:

PRIMITIVE AAW(0x0bbc3b24, 0x41424344) AAR-before=0x00000000 AAR-after=0x41424344 JS-after=0x41424344
METADATA AAW(0x0bbc3b10, 0x00000004) AAR-before=0x00000008 AAR-after=0x00000004
VERDICT AAR=true AAW=true metadata-AAW=true native-syntax=false memory-api=false

Root cause

1. The access-info producer bypasses its existing safety gate

Commit 85db96783362 added an optimized dictionary-property load using an entry index cached in a Smi IC handler. AccessInfoFactory::ComputePropertyAccessInfo now returns DictionaryDataField before calling CanInlinePropertyAccess:

// src/compiler/access-info.cc:1007-1032
if (map.is_dictionary_map() && access_mode == AccessMode::kLoad &&
    handler.has_value() && handler->IsSmi()) {
  ...
  if (index != LoadHandler::DictionaryIndexBits::kMax) {
    return PropertyAccessInfo::DictionaryDataField(
        zone(), map, OptionalJSObjectRef(), InternalIndex(index), name);
  }
}

// Not reached for the path above.
if (!CanInlinePropertyAccess(map, access_mode)) return Invalid();

That ordering matters for a Proxy. CanInlinePropertyAccess rejects non-JSObject receiver maps and documents the invariant that optimized dictionary access normally needs a one-to-one relationship between an object, its map and its property dictionary:

// src/compiler/access-info.cc:45-68
// We can only inline accesses to dictionary mode holders if the access is a
// load and the holder is a prototype. The latter ensures a 1:1
// relationship between the map and the object (and therefore the property
// dictionary).
...
return false;

The new early return loses that invariant.

2. A dictionary map does not imply a dictionary backing object

V8’s own JSReceiver invariant explicitly permits a Smi in properties_or_hash_:

// src/objects/js-objects-inl.h:1069-1074
Tagged<JSReceiver::PropertiesOrHash> properties_or_hash_obj =
    raw_properties_or_hash(kRelaxedLoad);
DCHECK(IsSmi(properties_or_hash_obj) ||
       ((IsGlobalDictionary(properties_or_hash_obj) ||
         IsPropertyDictionary(properties_or_hash_obj)) ==
        map()->is_dictionary_map()));

Runtime dictionary accessors handle this case correctly. For example, JSReceiver::property_dictionary() tests IsSmi(prop) and returns the empty dictionary before casting. The optimized path omits the equivalent check.

The victim state is created through normal engine behavior:

  1. Factory::NewJSProxy gives every non-callable Proxy in the context the same proxy_map.
  2. That map has is_dictionary_map() set.
  3. SetHashAndUpdateProperties replaces an empty property dictionary with Smi::FromInt(hash) when an identity hash is requested. new Set([proxy]) reaches this path.
  4. JSProxy::SetPrivateSymbol adds a class-private field by installing a real NameDictionary, but does not transition the Proxy’s map.

Consequently, a Proxy containing a real dictionary and a Proxy containing only an identity-hash Smi have the same map. A monomorphic map check cannot distinguish them.

3. Maglev and TurboFan dereference the unchecked field

The x64 Maglev lowering is representative:

// src/maglev/x64/maglev-ir-x64.cc:1262-1304
__ LoadTaggedField(properties, object,
                   offsetof(JSReceiver, properties_or_hash_));

Register length = temps.Acquire();
__ movl(length, FieldOperand(properties, FixedArrayBase::kLengthOffset));
__ cmpl(length, Immediate(max_index));
__ j(below_equal, deferred_fallback);

__ LoadTaggedField(scratch, properties, key_offset);
__ CompareTaggedAndJumpIf(scratch, name().object(), kNotEqual,
                          deferred_fallback);
...
__ LoadTaggedField(result_reg, properties, value_offset);

There is no Smi or instance-type check between the first and second statements. The arm64 and loong64 implementations have the same omission.

TurboFan reaches the same condition through PropertyAccessBuilder::BuildLoadDictionaryField. In Turboshaft lowering, the field is typed as V<HeapObject> immediately:

V<HeapObject> properties = __ template LoadField<HeapObject>(
    object, AccessBuilder::ForJSObjectPropertiesOrHash());
V<Word32> length = __ template LoadField<Word32>(
    properties, AccessBuilder::ForFixedArrayLength());

That type is the assumption being violated; no runtime check establishes it.

Resulting memory access

On the tested pointer-compressed x64 build, an identity hash h is stored as the compressed Smi 2h. LoadTaggedField decompresses it as though it were a heap pointer, producing cage_base + 2h. For a cached dictionary entry index i, the optimized load reads:

purpose address relative to cage_base
FixedArrayBase::length 2h + 3
dictionary key 2h + 31 + 12i
dictionary value 2h + 35 + 12i
property details 2h + 39 + 12i

The -1 terms come from applying FieldOperand to what the JIT believes is a tagged heap object. The entry stride is 12 bytes because NameDictionary::kEntrySize == 3 and compressed tagged fields are four bytes.

The length check does not make the access safe. The identity hash chooses the low-cage base, while the IC handler supplies a 23-bit dictionary entry index. A sufficiently large training dictionary moves the key/value/details reads into attacker-populated heap pages. The key comparison and details check can be satisfied with a periodic Smi spray; the value word is then returned to JavaScript as a tagged value.

Proof of concept

Attach these files together:

  • poc.js
  • poc-helper.js
  • poc.log

The helper must remain beside the main script because the main script loads it by relative name.

Build used

Source and binary version information:

$ git rev-parse HEAD
13d9e755e47218bb5369e8283389d42276b784aa

$ out/x64.sbx/d8 --version
V8 version 15.2.0 (candidate)
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = true
v8_enable_memory_corruption_api = true
dcheck_always_on = false
symbol_level = 1

v8_enable_memory_corruption_api was compiled into this local testing binary, but it is not exposed to the script: the command does not use --expose-memory-corruption-api, typeof Sandbox is undefined, and neither attached JavaScript file calls Sandbox.MemoryView, Sandbox.getAddressOf, or any native intrinsic.

Run

From the directory containing both JavaScript files:

/path/to/v8/out/x64.sbx/d8 poc.js

No d8 flags are required.

Example full output from a fresh process:

SAFE_BUCKETS 9428
WINNER 775 RATIO 4.139240506319751
PASS no-memory-api S=0x07e27a71 home=1596706 orientation=byte3 scan-attempts=505 scan-ms=1551.6320000000005
BOOTSTRAP payload=0x04508d89 data=0x045122fc
PRIMITIVE AAW(0x0bbc3b24, 0x41424344) AAR-before=0x00000000 AAR-after=0x41424344 JS-after=0x41424344
METADATA AAW(0x0bbc3b10, 0x00000004) AAR-before=0x00000008 AAR-after=0x00000004
VERDICT AAR=true AAW=true metadata-AAW=true native-syntax=false memory-api=false

What the PoC does

  1. It creates candidate victim Proxies and selects one whose identity hash gives a mapped, sufficiently large confused length. Selection uses only Map.has() collision-chain timing and build-specific offline safety bitmaps; it does not disclose or read the Proxy address.
  2. It creates genuine class-private #t Names. By timing #t in object against controlled NameDictionary collisions, it recovers the low 22 hash bits of a hidden private Name without materializing that Name as a JavaScript value.
  3. It warms a private-field getter naturally. Four independent getters reach Maglev after ordinary calls; no %Optimize* intrinsic is used.
  4. It uses the confused dictionary load to scan candidate cage pages at the tested build’s profiled within-page Name offsets. The matching candidate returns the private Name itself.
  5. A second confused return constructs a fake sequential one-byte string using static-root data. Its charCodeAt method is the first memory-read primitive.
  6. That read discovers a live payload array, the current Realm’s JSArray map, and its actual elements backing store. These are derived at runtime rather than supplied as leaked addresses.
  7. Normal double-array stores construct two overlapping fake-array lattices, covering both four-byte alignment classes. The PoC exports:
AAR(address)                  // uint32 read
AAW(address, value)           // uint32 write, preserving the adjacent word
AAR64(address)                // returns [lo, hi]
AAW64(address, lo, hi)        // writes both uint32 halves

The interfaces accept four-byte-aligned compressed addresses in the fake arrays’ forward indexed in-cage range. They are not APIs for arbitrary 64-bit process addresses.

Write proof

The first proof target is a real JavaScript array element. The PoC:

  1. reads its word with AAR;
  2. calls AAW(address, 0x41424344);
  3. reads the same address back with AAR;
  4. independently reads target[0] through ordinary JavaScript and confirms the same low word.

The second target is the real array’s length metadata. Writing tagged Smi 4 changes its JavaScript length from four to two, and AAR reads the new metadata value back. This rules out a read-only or self-referential fake primitive: an ordinary engine object changes at an address selected after the AAR has discovered it.

Reproducibility and controls

  • The final reusable-primitive revision completed 5/5 fresh-process runs recorded in the attached log; the preceding flagless chain revision completed 10/10.
  • A diagnostic run with --print-maglev-graph shows four actual LoadDictionaryField <Symbol: #t> nodes and still reaches the final verdict. This flag is not required for reproduction.
  • A Proxy containing the trained private field follows the legitimate dictionary path and returns the expected marker.
  • An un-hashed Proxy has the empty dictionary and falls back normally. The raw Smi identity hash is the load-bearing state difference.

Limitations stated explicitly

The vulnerability and the controlled write are demonstrated, but the attached PoC is not a portable, production-quality browser exploit.

  • The full AAR/AAW chain is calibrated to the exact tested V8 revision and allocation sequence. It uses build-specific static-root values, a large-object spray base, offline safe-length bitmaps, and profiled page-relative private-Name offsets.
  • The final primitive covers four-byte-aligned addresses at or above its forged elements bases; it does not claim every byte of the cage below those bases.
  • Forced compaction using --random-gc-interval=1000 --stress-marking=100 --stress-scavenge=100 moved the private Name’s within-page offset and the production path failed 0/1. Supplying the moved offset in an assisted verifier allowed the remaining chain to complete. Therefore the unresolved stress failure is in the address-layout discovery step, not in the final AAR/AAW primitive.
  • load() and print() are d8 harness conveniences. They can be replaced by concatenating the helper and using console.log; they are not security capabilities.
  • The complete AAR/AAW chain has not yet been verified inside an unmodified Chrome binary.

No pre-existing heap leak is used in the successful default run, but that must not be confused with full layout independence. The correct claim is: address-oracle-free, exact-build in-cage AAR/AAW from the reported type confusion.

Affected versions and tiers

The Maglev optimization was introduced by 85db96783362 on 2026-05-05. The same producer and lowering were ported to TurboFan by a6c78c1b70b0 on 2026-05-19. The vulnerable code is present in V8 tag 15.0.245.23, on the Chrome 150 Stable line, as well as the tested 15.2 revision.

As of 2026-08-01, the issue is also still present in the official V8 main HEAD, 2ad1b92f41e8e86bc106b9b6095d575e16b99a48 (V8 15.3.0 candidate). Source inspection at that revision confirms that the early DictionaryDataField return still precedes CanInlinePropertyAccess, and that the Maglev and TurboFan/Turboshaft lowerings still dereference properties_or_hash_ without first excluding a Smi. The exploit PoC itself was not recalibrated or run against this HEAD revision.

The architecture-specific Maglev implementations on x64, arm64 and loong64 all load and dereference properties_or_hash_ without a Smi check. TurboFan’s lowering is architecture-independent. The PoC and the address arithmetic above were validated only on x64; the other architectures should be treated as source-affected rather than exploit-verified.

SwissNameDictionary configurations return Invalid() before constructing this access info and are not affected by this exact path.

Suggested fix

The generated code must establish the invariant it consumes. Before loading FixedArrayBase::kLengthOffset, test whether properties_or_hash_ is a Smi and branch to the existing deferred LoadIC fallback if so. The equivalent check is needed in every Maglev backend and in the TurboFan/Turboshaft lowering.

For defense in depth, the producer should also reject receiver maps that cannot guarantee the expected property backing type. Merely checking map.is_dictionary_map() is insufficient because the JSReceiver invariant explicitly permits the Smi case.

The regression test should use two objects with the same Proxy map:

  1. train an optimized private-property load on a Proxy with a real NameDictionary;
  2. create an identity hash on a second Proxy without installing the private field;
  3. call the optimized getter on the second Proxy;
  4. verify that both Maglev and TurboFan take the fallback and throw the normal private-field TypeError without reading from the Smi-derived cage address.

Operating System:Linux

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION Type of crash: browser

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: ywatanabee

View on issue tracker