Medium watch CVSS 7.5 chrome Type Confusion 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactType Confusion in V8
DescriptionType Confusion in V8
ComponentV8
Sub-componentPromise
Bug ClassType Confusion
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker513289241
Fix commit5d91cd2961f8 (v8/v8) +165/-21
CWECWE-843 (Type confusion)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedggwhyp
Reported2026-05-14 (per vendor advisory)
Fix landed2026-05-19 (public git)
Disclosed2026-05-27
Patch gap8 days — fix public in source before the advisory

Background

`v8::Promise::Resolver`
the embedder-facing API object whose Resolve() and Reject() methods settle a native promise created via the C++ API.
`JSPromise` status
the tri-state (kPending, kFulfilled, kRejected) stored in the promise’s flags bitfield, reported by promise->status().
Thenable resolution
resolving a promise with a thenable does not immediately change its status to kFulfilled; the settlement is deferred, so the status can remain kPending after Resolve() returns.
`is_native_resolver_invoked`
a new single-bit flag in JSPromiseFlags set the moment the native resolver’s Resolve()/Reject() runs, independent of the promise’s status.

Root Cause Analysis

The pre-patch Resolve()/Reject() API methods guarded against re-settlement only with if (promise->status() != Promise::kPending) return Just(true);, i.e. they used the promise status as the “already settled” signal. Because resolving a native promise with a thenable defers settlement while leaving status() at kPending, a second call to Resolve() or Reject() (or a nested re-entrant call) passed the check and settled the promise again — a double settlement the spec’s promiseOrEmpty.[[Value]] is EMPTY machinery is meant to prevent.

The patch introduces is_native_resolver_invoked, initialized false at promise creation in promise-misc.tq, checked as the new guard, and set to true immediately before proceeding, so the very first native resolve/reject call latches the flag and every subsequent call returns early. The added DCHECK_IMPLIES(promise->status() != Promise::kPending, promise->is_native_resolver_invoked()) documents that any non-pending status now implies the flag was already set.

The fix works because the anti-double-settlement condition no longer depends on the observable status, which a thenable can hold at kPending.

Key insight
The core mistake was treating a still-kPending status as “not yet settled” for native resolvers, ignoring that thenable resolution defers the status change; the fix replaces the status-based guard with a dedicated is_native_resolver_invoked latch set on the first native resolve/reject.

Attack Path

  1. Obtain a native resolver Through an embedder path that exposes v8::Promise::Resolver behavior, get a native promise whose Resolve()/Reject() can be driven more than once.
  2. Resolve with a thenable Call Resolve() with a thenable so settlement is deferred and promise->status() stays kPending after the call returns.
  3. Re-enter the resolver Call Resolve() or Reject() a second time; pre-patch the stale status() != kPending check fails to fire and the promise is settled again (double settlement).
  4. Type confusion (inferred) The advisory classifies this as Type Confusion in V8; the double-settlement is the trigger, but the specific confused object/field and any resulting read/write primitive are not shown in the diff and are (inferred).

Impact Assessment

An attacker able to influence native-promise settlement (renderer process) can cause a promise to settle twice, which the advisory identifies as Type Confusion in V8; the precondition is a code path that invokes v8::Promise::Resolver::Resolve()/Reject() where re-entry or a thenable-deferred settlement is reachable. The advisory does not state exploitation in the wild. Escalation from the type confusion to a concrete memory-corruption or code-execution primitive is (inferred) and not demonstrated by the patch.

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

Audit Directions

  • Status-as-settled assumptions
    Audit all native/API settlement paths that infer “already handled” from an object’s observable status rather than a dedicated one-shot flag, especially where thenable/async resolution defers the status change.
  • Re-entrancy in embedder API sinks
    Review v8::Promise::Resolver and analogous API objects for methods callable multiple times or re-entrantly without an idempotency latch.
  • Spec `[[Value]] is EMPTY` shortcuts
    Where V8 substitutes a simplified check for spec’s resolving-functions capturing machinery, verify the simplification still rejects every double-settlement case the spec’s EMPTY check would.
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