CVE-2026-76047
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/compiler/access-info.cc |
modified | |
Basetest/mjsunit/compiler/regress-541251902.js |
modified | |
constructortest/mjsunit/compiler/regress-541251902.js |
modified | |
Ptest/mjsunit/compiler/regress-541251902.js |
modified | |
fortest/mjsunit/compiler/regress-541251902.js |
modified |
Files Changed
src/compiler/access-info.ccsrc/compiler/access-info.htest/mjsunit/compiler/regress-541251902.js
Patch
From c69bace374926ba465a755787cdefaf2c6785e29 Mon Sep 17 00:00:00 2001 From: Marco Vitale <[email protected]> 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 <[email protected]> Reviewed-by: Leszek Swirski <[email protected]> Auto-Submit: Marco Vitale <[email protected]> Commit-Queue: Marco Vitale <[email protected]> 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); +}
Regression Test / PoC
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);
+}
Original Bug Report
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
Setkey and therefore stores a raw Smi identity hash in the same field; - both objects retain the same map, so the optimized
CheckMapsaccepts 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:
Factory::NewJSProxygives every non-callable Proxy in the context the sameproxy_map.- That map has
is_dictionary_map()set. SetHashAndUpdatePropertiesreplaces an empty property dictionary withSmi::FromInt(hash)when an identity hash is requested.new Set([proxy])reaches this path.JSProxy::SetPrivateSymboladds a class-private field by installing a realNameDictionary, 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.jspoc-helper.jspoc.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
- 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. - It creates genuine class-private
#tNames. By timing#t in objectagainst controlledNameDictionarycollisions, it recovers the low 22 hash bits of a hidden private Name without materializing that Name as a JavaScript value. - It warms a private-field getter naturally. Four independent getters reach Maglev after ordinary
calls; no
%Optimize*intrinsic is used. - 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.
- A second confused return constructs a fake sequential one-byte string using static-root data. Its
charCodeAtmethod is the first memory-read primitive. - That read discovers a live payload array, the current Realm’s
JSArraymap, and its actual elements backing store. These are derived at runtime rather than supplied as leaked addresses. - 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:
- reads its word with
AAR; - calls
AAW(address, 0x41424344); - reads the same address back with
AAR; - 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-graphshows four actualLoadDictionaryField <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=100moved 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()andprint()are d8 harness conveniences. They can be replaced by concatenating the helper and usingconsole.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:
- train an optimized private-property load on a Proxy with a real
NameDictionary; - create an identity hash on a second Proxy without installing the private field;
- call the optimized getter on the second Proxy;
- verify that both Maglev and TurboFan take the fallback and throw the normal private-field
TypeErrorwithout 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