Medium patch-now ✔︎ verified CVSS 8.8 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentInspector
Bug ClassType Confusion
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
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.
Independent review by AIConfirmed
Reviewer notes: Diff confirms arbitrary tag=9 reinterpret_cast and tag-assignment conflict. Commit frames fix as lifetime guarantee 'even with sandbox corruption', so web-only corruption absent a tag collision is partly inferred.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker513751951
Fix commit417aadb55936 (v8/v8) +180/-171
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-16 (per vendor advisory)
Fix landed2026-06-03 (public git)
Disclosed2026-06-30
Patch gap27 days — fix public in source before the advisory

Background

`TaskInfo`
An inspector-side object that records the bookkeeping (id, owning V8Console) for an async task created via the console API and drives asyncTaskStarted/asyncTaskFinished notifications.
`v8::Object::Wrap`/`v8::Object::Unwrap`
The sandbox-aware API for attaching a native C++ pointer to a JS object through a tagged CppHeapPointer table slot, where the tag must match on both wrap and unwrap.
`v8::External`
A JS wrapper that stores a raw native pointer, historically retrieved with Value(tag) using a tag from a small V8_EXTERNAL_POINTER_TAG_COUNT space.
Oilpan / `cppgc::MakeGarbageCollected`
V8’s C++ garbage collector; an Oilpan-managed object stays alive as long as it is reachable from the JS heap and is traced via a Trace method.

Root Cause Analysis

The vulnerable path stored a TaskInfo in a v8::External attached to the task object through a private property, and later V8Console::runTask retrieved it with taskExternal->Value(kTaskInfoTag) where kTaskInfoTag was a hand-picked v8::ExternalPointerTypeTag value of 9 chosen “arbitrarily” from the external-pointer tag space. Because that tag was not reserved and the lifetime of the TaskInfo was owned by a separate m_tasks map rather than tied to the JS object, an attacker who could corrupt the sandboxed heap (or exploit a colliding external-pointer tag assignment) could cause the External’s slot to be interpreted as the wrong type, yielding a reinterpret_cast<TaskInfo*> over attacker-influenced memory — a type confusion.

The fix makes TaskInfo (and V8Console) Oilpan-managed objects wrapped directly onto the JS object with v8::Object::Wrap<TaskInfo::kPointerTag>, and retrieved with the type-checked v8::Object::Unwrap<TaskInfo::kPointerTag, TaskInfo> guarded by an IsApiWrapper() check. It also introduces dedicated, non-colliding tags (kInspectorV8ConsoleTag, kInspectorTaskInfoTag) in the reserved CppHeapPointerTag range, so the pointer’s type is verified on unwrap and the TaskInfo is guaranteed alive as long as it is referenced from the JS heap, even under sandbox corruption.

Key insight
The core mistake was authenticating a native pointer with an arbitrarily chosen, collision-prone external-pointer tag and decoupling the TaskInfo lifetime from its JS wrapper, letting a bad or corrupted slot be reinterpret_cast to TaskInfo*; the fix binds the object to the JS wrapper via Oilpan and unwraps it with a reserved, type-checked CppHeapPointerTag so a mismatched type fails safely.

Attack Path

  1. Create a console task Script calls the console task-creation API, invoking V8Console::createTask, which builds a task object and associates a TaskInfo native pointer with it.
  2. Corrupt or collide the tagged slot Using sandbox memory corruption or a colliding external-pointer tag assignment, an attacker arranges for the external-pointer slot read by taskExternal->Value(kTaskInfoTag) to reference attacker-controlled data.
  3. Trigger the confused read The attacker calls run on the task, entering V8Console::runTask, which fetches the value and performs reinterpret_cast<TaskInfo*> without any type validation.
  4. Dereference as `TaskInfo` V8 treats the fake pointer as a real TaskInfo, so field accesses like taskInfo->Id() and follow-on inspector calls read/write attacker-chosen memory, achieving type confusion.

Impact Assessment

An attacker gains a type confusion in V8 running in the renderer process, turning controlled memory into a fake TaskInfo object usable as a primitive toward memory corruption and potential remote code execution. Reaching it requires the ability to run script that exercises the inspector’s console-task API and, for the corruption vector, an existing sandbox/heap corruption or external-pointer-tag collision precondition.

Changed Functions

FunctionChangeNotes
if
src/inspector/v8-console.cc
modified

Files Changed

  • include/v8-sandbox.h
  • src/inspector/v8-console.cc

Audit Directions

  • Arbitrary or hand-picked pointer tags
    Search for v8::External::Value and any ExternalPointerTypeTag/CppHeapPointerTag chosen “arbitrarily” or without a reserved enumerator, since these permit type-confused pointer reads.
  • Untyped `reinterpret_cast` on wrapped pointers
    Flag native code that recovers an object from a JS wrapper via reinterpret_cast rather than a tag-checked v8::Object::Unwrap, especially without an IsApiWrapper() guard.
  • Lifetime decoupled from the JS wrapper
    Look for native objects whose lifetime is held in a side map (std::unique_ptr in a container) while a raw pointer is exposed to JS, as sandbox corruption can outlive or desynchronize such ownership.
From 417aadb5593663552777f30624ec5ee6bfacd514 Mon Sep 17 00:00:00 2001
From: Andreas Haas <[email protected]>
Date: Wed, 03 Jun 2026 00:39:24 -0700
Subject: [PATCH] [inspector] Turn V8Console and TaskInfo into an Oilpan objects

The TaskInfo object can thereby be attached directly to its
corresponding Task object. This guarantees that the TaskInfo object is
alive as long as it is referenced from the JS heap, even with sandbox
corruption.

As a side effect, this CL fixes a conflict in the assignment of external
pointer tags.

Bug: 513751951, 512591789
Change-Id: I7974751b1687bbf07c28b454e612ccfba7dbbdeb
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7858052
Reviewed-by: Michael Lippautz <[email protected]>
Commit-Queue: Andreas Haas <[email protected]>
Cr-Commit-Position: refs/heads/main@{#107743}
---

diff --git a/include/v8-sandbox.h b/include/v8-sandbox.h
index b41a6ce2..bf44fa7 100644
--- a/include/v8-sandbox.h
+++ b/include/v8-sandbox.h
@@ -55,6 +55,12 @@
 
   kDefaultTag = 0x7000,
 
+  kFirstV8InternalTag = 0x7f00,
+  // V8-internal Oilpan objects that use v8::Object::Wrap() should go here.
+  kInspectorV8ConsoleTag,
+  kInspectorTaskInfoTag,
+  kLastV8InternalTag,
+
   kZappedEntryTag = 0x7ffd,
   kEvacuationEntryTag = 0x7ffe,
   kFreeEntryTag = 0x7fff,
@@ -62,6 +68,9 @@
   kLastTag = 0x7fff,
 };
 
+static_assert(static_cast<uint16_t>(CppHeapPointerTag::kLastV8InternalTag) <
+              static_cast<uint16_t>(CppHeapPointerTag::kZappedEntryTag));
+
 using CppHeapPointerTagRange = internal::TagRange<CppHeapPointerTag>;
 
 constexpr CppHeapPointerTagRange kAnyCppHeapPointer(
diff --git a/src/inspector/v8-console.cc b/src/inspector/v8-console.cc
index 2ca22d8..05d3bce 100644
--- a/src/inspector/v8-console.cc
+++ b/src/inspector/v8-console.cc
@@ -4,12 +4,17 @@
 
 #include "src/inspector/v8-console.h"
 
+#include <atomic>
+
+#include "include/cppgc/allocation.h"
 #include "include/v8-container.h"
 #include "include/v8-context.h"
+#include "include/v8-cppgc.h"
 #include "include/v8-function.h"
 #include "include/v8-inspector.h"
 #include "include/v8-microtask-queue.h"
 #include "include/v8-profiler.h"
+#include "include/v8-sandbox.h"
 #include "src/base/lazy-instance.h"
 #include "src/base/macros.h"
 #include "src/base/platform/time.h"
@@ -508,10 +513,10 @@
                                    ->NewInstance(isolate->GetCurrentContext())
                                    .ToLocalChecked();
 
-  auto taskInfo = std::make_unique<TaskInfo>(isolate, this, task);
+  auto* taskInfo = cppgc::MakeGarbageCollected<TaskInfo>(
+      isolate->GetCppHeap()->GetAllocationHandle(), isolate, this);
   void* taskId = taskInfo->Id();
-  auto [iter, inserted] = m_tasks.emplace(taskId, std::move(taskInfo));
-  CHECK(inserted);
+  v8::Object::Wrap<TaskInfo::kPointerTag>(isolate, task, taskInfo);
 
   String16 nameArgument = toProtocolString(isolate, info[0].As<v8::String>());
   StringView taskName =
@@ -521,13 +526,60 @@
   info.GetReturnValue().Set(task);
 }
 
-namespace {
-// This tag value has been picked arbitrarily between 0 and
-// V8_EXTERNAL_POINTER_TAG_COUNT.
-constexpr v8::ExternalPointerTypeTag kTaskInfoTag = 9;
-}  // namespace
+v8::Local<v8::Object> V8Console::wrapConsole(v8::Local<v8::Context> context) {
+  v8::Isolate* isolate = m_inspector->isolate();
+  if (!m_consoleWrapper.IsEmpty() && context == m_consoleContext) {
+    return m_consoleWrapper.Get(isolate);
+  }
+  if (m_consoleTemplate.IsEmpty()) {
+    v8::Local<v8::FunctionTemplate> consTemplate =
+        v8::FunctionTemplate::New(isolate);
+    m_consoleTemplate.Reset(isolate, consTemplate->InstanceTemplate());
+  }
+  v8::Local<v8::Object> consoleWrapper =
+      m_consoleTemplate.Get(isolate)->NewInstance(context).ToLocalChecked();
+  v8::Object::Wrap<kPointerTag>(isolate, consoleWrapper, this);
+  m_consoleWrapper.Reset(isolate, consoleWrapper);
+  m_consoleWrapper.SetWeak();
+  m_consoleContext.Reset(isolate, context);
+  m_consoleContext.SetWeak();
+  return consoleWrapper;
+}
 
-void V8Console::runTask(const v8::FunctionCallbackInfo<v8::Value>& info) {
+v8::Local<v8::ObjectTemplate> V8Console::taskTemplate() {
+  v8::Isolate* isolate = m_inspector->isolate();
+  if (!m_taskTemplate.IsEmpty()) {
+    return m_taskTemplate.Get(isolate);
+  }
+
+  v8::Local<v8::FunctionTemplate> consTemplate =
+      v8::FunctionTemplate::New(isolate);
+  v8::Local<v8::ObjectTemplate> taskTemplate = consTemplate->InstanceTemplate();
+  v8::Local<v8::FunctionTemplate> funcTemplate =
+      v8::FunctionTemplate::New(isolate, &TaskInfo::runTask);
+  taskTemplate->Set(isolate, "run", funcTemplate);
+
+  m_taskTemplate.Reset(isolate, taskTemplate);
+  return taskTemplate;
+}
+
+TaskInfo::TaskInfo(v8::Isolate* isolate, V8Console* console)
+    : m_isolate(isolate), m_console(console), m_id(new char(0)) {}
+
+TaskInfo::~TaskInfo() {
+  auto* inspector =
+      static_cast<V8InspectorImpl*>(v8::debug::GetInspector(m_isolate));
+  if (inspector) {
+    inspector->asyncTaskCanceled(Id());
+  }
+}
+
+void TaskInfo::Trace(cppgc::Visitor* visitor) const {
+  visitor->Trace(m_console);
+  v8::Object::Wrappable::Trace(visitor);
+}
+
+void TaskInfo::runTask(const v8::FunctionCallbackInfo<v8::Value>& info) {
   v8::Isolate* isolate = info.GetIsolate();
   if (info.Length() < 1 || !info[0]->IsFunction()) {
     isolate->ThrowError("First argument must be a function.");
@@ -536,23 +588,24 @@
   v8::Local<v8::Function> function = info[0].As<v8::Function>();
 
   v8::Local<v8::Object> task = info.This();
-  v8::Local<v8::Value> maybeTaskExternal;
-  if (!task->GetPrivate(isolate->GetCurrentContext(), taskInfoKey())
-           .ToLocal(&maybeTaskExternal)) {
-    // An exception is already thrown.
+  if (!task->IsApiWrapper()) {
+    isolate->ThrowError("'run' called with illegal receiver.");
     return;
   }
-
-  if (!maybeTaskExternal->IsExternal()) {
+  TaskInfo* taskInfo =
+      v8::Object::Unwrap<TaskInfo::kPointerTag, TaskInfo>(isolate, task);
+  if (!taskInfo) {
     isolate->ThrowError("'run' called with illegal receiver.");
     return;
   }
 
-  v8::Local<v8::External> taskExternal = maybeTaskExternal.As<v8::External>();
-  TaskInfo* taskInfo =
-      reinterpret_cast<TaskInfo*>(taskExternal->Value(kTaskInfoTag));
-
-  m_inspector->asyncTaskStarted(taskInfo->Id());
+  V8Console* console = taskInfo->m_console.Get();
+  CHECK_NOT_NULL(console);
+  if (!console->m_inspector) {
+    // Inspector has been cleared, so we're in shutdown.
+    return;
+  }
+  console->m_inspector->asyncTaskStarted(taskInfo->Id());
   {
 #ifdef V8_USE_PERFETTO
     TRACE_EVENT(TRACE_DISABLED_BY_DEFAULT("v8.inspector"), "V8Console::runTask",
@@ -572,56 +625,13 @@
       info.GetReturnValue().Set(result);
     }
   }
-  m_inspector->asyncTaskFinished(taskInfo->Id());
+  console->m_inspector->asyncTaskFinished(taskInfo->Id());
 }
 
-v8::Local<v8::Private> V8Console::taskInfoKey() {
-  v8::Isolate* isolate = m_inspector->isolate();
-  if (m_taskInfoKey.IsEmpty()) {
-    m_taskInfoKey.Reset(isolate, v8::Private::New(isolate));
-  }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/unittests/inspector/inspector-unittest.cc b/test/unittests/inspector/inspector-unittest.cc
index 28fb48a..dccee46 100644
--- a/test/unittests/inspector/inspector-unittest.cc
+++ b/test/unittests/inspector/inspector-unittest.cc
@@ -284,45 +284,6 @@
       StringView(kCommand, sizeof(kCommand)));
 }
 
-TEST_F(InspectorTest, ApiCreatedTasksAreCleanedUp) {
-  v8::Isolate* isolate = v8_isolate();
-  v8::HandleScope handle_scope(isolate);
-
-  v8_inspector::V8InspectorClient default_client;
-  std::unique_ptr<v8_inspector::V8InspectorImpl> inspector =
-      std::make_unique<v8_inspector::V8InspectorImpl>(isolate, &default_client);
-  V8ContextInfo context_info(v8_context(), 1, toStringView(""));
-  inspector->contextCreated(context_info);
-
-  // Trigger V8Console creation.
-  v8_inspector::V8Console* console = inspector->console();
-  CHECK(console);
-
-  {
-    v8::HandleScope inner_handle_scope(isolate);
-    v8::MaybeLocal<v8::Value> result = TryRunJS(isolate, NewString(R"(
-      globalThis['task'] = console.createTask('Task');
-    )"));
-    CHECK(!result.IsEmpty());
-
-    // Run GC and check that the task is still here.
-    InvokeMajorGC();
-    CHECK_EQ(console->AllConsoleTasksForTest().size(), 1);
-  }
-
-  // Get rid of the task on the context, run GC and check we no longer have
-  // the TaskInfo in the inspector.
-  v8_context()->Global()->Delete(v8_context(), NewString("task")).Check();
-  {
-    // We need to invoke GC without stack, otherwise some objects may not be
-    // reclaimed because of conservative stack scanning.
-    DisableConservativeStackScanningScopeForTesting no_stack_scanning(
-        i_isolate()->heap());
-    InvokeMajorGC();
-  }
-  CHECK_EQ(console->AllConsoleTasksForTest().size(), 0);
-}
-
 TEST_F(InspectorTest, Evaluate) {
   v8::Isolate* isolate = v8_isolate();
   v8::HandleScope handle_scope(isolate);
Loading diff…

Original Bug Report

reported by [email protected]

V8 Sandbox Bypass via ExternalPointerTypeTag Collisions in v8_inspector and Gin

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: Hardcoded type tags in the V8 Inspector collide with enum-based tags used by Gin in Chromium. In processes where both components share a V8 Isolate, such as auction-worklet, an attacker with in-sandbox memory access can bypass the V8 Sandbox to achieve C++ type confusion.

Affected files:

  • v8/src/inspector/v8-console.cc
  • v8/src/inspector/v8-console.h
  • v8/src/inspector/v8-serialization-duplicate-tracker.cc
  • gin/public/gin_embedders.h
  • content/services/auction_worklet/report_bindings.cc

Estimated timestamp from git blame: 2025-10-07

Summary

A potential V8 Sandbox bypass exists due to collisions in the v8::ExternalPointerTypeTag values used by v8_inspector and the gin library. This collision allows an attacker who has already achieved an in-sandbox memory primitive to swap pointers of different C++ types that share the same tag. This leads to out-of-sandbox C++ type confusion when these pointers are subsequently used by the engine.

Root Cause Analysis

The V8 Sandbox hardening relies on v8::ExternalPointerTypeTag to ensure that v8::External pointers stored in the ExternalPointerTable (EPT) are only retrieved with the correct expected type. This mechanism is bypassed if two different C++ classes are assigned the same tag value.

In v8_inspector, the following tags are hardcoded as arbitrary integers:

  • kTaskInfoTag = 9 (defined in v8/src/inspector/v8-console.cc at line 528)
  • kV8ConsoleTag = 10 (defined in v8/src/inspector/v8-console.h at line 200)
  • kDictionaryValueTag = 11 (defined in v8/src/inspector/v8-serialization-duplicate-tracker.cc at line 52)

These collide with the gin::ExternalPointerTypeTag enum values defined in gin/public/gin_embedders.h. Since the enum starts at 0, the following values are assigned:

  • kRegisterAdMacroBindingsTag = 9 (line 47)
  • kReportBindingsTag = 10 (line 48)
  • kSetBidBindingsTag = 11 (line 49)

In the auction-worklet utility process, objects from both components coexist within the same v8::Isolate and share an EPT. For instance, when DevTools is attached, a v8_inspector::V8Console object (Tag 10) is created alongside the auction_worklet::ReportBindings object (Tag 10).

Potential Exploit Path

An attacker could potentially follow these steps to trigger the vulnerability:

  1. Use an initial V8 vulnerability (e.g., JIT optimization error) to gain an arbitrary read/write primitive within the V8 Sandbox memory in an auction-worklet process.
  2. Ensure DevTools is active, which instantiates the V8Inspector and its V8Console object.
  3. Locate the JSExternalObject for a V8Console method and the JSExternalObject for the sendReportTo binding (ReportBindings).
  4. Using the in-sandbox R/W primitive, swap the 32-bit ExternalPointerHandle in the ReportBindings object with the handle from the V8Console object.
  5. Invoke sendReportTo(). V8’s EPT logic will successfully decode the handle using Tag 10, returning a raw V8Console* pointer to the ReportBindings::SendReportTo C++ function.
  6. ReportBindings::SendReportTo will treat the V8Console pointer as a ReportBindings instance. Accessing members or virtual functions on this confused pointer leads to out-of-sandbox memory corruption and potential arbitrary code execution.

Suggested Fix

To resolve this issue, V8 should provide a centralized or coordinated mechanism for allocating ExternalPointerTypeTag values to ensure that internal components like v8_inspector do not collide with embedder-defined tags. Alternatively, v8_inspector should use a reserved range of tags that are guaranteed not to overlap with those used by Chromium’s Gin embedder.

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker