CVE-2026-13967
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/inspector/v8-console.cc |
modified |
Files Changed
include/v8-sandbox.hsrc/inspector/v8-console.cc
Patch
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)); - }
Regression Test / PoC
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);
Original Bug Report
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.ccv8/src/inspector/v8-console.hv8/src/inspector/v8-serialization-duplicate-tracker.ccgin/public/gin_embedders.hcontent/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 inv8/src/inspector/v8-console.ccat line 528)kV8ConsoleTag = 10(defined inv8/src/inspector/v8-console.hat line 200)kDictionaryValueTag = 11(defined inv8/src/inspector/v8-serialization-duplicate-tracker.ccat 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:
- 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-workletprocess. - Ensure DevTools is active, which instantiates the
V8Inspectorand itsV8Consoleobject. - Locate the
JSExternalObjectfor aV8Consolemethod and theJSExternalObjectfor thesendReportTobinding (ReportBindings). - Using the in-sandbox R/W primitive, swap the 32-bit
ExternalPointerHandlein theReportBindingsobject with the handle from theV8Consoleobject. - Invoke
sendReportTo(). V8’s EPT logic will successfully decode the handle using Tag 10, returning a rawV8Console*pointer to theReportBindings::SendReportToC++ function. ReportBindings::SendReportTowill treat theV8Consolepointer as aReportBindingsinstance. 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.