Medium chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Bug ClassType Confusion
Tracker513289241
Fix commit5d91cd2961f8 (v8/v8) +165/-21
CISA KEVNot listed
Creditedggwhyp
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
TEST
test/cctest/test-api.cc
modified

Files Changed

  • src/api/api.cc
  • src/builtins/promise-misc.tq
  • src/objects/js-promise-inl.h
  • src/objects/js-promise.h
  • src/objects/js-promise.tq
  • test/cctest/test-api-interceptors.cc
  • test/cctest/test-api.cc
From 5d91cd2961f8eb284edf3972b38501997fd60025 Mon Sep 17 00:00:00 2001
From: Igor Sheludko <[email protected]>
Date: Tue, 19 May 2026 00:35:02 +0200
Subject: [PATCH] [promise] Fix double-settlement issue in v8::Promise::Resolver

This CL adds JSPromise::is_native_resolver_invoked flag as an anti
double-settlement measure for native promises. This flag is never
used for JavaScript promises.

This is a simpler version of "promiseOrEmpty.[[Value]] is EMPTY"
related steps from the spec:
  https://tc39.es/ecma262/#sec-createresolvingfunctions
Native promises do not require the full `promiseOrEmpty` capturing
machinery because their Api is limited and does not allow providing
custom executor for native promises.

Drive-by:
 - cleanup definition of JSPromise flags,
 - cleanup Api tests by moving GetData/MakeData helpers to test-api.h.

Fixes: 513289241

TAG=agy
CONV=d40e1b59-f716-4800-9f72-429924a60d7f

Change-Id: Icdff40c58aa7dfdc330e53b6ff8e2e2e1f6619d9
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7857889
Reviewed-by: Leszek Swirski <[email protected]>
Commit-Queue: Igor Sheludko <[email protected]>
Cr-Commit-Position: refs/heads/main@{#107410}
---

diff --git a/src/api/api.cc b/src/api/api.cc
index 337a4a4..7bb0535 100644
--- a/src/api/api.cc
+++ b/src/api/api.cc
@@ -8643,9 +8643,18 @@
   auto self = Utils::OpenDirectHandle(this);
   auto promise = i::Cast<i::JSPromise>(self);
 
-  if (promise->status() != Promise::kPending) {
+  // Protect against double settlement. This is a simpler version of
+  // "promiseOrEmpty.[[Value]] is EMPTY" related steps from the spec:
+  // https://tc39.es/ecma262/#sec-createresolvingfunctions
+  // Native promises do not require the full `promiseOrEmpty` capturing
+  // dance because Api is limited and does not allow providing custom
+  // executor for native promises.
+  DCHECK_IMPLIES(promise->status() != Promise::kPending,
+                 promise->is_native_resolver_invoked());
+  if (promise->is_native_resolver_invoked()) {
     return Just(true);
   }
+  promise->set_is_native_resolver_invoked(true);
 
   // TODO(434637746): Drop this CHECK when the bug is fixed.
   CHECK(!value.IsEmpty());
@@ -8665,9 +8674,18 @@
   auto self = Utils::OpenDirectHandle(this);
   auto promise = i::Cast<i::JSPromise>(self);
 
-  if (promise->status() != Promise::kPending) {
+  // Protect against double settlement. This is a simpler version of
+  // "promiseOrEmpty.[[Value]] is EMPTY" related steps from the spec:
+  // https://tc39.es/ecma262/#sec-createresolvingfunctions
+  // Native promises do not require the full `promiseOrEmpty` capturing
+  // dance because Api is limited and does not allow providing custom
+  // executor for native promises.
+  DCHECK_IMPLIES(promise->status() != Promise::kPending,
+                 promise->is_native_resolver_invoked());
+  if (promise->is_native_resolver_invoked()) {
     return Just(true);
   }
+  promise->set_is_native_resolver_invoked(true);
 
   if (i::JSPromise::Reject(promise, Utils::OpenDirectHandle(*value))
           .is_null()) {
diff --git a/src/builtins/promise-misc.tq b/src/builtins/promise-misc.tq
index f83ee77..f82a7c2 100644
--- a/src/builtins/promise-misc.tq
+++ b/src/builtins/promise-misc.tq
@@ -49,6 +49,7 @@
   promise.reactions_or_result = kZero;
   promise.flags = SmiTag(JSPromiseFlags{
     status: PromiseState::kPending,
+    is_native_resolver_invoked: false,
     has_handler: false,
     is_silent: false,
     async_task_id: kInvalidAsyncTaskId
@@ -68,6 +69,7 @@
   promise.reactions_or_result = kZero;
   promise.flags = SmiTag(JSPromiseFlags{
     status: PromiseState::kPending,
+    is_native_resolver_invoked: false,
     has_handler: false,
     is_silent: false,
     async_task_id: kInvalidAsyncTaskId
diff --git a/src/objects/js-promise-inl.h b/src/objects/js-promise-inl.h
index 21dfbff..a15f688 100644
--- a/src/objects/js-promise-inl.h
+++ b/src/objects/js-promise-inl.h
@@ -33,6 +33,8 @@
   flags_.store(this, Smi::From31BitPattern(value));
 }
 
+BOOL_ACCESSORS(JSPromise, flags, is_native_resolver_invoked,
+               IsNativeResolverInvokedBit::kShift)
 BOOL_ACCESSORS(JSPromise, flags, has_handler, HasHandlerBit::kShift)
 BOOL_ACCESSORS(JSPromise, flags, is_silent, IsSilentBit::kShift)
 
diff --git a/src/objects/js-promise.h b/src/objects/js-promise.h
index 19e3f89..9e3f79f 100644
--- a/src/objects/js-promise.h
+++ b/src/objects/js-promise.h
@@ -10,6 +10,7 @@
 #include "src/handles/handles.h"
 #include "src/objects/js-objects.h"
 #include "src/objects/promise.h"
+#include "torque-generated/bit-fields.h"
 
 // Has to be the last include (doesn't have include guards):
 #include "src/objects/object-macros.h"
@@ -65,6 +66,14 @@
   V8_EXPORT_PRIVATE Promise::PromiseState status() const;
   void set_status(Promise::PromiseState status);
 
+  // [is_native_resolver_invoked]: This is a part of simpler anti double
+  // settlement mechanism for native promises unlike the "promiseOrEmpty"
+  // machinery required by the spec for JavaScript promises.
+  // https://tc39.es/ecma262/#sec-createresolvingfunctions
+  // Returns true if v8::Resolver::Resolve() or Reject() was called for this
+  // promise.
+  DECL_BOOLEAN_ACCESSORS(is_native_resolver_invoked)
+
   // https://tc39.es/ecma262/#sec-fulfillpromise
   V8_EXPORT_PRIVATE static Handle<Object> Fulfill(
       DirectHandle<JSPromise> promise, DirectHandle<Object> value);
@@ -92,10 +101,7 @@
   DECL_VERIFIER(JSPromise)
 
   // Flags layout.
-  using StatusBits = base::BitField<Promise::PromiseState, 0, 2>;
-  using HasHandlerBit = base::BitField<bool, 2, 1>;
-  using IsSilentBit = base::BitField<bool, 3, 1>;
-  using AsyncTaskIdBits = base::BitField<uint32_t, 4, 27>;
+  DEFINE_TORQUE_GENERATED_JS_PROMISE_FLAGS()
 
   static_assert(v8::Promise::kPending == 0);
   static_assert(v8::Promise::kFulfilled == 1);
diff --git a/src/objects/js-promise.tq b/src/objects/js-promise.tq
index 11c4aff..6ea2059e 100644
--- a/src/objects/js-promise.tq
+++ b/src/objects/js-promise.tq
@@ -4,9 +4,10 @@
 
 bitfield struct JSPromiseFlags extends uint31 {
   status: PromiseState: 2 bit;
+  is_native_resolver_invoked: bool: 1 bit;
   has_handler: bool: 1 bit;
   is_silent: bool: 1 bit;
-  async_task_id: uint32: 27 bit;
+  async_task_id: uint32: 26 bit;
 }
 
 @cppObjectLayoutDefinition
diff --git a/test/cctest/test-api-interceptors.cc b/test/cctest/test-api-interceptors.cc
index 46e05bf..bd69f2b 100644
--- a/test/cctest/test-api-interceptors.cc
+++ b/test/cctest/test-api-interceptors.cc
@@ -32,20 +32,6 @@
 
 namespace {
 
-// This tag value has been picked arbitrarily between 0 and
-// V8_EXTERNAL_POINTER_TAG_COUNT.
-constexpr v8::ExternalPointerTypeTag kTestConfigTag = 14;
-
-template <typename T, typename U>
-T* GetData(const v8::PropertyCallbackInfo<U>& info) {
-  return reinterpret_cast<T*>(
-      v8::External::Cast(*info.Data())->Value(kTestConfigTag));
-}
-
-v8::Local<v8::External> MakeData(v8::Isolate* isolate, void* pointer) {
-  return v8::External::New(isolate, pointer, kTestConfigTag);
-}
-
 constexpr v8::EmbedderDataTypeTag kApiInterceptorTag = 1;
 
 void Returns42(const v8::FunctionCallbackInfo<v8::Value>& info) {
diff --git a/test/cctest/test-api.cc b/test/cctest/test-api.cc
index e202818..d255ae9 100644
--- a/test/cctest/test-api.cc
+++ b/test/cctest/test-api.cc
@@ -23513,6 +23513,117 @@
                   .FromJust());
 }
 
+TEST(PromiseResolverIsNativeResolverInvokedTest) {
+  LocalContext context;
+  v8::Isolate* isolate = context.isolate();
Loading diff…

Original Bug Report

reported by [email protected]

V8 type confusion in FulfillPromise via double settlement of chrome.runtime.getPackageDirectoryEntry() Promise

VULNERABILITY DETAILS

chrome.runtime.getPackageDirectoryEntry() exposes a promise-returning extension binding path that passes an internal promise callback adaptor into DirectoryEntry.getDirectory(). An extension page can obtain a real DirectoryEntry, replace DirectoryEntry.prototype.getDirectory, and synchronously call the supplied success callback twice before the queued microtasks run.

The first success callback call passes a thenable. V8 queues a PromiseResolveThenableJobTask, while the target JSPromise is still pending. The second success callback call passes another value and resolves the same raw v8::Promise::Resolver. When the queued thenable job later runs, V8 attempts to fulfill the same promise again.

In release V8, FulfillPromise relies on a debug-only pending-state invariant before it unsafe-casts promise.reactions_or_result to a PromiseReaction list. After the second resolve, reactions_or_result contains the second resolve value instead of a reaction list. V8 then treats the JavaScript value as internal PromiseReaction data and crashes in Builtins_FulfillPromise.

The attached PoC demonstrates a renderer memory-safety crash in an official Google Chrome stable release build. The second resolve value is the JavaScript number 0x20a0a0a0, whose tagged Smi representation is 0x41414140. The release crash confirms that V8 dereferences cage_base + 0x41414140 + 0x7 from Builtins_FulfillPromise.

This report does not claim a controlled read/write primitive or a V8 Sandbox bypass.

VERSION

Chrome Version: Google Chrome 148.0.7778.167 + stable

Chrome Build ID: 23b8fa1ce2d3b3613509b206bdd65be3b3f2f149

Operating System: Ubuntu 24.04.3 LTS, Linux x64

REPRODUCTION CASE

The CRX is signed with a fixed test key and has extension ID:

nbpgoaaffdflnihomombbeimnpfajhap

Official Google Chrome release blocks --load-extension, so the release repro uses the standard Linux external extension installation path for a CRX.

Steps:

  1. Put the attached ext.crx at an absolute path, for example /tmp/ext.crx.

  2. Register the CRX as an external extension:

sudo mkdir -p /usr/share/google-chrome/extensions
sudo tee /usr/share/google-chrome/extensions/nbpgoaaffdflnihomombbeimnpfajhap.json >/dev/null <<EOF
{
  "external_crx": "/tmp/ext.crx",
  "external_version": "0.1"
}
EOF
  1. Start official stable Google Chrome with a fresh profile:
USER_DATA_DIR="$(mktemp -d)"
google-chrome-stable \
  --user-data-dir="$USER_DATA_DIR" \
  --no-first-run \
  --no-default-browser-check \
  --enable-logging=stderr \
  --v=1 \
  about:blank
  1. The extension service worker opens test.html automatically on install.

  2. The renderer crashes in Builtins_FulfillPromise after the second callback returns and the queued thenable job runs.

  3. Cleanup after the test:

sudo rm -f /usr/share/google-chrome/extensions/nbpgoaaffdflnihomombbeimnpfajhap.json

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION

Type of crash: renderer / tab

Crash State, symbolized with the public release debug-info package for Google Chrome 148.0.7778.167:

Crash reason:  SIGSEGV / SEGV_ACCERR
Crash address: 0x169b41414147

Thread 0 (crashed)
 0  chrome!Builtins_FulfillPromise + 0x41
    rdx = 0x0000169b41414140
    r14 = 0x0000169b00000000
    rip = 0x000063d80d26fa81
 1  chrome!Builtins_ResolvePromise + 0x44
 2  chrome!Builtins_PromiseCapabilityDefaultResolve + 0x3e
 3  chrome!Builtins_InterpreterEntryTrampoline + 0x143
 4  chrome!Builtins_PromiseResolveThenableJob + 0x216
 5  chrome!Builtins_RunMicrotasks + 0x459
 6  chrome!Builtins_JSRunMicrotasksEntry + 0xab
 7  chrome!v8::internal::(anonymous namespace)::Invoke(...) + 0x3c0
 8  chrome!v8::internal::(anonymous namespace)::InvokeWithTryCatch(...) + 0x62
 9  chrome!v8::internal::MicrotaskQueue::PerformCheckpointInternal(v8::Isolate*) + 0x23f
10  chrome!blink::V8ScriptRunner::CallFunction(...) + 0x732
11  chrome!blink::V8FunctionExecutor::Execute(...) + 0x164
12  chrome!blink::PausableScriptExecutor::ExecuteAndDestroySelf() + 0xff
13  chrome!blink::PausableScriptExecutor::CreateAndRun(...) + 0x334
14  chrome!blink::WebLocalFrameImpl::RequestExecuteV8Function(...) + 0x3d
15  chrome!extensions::ScriptContext::SafeCallFunction(...) + 0x2a2
16  chrome!extensions::ExtensionJSRunner::RunJSFunction(...) + 0x56
17  chrome!extensions::APIRequestHandler::AsyncResultHandler::CallCustomCallback(...) + 0x234
18  chrome!extensions::APIRequestHandler::AsyncResultHandler::ResolveRequest(...) + 0x2b4
19  chrome!extensions::APIRequestHandler::CompleteRequestImpl(...) + 0x1d3
20  chrome!extensions::APIRequestHandler::CompleteRequest(...) + 0x4d
21  chrome!extensions::APIBindingsSystem::CompleteRequest(...) + 0x29
22  chrome!extensions::NativeExtensionBindingsSystem::HandleResponse(...) + 0x9f
23  chrome!extensions::MainThreadIPCMessageSender::OnResponse(...) + 0x55

Fault address analysis:

r14 = 0x0000169b00000000   // V8 pointer-compression cage base
rdx = 0x0000169b41414140   // cage_base + tagged Smi 0x41414140
crash address = 0x169b41414147

Faulting instruction:

661ea81: cmpl $0x0, 0x7(%rdx)
661ea85: jne  0x661eb58
661ea8b: movl 0xf(%rdx), %ecx

The crash address is rdx + 0x7, matching a confused PromiseReaction field access after promise.reactions_or_result was overwritten by the second resolve value.

CREDIT INFORMATION

Reporter credit: ggwhyp

View on issue tracker