Low watch CVSS 4.3 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in V8
DescriptionMissing authorization in V8
ComponentV8
Sub-componentBuiltins
Bug ClassAuthorization Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker547027738
Fix commita98cbbeb260c (v8/v8) +209/-22
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N
CISA KEVNot listed
CreditedHongwei Li, Zhun Wang, Ziyue Pan, Junmin Zhu, Saastha Vasan, and Wenbo Guo
Reported2026-08-16 (per vendor advisory)
Fix landed2026-08-27 (public git)
Disclosed2026-09-22
Patch gap26 days — fix public in source before the advisory

Background

`CallSite`
the object passed to Error.prepareStackTrace whose prototype getters (getFunction, getThis, getFileName, getFunctionName, …) expose details of individual stack frames.
Security token
a value stored on a NativeContext that HasSameSecurityTokenAs compares to decide whether two realms may inspect each other’s objects.
Creation context
the NativeContext (realm) in which a JSReceiver was created, retrieved via GetCreationContext, used to attribute a frame’s function or receiver to a realm.
`CallSiteInfo`
the internal frame record (frame) holding a frame’s function() and receiver_or_instance(), from which each CallSitePrototype* 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.

Key insight
The core mistake was applying the cross-realm security-token check to only one or two of the 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

  1. 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.eval or a Wasm frame), and grabs the CallSite object through Error.prepareStackTrace.
  2. Call an unguarded getter The attacker invokes a getter other than getFunction/getFunctionName, such as getFileName, getThis, getTypeName, or toString, none of which checked the security token.
  3. 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.
  4. 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

An attacker executing JavaScript in a renderer gains cross-realm information disclosure and capability leaks: script names, positions, type/method names, and live function/receiver objects belonging to a realm protected by a different security token. This runs in the renderer process and requires only that the attacker share a 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

FunctionChangeNotes
BUILTIN
src/builtins/builtins-callsite.cc
modified
if
test/mjsunit/regress/regress-514071819.js
modified

Files Changed

  • src/builtins/builtins-callsite.cc
  • test/mjsunit/regress/regress-547027738.js
  • test/mjsunit/regress/regress-514071819.js
  • test/mjsunit/regress/regress-547027738-wasm.js

Audit Directions

  • Partial guard coverage
    When 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 frame
    Any authorization check on frame-derived data must cover both function() and receiver_or_instance(), since either can carry a foreign realm’s identity into the caller.
  • Wasm and other frame kinds
    Verify 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.
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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());
+}
+})();
Loading diff…

Original Bug Report

reported by li...@gmail.com

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

  1. stack capture filters and retains frames using the trusted capture context;
  2. lazy formatting selects prepareStackTrace using the untrusted target context; and
  3. 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

View on issue tracker