Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in V8
DescriptionInsufficient policy enforcement in V8
ComponentV8
Bug ClassLogic Error
Tracker531319201
Fix commit9cfa2ca3fee8 (v8/v8) +6/-86
CISA KEVNot listed
Creditedwang1r && lhfff
Disclosed2026-07-14

Background

`LookupIterator`
V8’s internal object that walks an object and its prototype chain to resolve a property, configured by flags that decide how far and with what filtering it searches.
`error_stack_symbol` / `error_message_symbol`
private symbols V8 uses as hidden internal slots on genuine Error objects to store the captured stack trace and message.
`PROTOTYPE_CHAIN_SKIP_INTERCEPTOR`
a LookupIterator configuration that searches the entire prototype chain (while skipping interceptors), as opposed to OWN_SKIP_INTERCEPTOR which inspects only the receiver’s own properties.
`[[ErrorData]]`
the ECMAScript internal slot marking an object as a real Error, which the stack accessor is specified to consult only on the receiver itself.

Root Cause Analysis

The .stack and .message error accessors resolved their backing private symbols through a dedicated LookupIterator constructor hardwired to PROTOTYPE_CHAIN_SKIP_INTERCEPTOR, so ErrorUtils::GetErrorStackProperty and the message path in messages.cc walked the full prototype chain instead of stopping at the receiver. This violated the invariant that an object’s internal [[ErrorData]]-style stack/message slots belong to that object alone: an ordinary object whose __proto__ is a real Error would, when its .stack or .message was read, find the private symbol on the inherited Error and return that prototype’s captured stack data.

The fix replaces both call sites with the standard LookupIterator constructor using OWN_SKIP_INTERCEPTOR, restricting the lookup to the receiver’s own slots, and deletes the special prototype-chain constructor entirely from lookup-inl.h and lookup.h so the unsafe configuration can no longer be requested. Because a non-Error receiver no longer has the private symbol as an own property, the accessor now correctly declines to expose inherited internal stack state, matching the direction of the Error Stack Accessor proposal.

Key insight
The single mistake was reading an object-internal, per-instance private slot via a prototype-chain search, letting a receiver inherit another Error’s stack; the fix confines the lookup to the receiver’s own properties with OWN_SKIP_INTERCEPTOR and removes the prototype-walking constructor so the mistake cannot recur.

Attack Path

  1. Craft an inheriting object Script creates a plain object whose prototype is a genuine Error, e.g. var e = {__proto__: new Error()}, so the object itself has no own stack slot.
  2. Trigger internal accessor Reading e.stack (or e.message, or throwing e so uncaught-exception handling reads it) invokes the V8 error accessor with e as the receiver.
  3. Prototype-chain lookup finds inherited slot The old PROTOTYPE_CHAIN_SKIP_INTERCEPTOR LookupIterator walks past e and locates the private error_stack_symbol on the inherited Error prototype.
  4. Inherited stack surfaced on non-Error receiver The accessor returns the prototype Error’s captured stack/message even though the receiver was never constructed as an Error, contrary to the intended own-slot semantics.

Impact Assessment

An attacker running JavaScript in the renderer’s V8 context gains the ability to make the internal .stack / .message accessors read [[ErrorData]]-style state off an inherited Error rather than the receiver, causing incorrect policy enforcement over which object’s captured stack trace is exposed. The only precondition is executing script that arranges a receiver with a real Error in its prototype chain and then triggering the accessor; the flaw is a logic/policy error in stack-property resolution, not itself a memory-corruption primitive per the metadata.

Changed Functions

FunctionChangeNotes
index_
src/objects/lookup-inl.h
modified
PropertyKey
src/objects/lookup-inl.h
modified

Files Changed

  • src/execution/messages.cc
  • src/objects/lookup-inl.h
  • src/objects/lookup.h
  • test/cctest/heap/test-heap.cc
  • test/cctest/test-api-stack-traces.cc
  • test/mjsunit/stack-traces-overflow.js

Audit Directions

  • Private-symbol prototype-chain lookups
    Flag any LookupIterator constructed with PROTOTYPE_CHAIN_SKIP_INTERCEPTOR (or otherwise chain-walking) that resolves internal private symbols, since such slots should almost always be read with OWN_SKIP_INTERCEPTOR.
  • Receiver-vs-holder confusion in accessors
    Review accessors that read internal-slot state to confirm they consult the receiver’s own properties rather than inherited ones, especially where an object can be given an Error (or other tagged type) as its __proto__.
  • Special-case constructors that relax invariants
    Audit constructors or helpers that exist solely to bypass a normal restriction (here, allowing private symbols beyond own-property scope), as they concentrate policy-enforcement risk and are prone to misuse.
From 9cfa2ca3fee87bd2919978d73d0ea28b93493be8 Mon Sep 17 00:00:00 2001
From: Igor Sheludko <ishell@chromium.org>
Date: Fri, 10 Jul 2026 11:34:05 +0200
Subject: [PATCH] [stack-traces] Make .stack accessors look only at receiver

... when reading internal stack trace or message (i.e. [[ErrorData]]
slot). Update tests accordingly.

This is a first step towards implementing Error Stack Accessor proposal
https://tc39.es/proposal-error-stack-accessor/#sec-get-error.prototype-stack.

Fixed: 531319201
Bug: 512450179
Change-Id: I333769d61d20c99b59250b8cbd2dcf3172b56640
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8064992
Reviewed-by: Olivier Flückiger <olivf@chromium.org>
Commit-Queue: Igor Sheludko <ishell@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108587}
---

diff --git a/src/execution/messages.cc b/src/execution/messages.cc
index cfe5228..2bd0247 100644
--- a/src/execution/messages.cc
+++ b/src/execution/messages.cc
@@ -699,8 +699,8 @@
     // is accessed the first time.
     //
     // If |recv| was not constructed with %Error%, use the "message" property.
-    LookupIterator it(isolate, LookupIterator::PROTOTYPE_CHAIN_SKIP_INTERCEPTOR,
-                      recv, isolate->factory()->error_message_symbol());
+    LookupIterator it(isolate, recv, isolate->factory()->error_message_symbol(),
+                      LookupIterator::OWN_SKIP_INTERCEPTOR);
     Handle<Object> result = JSReceiver::GetDataProperty(&it);
     if (it.IsFound() && IsUndefined(*result)) {
       msg = msg_default;
@@ -1133,9 +1133,9 @@
 // static
 ErrorUtils::StackPropertyLookupResult ErrorUtils::GetErrorStackProperty(
     Isolate* isolate, DirectHandle<JSReceiver> maybe_error_object) {
-  LookupIterator it(isolate, LookupIterator::PROTOTYPE_CHAIN_SKIP_INTERCEPTOR,
-                    maybe_error_object,
-                    isolate->factory()->error_stack_symbol());
+  LookupIterator it(isolate, maybe_error_object,
+                    isolate->factory()->error_stack_symbol(),
+                    LookupIterator::OWN_SKIP_INTERCEPTOR);
   Handle<Object> result = JSReceiver::GetDataProperty(&it);
 
   if (!it.IsFound()) {
diff --git a/src/objects/lookup-inl.h b/src/objects/lookup-inl.h
index 9dfa2ea..9981d2c 100644
--- a/src/objects/lookup-inl.h
+++ b/src/objects/lookup-inl.h
@@ -113,24 +113,6 @@
   }
 }
 
-LookupIterator::LookupIterator(Isolate* isolate, Configuration configuration,
-                               DirectHandle<JSAny> receiver,
-                               DirectHandle<Symbol> name)
-    : configuration_(configuration),
-      isolate_(isolate),
-      name_(name),
-      receiver_(receiver),
-      lookup_start_object_(receiver),
-      index_(kInvalidIndex) {
-  // This is the only lookup configuration allowed by this constructor because
-  // it's special case allowing lookup of the private symbols on the prototype
-  // chain. Usually private symbols are limited to OWN_SKIP_INTERCEPTOR lookups.
-  DCHECK(*name_ == *isolate->factory()->error_stack_symbol() ||
-         *name_ == *isolate->factory()->error_message_symbol());
-  DCHECK_EQ(configuration, PROTOTYPE_CHAIN_SKIP_INTERCEPTOR);
-  Start<false>();
-}
-
 PropertyKey::PropertyKey(Isolate* isolate, double index) {
   DCHECK_EQ(index, static_cast<uint64_t>(index));
 #if V8_TARGET_ARCH_32_BIT
diff --git a/src/objects/lookup.h b/src/objects/lookup.h
index c1f1249..c41ba1a 100644
--- a/src/objects/lookup.h
+++ b/src/objects/lookup.h
@@ -141,13 +141,6 @@
                         DirectHandle<JSAny> lookup_start_object,
                         Configuration configuration = DEFAULT);
 
-  // Special case for lookup of the |error_stack_trace| private symbol in
-  // prototype chain (usually private symbols are limited to
-  // OWN_SKIP_INTERCEPTOR lookups).
-  inline LookupIterator(Isolate* isolate, Configuration configuration,
-                        DirectHandle<JSAny> receiver,
-                        DirectHandle<Symbol> name);
-
   inline InternalIndex descriptor_number() const;
   inline InternalIndex dictionary_entry() const;
 
diff --git a/test/cctest/heap/test-heap.cc b/test/cctest/heap/test-heap.cc
index 52f90ce..7535d46 100644
--- a/test/cctest/heap/test-heap.cc
+++ b/test/cctest/heap/test-heap.cc
@@ -3939,39 +3939,13 @@
         "} catch (e) {                "
         "  error = e;                 "
         "}                            ";
-    static const char* source3 =
-        "var error = null;            "
-        /* Normal Error */
-        "try {                        "
-        /* as prototype */
-        "  throw new Error();         "
-        "} catch (e) {                "
-        "  error = {};                "
-        "  error.__proto__ = e;       "
-        "}                            ";
-    static const char* source4 =
-        "var error = null;            "
-        /* Stack overflow */
-        "try {                        "
-        /* as prototype   */
-        "  (function f() { f(); })(); "
-        "} catch (e) {                "
-        "  error = {};                "
-        "  error.__proto__ = e;       "
-        "}                            ";
     static const char* getter = "error.stack";
     static const char* setter = "error.stack = 0";
 
     ReleaseStackTraceDataTest(isolate, source1, setter);
     ReleaseStackTraceDataTest(isolate, source2, setter);
-    // We do not test source3 and source4 with setter, since the setter is
-    // supposed to (untypically) write to the receiver, not the holder.  This is
-    // to emulate the behavior of a data property.
-
     ReleaseStackTraceDataTest(isolate, source1, getter);
     ReleaseStackTraceDataTest(isolate, source2, getter);
-    ReleaseStackTraceDataTest(isolate, source3, getter);
-    ReleaseStackTraceDataTest(isolate, source4, getter);
   }
   isolate->Dispose();
 }
diff --git a/test/cctest/test-api-stack-traces.cc b/test/cctest/test-api-stack-traces.cc
index 568ef2a..d81238b 100644
--- a/test/cctest/test-api-stack-traces.cc
+++ b/test/cctest/test-api-stack-traces.cc
@@ -672,7 +672,7 @@
   v8::HandleScope scope(isolate);
   const char* source =
       "var e = {__proto__: new Error()} \n"
-      "throw e;                         \n";
+      "throw e.__proto__;               \n";
   isolate->AddMessageListener(RethrowBogusErrorStackTraceHandler);
   isolate->SetCaptureStackTraceForUncaughtExceptions(true);
   CompileRun(source);
diff --git a/test/mjsunit/stack-traces-overflow.js b/test/mjsunit/stack-traces-overflow.js
index cd69075..60dc034 100644
--- a/test/mjsunit/stack-traces-overflow.js
+++ b/test/mjsunit/stack-traces-overflow.js
@@ -76,35 +76,6 @@
 }
 
 
-// Check setting/getting stack property on the prototype chain.
-function testErrorPrototype(prototype) {
-  var object = {};
-  object.__proto__ = prototype;
-  // Setting stack property overwrites prototype.stack value.
-  object.stack = "123";
-  assertEquals(prototype.stack, object.stack);
-  assertEquals("123", object.stack);
-}
-
-try {
-  rec1(0);
-} catch (e) {
-  e.stack;
-  testErrorPrototype(e);
-}
-
-try {
-  rec1(0);
-} catch (e) {
-  testErrorPrototype(e);
-}
-
-try {
-  throw new Error();
-} catch (e) {
-  testErrorPrototype(e);
-}
-
 Error.stackTraceLimit = 3;
 try {
   rec1(0);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/stack-traces-overflow.js b/test/mjsunit/stack-traces-overflow.js
index cd69075..60dc034 100644
--- a/test/mjsunit/stack-traces-overflow.js
+++ b/test/mjsunit/stack-traces-overflow.js
@@ -76,35 +76,6 @@
 }
 
 
-// Check setting/getting stack property on the prototype chain.
-function testErrorPrototype(prototype) {
-  var object = {};
-  object.__proto__ = prototype;
-  // Setting stack property overwrites prototype.stack value.
-  object.stack = "123";
-  assertEquals(prototype.stack, object.stack);
-  assertEquals("123", object.stack);
-}
-
-try {
-  rec1(0);
-} catch (e) {
-  e.stack;
-  testErrorPrototype(e);
-}
-
-try {
-  rec1(0);
-} catch (e) {
-  testErrorPrototype(e);
-}
-
-try {
-  throw new Error();
-} catch (e) {
-  testErrorPrototype(e);
-}
-
 Error.stackTraceLimit = 3;
 try {
   rec1(0);
Loading diff…

Original Bug Report

reported by wa...@gmail.com

Same-Site Cross-Origin Hidden Error-State Bypass with Default-Stack Navigation-Token Disclosure

Same-Site Cross-Origin Hidden Error-State Bypass with Default-Stack Navigation-Token Disclosure

Summary

Chrome/V8 allows a same-site cross-origin attacker page to read hidden Error.stack state from a victim WindowProxy after the victim page seeds stack state on window.

The attached PoC does not use victim Error.prepareStackTrace. The victim final page uses the documented custom-error pattern Error.captureStackTrace(this, Ctor) from a non-strict ordinary function call. In that browser call shape, this === window, so V8 stores hidden error-stack state on the victim global object without the victim code explicitly passing window or globalThis.

Direct attacker reads are blocked:

  • contentWindow.location.href throws SecurityError
  • contentWindow.stack throws SecurityError
  • normal prototype-chain o.stack also throws SecurityError

However, if the attacker borrows the local Error.stack getter and calls it on a local object whose prototype is the cross-origin victim WindowProxy, the getter returns the victim’s default formatted stack string. That string includes the final victim page URL and leaks a victim-generated ?code=OAUTH_CODE_<uuid> navigation token.

The current demonstrated scope is same-site cross-origin. A true cross-site nip.io to sslip.io variant was tested separately and failed closed to undefined, so this report does not claim arbitrary cross-site disclosure.

Suggested component: Blink>JavaScript.

Suggested bug class: same-site cross-origin information disclosure / same-origin policy bypass.

Tested Versions

Observed positive:

  • Chromium 149.0.7827.55, Linux x64
  • Google Chrome for Testing 150.0.7871.46, Linux x64
  • V8 source snapshot 0c7a9d0d8c05491e8053d7259a1a6b11e75c1907

Observed scope:

  • Positive: http://127.0.0.1:8000 -> http://127.0.0.1:8001
  • Positive: http://attacker.127.0.0.1.nip.io:8000 -> http://victim.127.0.0.1.nip.io:8001
  • Positive under an explicit Chrome --site-per-process validation run for the same-site different-host pair
  • Negative: http://attacker.127.0.0.1.nip.io:8000 -> http://victim.127.0.0.1.sslip.io:8001, where the borrowed getter returned undefined

Attached Files

All attached files should be placed in the same directory:

  • report.md
  • poc.html
  • victim-start.html
  • victim-final.html

Reproduction Steps

Run two local HTTP servers from the directory containing the attached files:

python3 -m http.server 8000 --bind 127.0.0.1

In a second terminal, from the same directory:

python3 -m http.server 8001 --bind 127.0.0.1

Open this URL in Chrome:

http://127.0.0.1:8000/poc.html

Optional same-site different-host variant, using the same two servers:

http://attacker.127.0.0.1.nip.io:8000/poc.html?victimOrigin=http%3A%2F%2Fvictim.127.0.0.1.nip.io%3A8001

The victim bootstrap page creates a random placeholder code and self-navigates to:

victim-final.html?code=OAUTH_CODE_<uuid>

The victim final page then runs:

function VictimSloppyCustomErrorFrame_Q4P7() {
  window.__producerThisIsWindow = (this === window);
  Error.captureStackTrace(this, VictimSloppyCustomErrorFrame_Q4P7);
}

VictimSloppyCustomErrorFrame_Q4P7();

Because the function is called as an ordinary non-strict browser function, this === window. The attacker page then creates a local object with the victim WindowProxy as its prototype and invokes the local stack getter on that object.

Expected Result

Every path from the attacker to the victim WindowProxy should observe the same cross-origin access-control boundary. If direct location.href, direct window.stack, and normal prototype-chain o.stack reads throw SecurityError, then invoking a local Error.stack getter should not disclose hidden stack state from the cross-origin victim object.

Actual Result

Representative output from poc.html:

direct.location.href=SecurityError:...
direct.stack=SecurityError:...
proto.stack=SecurityError:...
producer.this_is_window=true
borrowed.type=string
borrowed.has_code_param=true
borrowed.has_uuid_shape=true
borrowed.has_final_url=true
borrowed.first_line=at http://victim.127.0.0.1.nip.io:8001/victim-final.html?code=OAUTH_CODE_<uuid>:11:1

The important split is:

  • Direct public cross-origin reads are blocked.
  • The victim producer really ran with this === window.
  • The borrowed getter still returns a string from the victim hidden stack state.
  • That string contains the final victim URL and the victim-generated navigation token.

This is not a same-origin read of the attacker’s own data. The leaked URL is the final URL of the cross-origin victim frame.

Root Cause Analysis

The relevant stack getter path is:

  • Accessors::ErrorStackGetter
  • ErrorUtils::GetFormattedStack
  • GetErrorStackProperty
  • LookupIterator lookup for V8 private internal error-state symbols

The public cross-origin property reads correctly hit access checks and throw SecurityError. The hidden error-state lookup is different because it uses V8 private internal names such as the internal error stack symbol.

The relevant policy split is that access-checked holders do not enter the normal ACCESS_CHECK state for private internal names:

if (map->is_access_check_needed()) {
  if (is_element || !name_->IsPrivateInternal()) return ACCESS_CHECK;
}

That means a public property read like contentWindow.stack is blocked, but a borrowed hidden-stack lookup can still walk to the cross-origin holder and read hidden error metadata.

There is also a historical hardening signal. V8 commit fa81078cca6 explicitly made Error.captureStackTrace() a no-op for JSGlobalProxy targets under chromium:1432210. Later commit 79cdd69a1cf rewrote Error.stack as a JavaScript accessor path and removed that specific JSGlobalProxy guard. The current producer is therefore a reopened hardening gap: victim code can once again place hidden stack state on a browser global object.

Security Impact

This is a same-site cross-origin information disclosure. It bypasses the ordinary same-origin policy check for a cross-origin frame and leaks a victim-generated navigation token embedded in the final victim page URL.

The token in the PoC is intentionally a placeholder generated as OAUTH_CODE_<uuid>. The security-relevant property is that the attacker page learns a victim-frame URL component that direct location.href access cannot read.

The strongest current claim is not arbitrary cross-site disclosure. The tested true cross-site host pair fails closed. The issue is still security-relevant because same-site cross-origin boundaries are ordinary web security boundaries: different ports and different subdomains are cross-origin, and direct access is correctly blocked by Chrome except through this hidden-state path.

Scope and Limitations

  • Victim-side producer required: the victim page must seed hidden stack state on window.
  • No victim Error.prepareStackTrace is required for the attached PoC.
  • The producer uses a standard custom-error idiom in a non-strict ordinary browser function call.
  • True cross-site nip.io -> sslip.io was tested separately and currently returned undefined.
  • Common public cross-origin property reads remain blocked with SecurityError.

Suggested Fix Direction

Either of these would close the demonstrated issue:

  • Restore an effective JSGlobalProxy / browser global no-op guard in Error.captureStackTrace, so hidden error state cannot be installed on browser global objects.
  • Enforce access checks when private internal error-state lookup traverses an access-checked WindowProxy / global holder from an attacker-controlled receiver or prototype chain.

The first option addresses the producer. The second option addresses the shared hidden-state consumer and may also cover adjacent hidden error metadata paths.

View on issue tracker