CVE-2026-15775
Overview
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
Errorobjects to store the captured stack trace and message. - `PROTOTYPE_CHAIN_SKIP_INTERCEPTOR`
- a
LookupIteratorconfiguration that searches the entire prototype chain (while skipping interceptors), as opposed toOWN_SKIP_INTERCEPTORwhich 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.
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
- 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. - Trigger internal accessor
Reading
e.stack(ore.message, or throwingeso uncaught-exception handling reads it) invokes the V8 error accessor witheas the receiver. - Prototype-chain lookup finds inherited slot
The old
PROTOTYPE_CHAIN_SKIP_INTERCEPTORLookupIteratorwalks pasteand locates the privateerror_stack_symbolon the inheritedErrorprototype. - 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 anError, contrary to the intended own-slot semantics.
Impact Assessment
.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
| Function | Change | Notes |
|---|---|---|
index_src/objects/lookup-inl.h |
modified | |
PropertyKeysrc/objects/lookup-inl.h |
modified |
Files Changed
src/execution/messages.ccsrc/objects/lookup-inl.hsrc/objects/lookup.htest/cctest/heap/test-heap.cctest/cctest/test-api-stack-traces.cctest/mjsunit/stack-traces-overflow.js
Audit Directions
- Private-symbol prototype-chain lookupsFlag any
LookupIteratorconstructed withPROTOTYPE_CHAIN_SKIP_INTERCEPTOR(or otherwise chain-walking) that resolves internal private symbols, since such slots should almost always be read withOWN_SKIP_INTERCEPTOR. - Receiver-vs-holder confusion in accessorsReview 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 invariantsAudit 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.
Patch
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);
Regression Test / PoC
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);
Original Bug Report
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.hrefthrowsSecurityErrorcontentWindow.stackthrowsSecurityError- normal prototype-chain
o.stackalso throwsSecurityError
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-processvalidation 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 returnedundefined
Attached Files
All attached files should be placed in the same directory:
report.mdpoc.htmlvictim-start.htmlvictim-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::ErrorStackGetterErrorUtils::GetFormattedStackGetErrorStackPropertyLookupIteratorlookup 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.prepareStackTraceis 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.iowas tested separately and currently returnedundefined. - 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 inError.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.
- http://127.0.0.1:8000
- http://127.0.0.1:8000/poc.html
- http://127.0.0.1:8001
- http://attacker.127.0.0.1.nip.io:8000
- http://attacker.127.0.0.1.nip.io:8000/poc.html?victimOrigin=http%3A%2F%2Fvictim.127.0.0.1.nip.io%3A8001
- http://victim.127.0.0.1.nip.io:8001
- http://victim.127.0.0.1.nip.io:8001/victim-final.html?code=OAUTH_CODE_
- http://victim.127.0.0.1.sslip.io:8001