CVE-2026-95342
Overview
Background
- `CallSite`
- the object passed to
Error.prepareStackTracewhose prototype getters (getFunction,getThis,getFileName,getFunctionName, …) expose details of individual stack frames. - Security token
- a value stored on a
NativeContextthatHasSameSecurityTokenAscompares to decide whether two realms may inspect each other’s objects. - Creation context
- the
NativeContext(realm) in which aJSReceiverwas created, retrieved viaGetCreationContext, used to attribute a frame’s function or receiver to a realm. - `CallSiteInfo`
- the internal frame record (
frame) holding a frame’sfunction()andreceiver_or_instance(), from which eachCallSitePrototype*builtin derives its return value.
Root Cause Analysis
The CallSitePrototype* builtins in builtins-callsite.cc return per-frame details derived from a CallSiteInfo that may describe a function or receiver belonging to a foreign realm. Only CallSitePrototypeGetFunction and CallSitePrototypeGetFunctionName performed the HasSameSecurityTokenAs check against the current native context before returning; the other 17 getters (getThis, getFileName, getLineNumber, getColumnNumber, getTypeName, getMethodName, toString, the is* predicates, and Wasm frames) returned frame data with no token comparison at all. This violated the same-security-token invariant that gates cross-realm inspection, so a script sharing a CallSite across a realm boundary could read names, script URLs, positions, and even the live function/receiver objects of a realm it had no authorization to observe.
The fix introduces the IsSecurityTokenCompatible helper, which resolves the creation context of both frame->function() and frame->receiver_or_instance() and rejects the frame unless both share the current context’s security token, and calls it at the top of every getter, returning a neutral value (undefined, empty string, or null) on mismatch. Because the check is now applied uniformly and the per-getter checks in getFunction/getFunctionName/getThis are removed as redundant, no getter can leak cross-token frame data.
CallSite getters instead of all of them, treating the function’s identity as the sole secret while leaving frame metadata unguarded. The fix centralizes the check in IsSecurityTokenCompatible and invokes it in every CallSitePrototype* builtin, closing the leak uniformly.Attack Path
- Obtain a cross-realm `CallSite`
An attacker script arranges for a stack trace to capture a frame whose function or receiver belongs to a different realm with an incompatible security token (e.g. via
Realm.evalor a Wasm frame), and grabs theCallSiteobject throughError.prepareStackTrace. - Call an unguarded getter
The attacker invokes a getter other than
getFunction/getFunctionName, such asgetFileName,getThis,getTypeName, ortoString, none of which checked the security token. - Read foreign-realm data
The getter returns the foreign frame’s script URL, source position, type name, or even the live receiver object (
getThis), disclosing information and capabilities from a realm the attacker is not authorized to inspect. - Escalate via leaked receiver Using the returned cross-realm receiver or function object, the attacker gains a reference into the victim realm that can be used to further probe or manipulate its state.
Impact Assessment
CallSite (through Error.prepareStackTrace) that references a frame from a realm with an incompatible token, which same-origin-policy-style isolation between realms is meant to prevent.Changed Functions
| Function | Change | Notes |
|---|---|---|
BUILTINsrc/builtins/builtins-callsite.cc |
modified | |
iftest/mjsunit/regress/regress-514071819.js |
modified |
Files Changed
src/builtins/builtins-callsite.cctest/mjsunit/regress/regress-547027738.jstest/mjsunit/regress/regress-514071819.jstest/mjsunit/regress/regress-547027738-wasm.js
Audit Directions
- Partial guard coverageWhen one accessor in a family enforces a security-token or cross-origin check, audit every sibling accessor over the same underlying record (
CallSiteInfo, frame data) for the missing check rather than assuming the family is uniformly protected. - Both operands of a frameAny authorization check on frame-derived data must cover both
function()andreceiver_or_instance(), since either can carry a foreign realm’s identity into the caller. - Wasm and other frame kindsVerify that cross-realm checks extend to Wasm frames and non-JS frame types, which historically bypass receiver/function-based checks that assume an ordinary
JSFunction.
Patch
From a98cbbeb260c1892c5f0664831c0667cc31416f8 Mon Sep 17 00:00:00 2001
From: Jakob Linke <jgruber@chromium.org>
Date: Thu, 27 Aug 2026 13:24:02 +0200
Subject: [PATCH] [builtins] Protect all CallSite getters against cross-token leaks
CallSite.prototype.getFunction(), getThis(), and getFunctionName()
allow inspecting details of stack frames. When the recorded function
or receiver belongs to a realm with an incompatible security token,
returning these properties leaks cross-realm information and
capabilities.
This CL checks whether the creation context of the function/receiver
has the same security token as the current native context and filters
the return value (undefined for getFunction/getThis, an empty string
for toString, and null for all others) if the tokens do not match.
It extends this to all 19 `CallSitePrototype*` getters and Wasm frames.
Bug: 547027738
Change-Id: I15ff48f326f70ab73c54250e19c126818d3e7431
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8274754
Reviewed-by: Jakob Linke <jgruber@chromium.org>
Commit-Queue: Jakob Linke <jgruber@chromium.org>
Reviewed-by: Igor Sheludko <ishell@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109537}
---
diff --git a/src/builtins/builtins-callsite.cc b/src/builtins/builtins-callsite.cc
index ee11ef2..dfb280c 100644
--- a/src/builtins/builtins-callsite.cc
+++ b/src/builtins/builtins-callsite.cc
@@ -45,6 +45,29 @@
namespace {
+bool IsSecurityTokenCompatible(Isolate* isolate,
+ DirectHandle<CallSiteInfo> frame) {
+ Tagged<NativeContext> current_native_context = isolate->raw_native_context();
+
+ auto is_compatible = [&](Tagged<Object> obj) {
+ if (!IsJSReceiver(obj)) return true;
+ std::optional<Tagged<NativeContext>> maybe_creation_context =
+ Cast<JSReceiver>(obj)->GetCreationContext();
+ if (maybe_creation_context.has_value()) {
+ Tagged<NativeContext> cc = maybe_creation_context.value();
+ if (!cc->HasSameSecurityTokenAs(current_native_context)) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ return true;
+ };
+
+ return is_compatible(frame->function()) &&
+ is_compatible(frame->receiver_or_instance());
+}
+
Tagged<Object> PositiveNumberOrNull(int value, Isolate* isolate) {
if (value > 0) return *isolate->factory()->NewNumberFromInt(value);
return ReadOnlyRoots(isolate).null_value();
@@ -55,12 +78,18 @@
BUILTIN(CallSitePrototypeGetColumnNumber) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getColumnNumber");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return PositiveNumberOrNull(CallSiteInfo::GetColumnNumber(frame), isolate);
}
BUILTIN(CallSitePrototypeGetEnclosingColumnNumber) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getEnclosingColumnNumber");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return PositiveNumberOrNull(CallSiteInfo::GetEnclosingColumnNumber(frame),
isolate);
}
@@ -68,6 +97,9 @@
BUILTIN(CallSitePrototypeGetEnclosingLineNumber) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getEnclosingLineNumber");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return PositiveNumberOrNull(CallSiteInfo::GetEnclosingLineNumber(frame),
isolate);
}
@@ -75,12 +107,18 @@
BUILTIN(CallSitePrototypeGetEvalOrigin) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getEvalOrigin");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return *CallSiteInfo::GetEvalOrigin(frame);
}
BUILTIN(CallSitePrototypeGetFileName) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getFileName");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return frame->GetScriptName();
}
@@ -88,6 +126,9 @@
static const char method_name[] = "getFunction";
HandleScope scope(isolate);
CHECK_CALLSITE(frame, method_name);
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).undefined_value();
+ }
Tagged<Object> func_obj = frame->function();
if (frame->IsStrict() ||
@@ -113,10 +154,6 @@
// not exist outside the ShadowRealm.
CHECK_SHADOW_REALM_BOUNDARY_OR_RETURN_FAILURE(
isolate, current_native_context, creation_context, method_name);
-
- if (!creation_context->HasSameSecurityTokenAs(current_native_context)) {
- return ReadOnlyRoots(isolate).undefined_value();
- }
}
isolate->CountUsage(v8::Isolate::kCallSiteAPIGetFunctionSloppyCall);
@@ -126,6 +163,9 @@
BUILTIN(CallSitePrototypeGetFunctionName) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getFunctionName");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
Tagged<Object> func_obj = frame->function();
if (IsJSReceiver(func_obj)) {
@@ -136,13 +176,6 @@
if (!maybe_creation_context.has_value()) {
return ReadOnlyRoots(isolate).null_value();
}
- Tagged<NativeContext> creation_context = maybe_creation_context.value();
- Tagged<NativeContext> current_native_context =
- isolate->raw_native_context();
-
- if (!creation_context->HasSameSecurityTokenAs(current_native_context)) {
- return ReadOnlyRoots(isolate).null_value();
- }
}
return *CallSiteInfo::GetFunctionName(frame);
@@ -151,24 +184,36 @@
BUILTIN(CallSitePrototypeGetLineNumber) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getLineNumber");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return PositiveNumberOrNull(CallSiteInfo::GetLineNumber(frame), isolate);
}
BUILTIN(CallSitePrototypeGetMethodName) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getMethodName");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return *CallSiteInfo::GetMethodName(frame);
}
BUILTIN(CallSitePrototypeGetPosition) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getPosition");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return Smi::FromInt(CallSiteInfo::GetSourcePosition(frame));
}
BUILTIN(CallSitePrototypeGetPromiseIndex) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getPromiseIndex");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
if (!frame->IsPromiseAll() && !frame->IsPromiseAny() &&
!frame->IsPromiseAllSettled()) {
return ReadOnlyRoots(isolate).null_value();
@@ -179,12 +224,18 @@
BUILTIN(CallSitePrototypeGetScriptHash) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getScriptHash");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return *CallSiteInfo::GetScriptHash(frame);
}
BUILTIN(CallSitePrototypeGetScriptNameOrSourceURL) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getScriptNameOrSourceUrl");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return frame->GetScriptNameOrSourceURL();
}
@@ -192,6 +243,9 @@
static const char method_name[] = "getThis";
HandleScope scope(isolate);
CHECK_CALLSITE(frame, method_name);
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).undefined_value();
+ }
if (frame->IsStrict()) return ReadOnlyRoots(isolate).undefined_value();
isolate->CountUsage(v8::Isolate::kCallSiteAPIGetThisSloppyCall);
@@ -214,10 +268,6 @@
// not exist outside the ShadowRealm.
CHECK_SHADOW_REALM_BOUNDARY_OR_RETURN_FAILURE(
isolate, current_native_context, creation_context, method_name);
-
- if (!creation_context->HasSameSecurityTokenAs(current_native_context)) {
- return ReadOnlyRoots(isolate).undefined_value();
- }
}
return this_obj;
}
@@ -225,48 +275,72 @@
BUILTIN(CallSitePrototypeGetTypeName) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "getTypeName");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return *CallSiteInfo::GetTypeName(frame);
}
BUILTIN(CallSitePrototypeIsAsync) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "isAsync");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return ReadOnlyRoots(isolate).boolean_value(frame->IsAsync());
}
BUILTIN(CallSitePrototypeIsConstructor) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "isConstructor");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return ReadOnlyRoots(isolate).boolean_value(frame->IsConstructor());
}
BUILTIN(CallSitePrototypeIsEval) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "isEval");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return ReadOnlyRoots(isolate).boolean_value(frame->IsEval());
}
BUILTIN(CallSitePrototypeIsNative) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "isNative");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return ReadOnlyRoots(isolate).boolean_value(frame->IsNative());
}
BUILTIN(CallSitePrototypeIsPromiseAll) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "isPromiseAll");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return ReadOnlyRoots(isolate).boolean_value(frame->IsPromiseAll());
}
BUILTIN(CallSitePrototypeIsToplevel) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "isToplevel");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return ReadOnlyRoots(isolate).null_value();
+ }
return ReadOnlyRoots(isolate).boolean_value(frame->IsToplevel());
}
BUILTIN(CallSitePrototypeToString) {
HandleScope scope(isolate);
CHECK_CALLSITE(frame, "toString");
+ if (!IsSecurityTokenCompatible(isolate, frame)) {
+ return *isolate->factory()->empty_string();
+ }
RETURN_RESULT_OR_FAILURE(isolate, SerializeCallSiteInfo(isolate, frame));
}
diff --git a/test/mjsunit/regress/regress-514071819.js b/test/mjsunit/regress/regress-514071819.js
index 3b56444..d77fafb 100644
--- a/test/mjsunit/regress/regress-514071819.js
+++ b/test/mjsunit/regress/regress-514071819.js
@@ -51,9 +51,9 @@
Realm.eval(remoteRealm, `
async function remoteBaz(throwFromRemote) {
if (throwFromRemote) {
- return Realm.shared.remoteCaptureStack();
+ return (0, Realm.shared.remoteCaptureStack)();
} else {
- return Realm.shared.mainCaptureStack();
+ return (0, Realm.shared.mainCaptureStack)();
}
}
Realm.shared.remoteBaz = remoteBaz;
@@ -61,7 +61,7 @@
async function bar(throwFromRemote) {
await Promise.resolve();
- return Realm.shared.remoteBaz(throwFromRemote);
+ return (0, Realm.shared.remoteBaz)(throwFromRemote);
}
async function foo(throwFromRemote) {
@@ -79,7 +79,7 @@
checkStackTrace(remoteRealm, frames);
})
.catch((e) => {
- console.error(e);
+ console.error(e.stack);
quit(1);
});
@@ -94,7 +94,7 @@
checkStackTrace(mainRealm, frames);
})
.catch((e) => {
- console.error(e);
+ console.error(e.stack);
quit(1);
});
@@ -109,7 +109,7 @@
checkStackTrace(remoteRealm, frames);
})
.catch((e) => {
- console.error(e);
+ console.error(e.stack);
quit(1);
});
@@ -124,6 +124,6 @@
checkStackTrace(mainRealm, frames);
})
.catch((e) => {
- console.error(e);
+ console.error(e.stack);
quit(1);
});
diff --git a/test/mjsunit/regress/regress-547027738-wasm.js b/test/mjsunit/regress/regress-547027738-wasm.js
new file mode 100644
index 0000000..d7a79f0
--- /dev/null
+++ b/test/mjsunit/regress/regress-547027738-wasm.js
@@ -0,0 +1,68 @@
+// 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
+
+load("test/mjsunit/wasm/wasm-module-builder.js");
+
+(function() {
+if (typeof WebAssembly === 'undefined') return;
+
+Error.stackTraceLimit = 20;
+
+let capturedSites = null;
+Error.prepareStackTrace = (_error, callSites) => {
+ capturedSites = callSites;
+ return callSites;
+};
+
+const realm1 = Realm.create();
+
+let builder2 = new WasmModuleBuilder();
+builder2.addImport("env", "trigger", kSig_v_v);
+builder2.addFunction("main", kSig_v_v).addBody([
+ kExprCallFunction, 0
+]).exportFunc();
+const wasmBytes2 = builder2.toArray();
+
+const captureInRealm1 = Realm.eval(realm1, `
+ (function capture(wasmBytes, target) {
+ let importObj = {
+ env: {
... (file diff truncated)
Regression Test / PoC
diff --git a/test/mjsunit/regress/regress-514071819.js b/test/mjsunit/regress/regress-514071819.js
index 3b56444..d77fafb 100644
--- a/test/mjsunit/regress/regress-514071819.js
+++ b/test/mjsunit/regress/regress-514071819.js
@@ -51,9 +51,9 @@
Realm.eval(remoteRealm, `
async function remoteBaz(throwFromRemote) {
if (throwFromRemote) {
- return Realm.shared.remoteCaptureStack();
+ return (0, Realm.shared.remoteCaptureStack)();
} else {
- return Realm.shared.mainCaptureStack();
+ return (0, Realm.shared.mainCaptureStack)();
}
}
Realm.shared.remoteBaz = remoteBaz;
@@ -61,7 +61,7 @@
async function bar(throwFromRemote) {
await Promise.resolve();
- return Realm.shared.remoteBaz(throwFromRemote);
+ return (0, Realm.shared.remoteBaz)(throwFromRemote);
}
async function foo(throwFromRemote) {
@@ -79,7 +79,7 @@
checkStackTrace(remoteRealm, frames);
})
.catch((e) => {
- console.error(e);
+ console.error(e.stack);
quit(1);
});
@@ -94,7 +94,7 @@
checkStackTrace(mainRealm, frames);
})
.catch((e) => {
- console.error(e);
+ console.error(e.stack);
quit(1);
});
@@ -109,7 +109,7 @@
checkStackTrace(remoteRealm, frames);
})
.catch((e) => {
- console.error(e);
+ console.error(e.stack);
quit(1);
});
@@ -124,6 +124,6 @@
checkStackTrace(mainRealm, frames);
})
.catch((e) => {
- console.error(e);
+ console.error(e.stack);
quit(1);
});
diff --git a/test/mjsunit/regress/regress-547027738-wasm.js b/test/mjsunit/regress/regress-547027738-wasm.js
new file mode 100644
index 0000000..d7a79f0
--- /dev/null
+++ b/test/mjsunit/regress/regress-547027738-wasm.js
@@ -0,0 +1,68 @@
+// 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
+
+load("test/mjsunit/wasm/wasm-module-builder.js");
+
+(function() {
+if (typeof WebAssembly === 'undefined') return;
+
+Error.stackTraceLimit = 20;
+
+let capturedSites = null;
+Error.prepareStackTrace = (_error, callSites) => {
+ capturedSites = callSites;
+ return callSites;
+};
+
+const realm1 = Realm.create();
+
+let builder2 = new WasmModuleBuilder();
+builder2.addImport("env", "trigger", kSig_v_v);
+builder2.addFunction("main", kSig_v_v).addBody([
+ kExprCallFunction, 0
+]).exportFunc();
+const wasmBytes2 = builder2.toArray();
+
+const captureInRealm1 = Realm.eval(realm1, `
+ (function capture(wasmBytes, target) {
+ let importObj = {
+ env: {
+ trigger: function() {
+ Error.captureStackTrace(target);
+ }
+ }
+ };
+
+ let instance = new WebAssembly.Instance(
+ new WebAssembly.Module(new Uint8Array(wasmBytes)),
+ importObj
+ );
+
+ instance.exports.main();
+ })
+`);
+
+const target = {};
+captureInRealm1(wasmBytes2, target);
+void target.stack;
+
+assertTrue(Array.isArray(capturedSites));
+assertTrue(capturedSites.length > 0);
+
+// The first three frames should be:
+// 1. trigger (from realm1)
+// 2. main (Wasm frame, instantiated in realm1)
+// 3. capture (from realm1)
+// The remaining frames are from the main realm (e.g. the top-level script).
+for (let i = 0; i < 3; i++) {
+ const site = capturedSites[i];
+ assertEquals("", site.toString());
+ assertEquals(undefined, site.getFunction());
+ assertEquals(undefined, site.getThis());
+ assertEquals(null, site.getFunctionName());
+ assertEquals(null, site.getMethodName());
+}
+})();
diff --git a/test/mjsunit/regress/regress-547027738.js b/test/mjsunit/regress/regress-547027738.js
new file mode 100644
index 0000000..854380a
--- /dev/null
+++ b/test/mjsunit/regress/regress-547027738.js
@@ -0,0 +1,45 @@
+// 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.
+
+(function() {
+Error.stackTraceLimit = 20;
+
+let capturedSites = null;
+Error.prepareStackTrace = (_error, callSites) => {
+ capturedSites = callSites;
+ return callSites;
+};
+
+const realm1 = Realm.create();
+const captureInRealm1 = Realm.eval(realm1, `
+ (() => {
+ globalThis.realm1Receiver = {};
+
+ function realm1Function(target) {
+ Error.captureStackTrace(target);
+ }
+
+ return function(target) {
+ realm1Function.call(globalThis.realm1Receiver, target);
+ };
+ })()
+`);
+
+const target = {};
+captureInRealm1(target);
+void target.stack;
+
+assertTrue(Array.isArray(capturedSites));
+assertTrue(capturedSites.length > 0);
+
+// The first two frames are inside realm1. Their function, receiver,
+// and function name must be filtered out when accessed by the main realm.
+for (let i = 0; i < 2; i++) {
+ const site = capturedSites[i];
+ assertEquals("", site.toString());
+ assertEquals(undefined, site.getFunction());
+ assertEquals(undefined, site.getThis());
+ assertEquals(null, site.getFunctionName());
+}
+})();
Original Bug Report
[V8] Error.captureStackTrace exposes private cross-token function capabilities to attacker prepareStackTrace
VULNERABILITY DETAILS
V8 can capture stack frames under one security token and later expose capability-bearing
CallSite objects to an Error.prepareStackTrace callback from a different security
token.
The attached single-file d8 reproduction creates an untrusted realm and a trusted realm
with distinct security tokens. Direct access from the untrusted realm to the trusted
global is first confirmed to be denied. Trusted code then calls
Error.captureStackTrace() on an untrusted target while a private trusted function is on
the stack.
When the untrusted realm later reads the target’s lazy .stack, V8 selects the
untrusted target’s creation context and calls its Error.prepareStackTrace. The callback
receives a retained trusted frame. CallSite.prototype.getFunction() then returns the
private function object even though it belongs to the other security token. The untrusted
realm invokes that function and reads D8-CALLSITE-SECRET.
The principal mismatch is:
- stack capture filters and retains frames using the trusted capture context;
- lazy formatting selects
prepareStackTraceusing the untrusted target context; and - the ordinary CallSite accessors do not recheck security-token equality before returning the retained function capability.
VERSION
V8 version: 15.3.0 (candidate)
V8 revision: 5280f012d5ce0714c8db750cbada4251c76412c7 (official main)
Chrome Version: N/A; this is a standalone V8 d8 reproduction for V8 security triage.
Operating System: Kali GNU/Linux Rolling 2026.2, Linux 5.15.0-126-generic, x86_64.
REPRODUCTION CASE
Attached file: poc.js
Build a normal x64 release d8 from the revision above, then run:
out/vrp-release/d8 --run-as-security-poc poc.js
No other V8 flags are required. An affected build prints:
D8-CALLSITE-SECRET
The PoC throws instead of printing this marker unless direct cross-token global access is denied, a private function owned by the trusted realm is returned to the untrusted formatter, and invoking it returns the trusted secret.
The minimized attachment reproduced 10/10 in both release and release-mode ASAN builds
using --run-as-security-poc. ASAN emitted no diagnostic, as expected for a JavaScript
capability disclosure rather than native memory corruption.
FOR CRASHES
Type of crash: N/A. This is a deterministic cross-token capability disclosure.
Crash State: N/A.
Client ID: N/A.
CREDIT INFORMATION
Reporter credit: Hongwei Li, Zhun Wang, Ziyue Pan, Junmin Zhu, Saastha Vasan, and Wenbo Guo