CVE-2026-10022
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TESTtest/cctest/test-api.cc |
modified |
Files Changed
src/api/api.ccsrc/builtins/promise-misc.tqsrc/objects/js-promise-inl.hsrc/objects/js-promise.hsrc/objects/js-promise.tqtest/cctest/test-api-interceptors.cctest/cctest/test-api.cc
Patch
From 5d91cd2961f8eb284edf3972b38501997fd60025 Mon Sep 17 00:00:00 2001
From: Igor Sheludko <ishell@chromium.org>
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 <leszeks@chromium.org>
Commit-Queue: Igor Sheludko <ishell@chromium.org>
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();
Original Bug Report
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:
-
Put the attached
ext.crxat an absolute path, for example/tmp/ext.crx. -
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
- 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
-
The extension service worker opens
test.htmlautomatically on install. -
The renderer crashes in
Builtins_FulfillPromiseafter the second callback returns and the queued thenable job runs. -
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