51cc3feb729804866ae63510d837f4df674bbc59 [JSC] Promise jobs must not run with the realm of a cross-realm settle site
Triage note: Test shows resolving functions of an awaited thenable were taking the foreign (settle-site) realm's constructors instead of the async function's own realm; a realm-boundary confusion that can leak foreign-realm objects/functions across the realm boundary.
Contents
The bug at a glance
Reachable purely from script whenever two JSGlobalObjects share a VM and a promise from one realm is awaited/settled by another (createGlobalObject in JSC shells, and in the browser via same-VM contexts such as same-origin iframes and ShadowRealm), so the trigger surface is ordinary async/await and Promise combinator code. The impact is a realm-boundary confusion that hands script from realm A a function/object (resolving functions, AggregateError, Promise.allSettled result records) constructed in realm B, i.e. a cross-realm reference leak that can seed prototype-pollution and confused-deputy chains; there is no direct memory corruption, so Medium/5.4 fits.
In JavaScript the realm a job runs in is not cosmetic: it decides which %Promise%, %Function%, %Object% and %AggregateError% a job’s newly minted objects are stamped with, and the spec pins each job’s realm at creation time for exactly this reason. JSC had two shortcuts that quietly used the wrong realm. The await fast paths adopted a foreign vanilla promise directly instead of routing it through the intermediate promise PromiseResolve mandates, and the internal-microtask dispatcher fed every job the settle site’s globalObject rather than the realm of the object the job actually drives. The payoff for an attacker is clean and deterministic: await a thenable and read f.constructor on the resolving function, and you get a live Function from a foreign realm. This patch re-anchors every internal promise job on the realm of its owning object and makes cross-realm await take the spec-required extra hop.
Root cause
A JSPromise remembers the realm it was created in via realm(). Internal promise machinery in JSC is driven by queueMicrotask/runInternalMicrotask in Source/JavaScriptCore/runtime/JSMicrotask.cpp, and by JSPromise::performPromiseThenWithInternalMicrotask, fulfillPromise, rejectPromise, resolvePromise, and the async-await entry points in JSPromise.cpp. The realm chosen when a job runs determines which intrinsics (Function, Object, AggregateError, Promise) any objects the job creates — most visibly the pair of resolving functions handed to a thenable’s then(onFulfilled, onRejected) — belong to.
Defect 1 (adoption fast path). JSPromise::resolveWithInternalMicrotaskForAsyncAwait and resolveWithInternalMicrotask had a fast path that, when the awaited resolution was itself a JSPromise, adopted it directly via performPromiseThenWithInternalMicrotask/PromiseResolveThenableJobWithInternalMicrotaskFast as long as the species watchpoint was valid. For a cross-realm vanilla promise this skips the intermediate promise that the spec’s PromiseResolve requires (its SameValue(Get(x,"constructor"), C) check fails across realms), so the async function’s continuation ends up running against the foreign promise’s realm. The fix adds promise->realm() == globalObject to both fast-path guards: a cross-realm promise now falls through to the thenable path, whose resolving functions re-anchor the realm. This costs cross-realm await exactly one extra microtask, matching V8; same-realm await is unchanged except for one pointer comparison.
Defect 2 (settle-site realm). runInternalMicrotask received a globalObject argument that is the realm of the queue entry — i.e. the settle site, which can be foreign when then/catch/finally or a combinator was invoked on a cross-realm promise. It passed that foreign globalObject straight into the job handlers. Per spec each job’s realm is fixed at creation (e.g. Await step 4), so the patch makes runInternalMicrotask derive the realm from the object each job drives: generator->realm() for AsyncFunctionResume and the async-generator jobs, resultPromise->realm() (via the promise or the combinator context’s promise()) for PromiseReactionJob, PromiseRaceResolveJob, PromiseAll/AllSettled/AnyResolveJob and the finally jobs, module->realm() for the async-module jobs, and the async-from-sync iterator’s own promise->realm(). It follows the precedent already set by JSPromise::pipeFrom.
Supporting change. fulfillPromise, rejectPromise, reject, fulfill, rejectWithCaughtException and performPromiseThenWithInternalMicrotask are rewritten to drop the explicit JSGlobalObject* parameter and instead read realm() internally, so a promise can never be settled or have reactions queued against a caller-supplied foreign realm. resolvePromise keeps its spec-sanctioned fast path but now enqueues the thenable job with the resolution promise’s realm (promise->realm()->queueMicrotask(...)) and promiseResolveWithoutHandlerJob calls promise->resolvePromise(promise->realm(), ...). The two added stress tests observe f.constructor (and the realm of allSettled result objects and Promise.any’s AggregateError) and assert it is always the async function’s own realm.
Key code
Cross-realm promises now fall off the adopt fast path, and internal jobs anchor on the object’s own realm.
// JSPromise::resolveWithInternalMicrotaskForAsyncAwait
- if (promiseSpeciesWatchpointIsValid(vm, promise)) [[likely]]
- return promise->performPromiseThenWithInternalMicrotask(vm, globalObject, task, nullptr, context);
+ if (promise->realm() == globalObject && promiseSpeciesWatchpointIsValid(vm, promise)) [[likely]]
+ return promise->performPromiseThenWithInternalMicrotask(vm, task, nullptr, context);
// JSPromise::resolvePromise (fast path kept, but job runs in the promise's realm)
- return globalObject->queueMicrotask(vm, InternalMicrotask::PromiseResolveThenableJobFast, 0, resolutionObject, this, jsUndefined());
+ return promise->realm()->queueMicrotask(vm, InternalMicrotask::PromiseResolveThenableJobFast, 0, resolutionObject, this, jsUndefined());
// runInternalMicrotask: PromiseReactionJob resolve branch
- RELEASE_AND_RETURN(scope, promise->resolvePromise(globalObject, vm, result));
+ RELEASE_AND_RETURN(scope, promise->resolvePromise(promise->realm(), vm, result));
// runInternalMicrotask: AsyncFunctionResume
+ JSGlobalObject* generatorGlobalObject = generator->realm();
...
- JSPromise::resolveWithInternalMicrotaskForAsyncAwait(globalObject, vm, value, InternalMicrotask::AsyncFunctionResume, generator);
+ JSPromise::resolveWithInternalMicrotaskForAsyncAwait(generatorGlobalObject, vm, value, InternalMicrotask::AsyncFunctionResume, generator);
Patch walkthrough
Source/JavaScriptCore/runtime/JSPromise.cpp— Adds thepromise->realm() == globalObjectguard to the vanilla-promise fast paths in bothresolveWithInternalMicrotaskForAsyncAwaitandresolveWithInternalMicrotask, forcing cross-realm awaited promises onto the thenable path that re-anchors the realm.fulfillPromise/rejectPromisenow setJSGlobalObject* globalObject = realm()internally instead of taking it as a parameter, andperformPromiseThenWithInternalMicrotaskreadsrealm()per settled branch. InresolvePromisethe thenable-fast job is enqueued viapromise->realm()->queueMicrotask(...)so the adopted promise’s own realm drives the job.Source/JavaScriptCore/runtime/JSMicrotask.cpp—runInternalMicrotaskis rewritten so each job derives its realm from the object it drives rather than the incoming settle-siteglobalObject: PromiseRace/All/AllSettled/AnyResolveJob useresultPromise->realm(), AsyncFunctionResume capturesgenerator->realm()asgeneratorGlobalObjectand uses it forcallMicrotask,promise->resolveandresolveWithInternalMicrotaskForAsyncAwait, the async-generator jobs usegenerator->realm(), the finally jobs useresultPromise->realm(), and the async-module jobs usemodule->realm().asyncFromSyncIteratorContinueOrDone,asyncModuleExecutionDoneandasyncModuleExecutionResumeare refactored to take the concrete object and status directly, andpromiseResolveWithoutHandlerJobresolves withpromise->realm().Source/JavaScriptCore/runtime/JSPromise.h— Updates the signatures to match:reject,fulfill,rejectAsHandled,rejectWithCaughtException,rejectPromise,fulfillPromiseandperformPromiseThenWithInternalMicrotaskall lose theirJSGlobalObject*parameter (rejectWithCaughtException now takesVM&). This is what makes it structurally impossible to settle a promise against a foreign realm; the realm is always taken from the promise itself.JSTests/stress/cross-realm-await-thenable-resolve-realm.js— New regression test. An async function in a child realm awaits a pending/fulfilled/subclassed cross-realm promise, then awaits a thenable and records whether the resolving functionf’s constructor is the own realm’s Function or the main realm’s (MainFunction). It also covers resolving-function adoption and async generators. All cases must beown-realm; pre-patch the thenable’s resolving function wasmain-realm.JSTests/stress/cross-realm-promise-internal-reaction-realm.js— New regression test exercising the internal-reaction jobs: then/catch/finally on a cross-realm promise, Promise.all/race/allSettled/any, the allSettled result object’s realm, Promise.any’s AggregateError realm, async-from-sync iteration, and an async-generator driver, plus an own-realm control. Each must observe the async function’s own realm for the subsequently awaited thenable’s resolving function (or for the created result object/error).Source/JavaScriptCore/runtime/JSModuleLoader.cpp / CyclicModuleRecord.cpp / Completion.cpp / JSModuleRecord.cpp / ModuleRegistryEntry.cpp— Mechanical call-site updates for the new signatures: everypromise->reject(vm, globalObject, x)/fulfill(vm, globalObject, x)/rejectWithCaughtException(globalObject, scope)/performPromiseThenWithInternalMicrotask(vm, globalObject, ...)becomes the globalObject-free form, so module loading, dynamic import, and top-level-await settle their promises on the promise’s own realm.Source/JavaScriptCore/runtime/JSGlobalObject.cpp / JSGlobalObjectFunctions.cpp / JSPromiseConstructor.cpp / JSPromisePrototype.cpp / dfg/DFGOperations.cpp / API/JSAPIGlobalObject.mm / jsc.cpp / tools/JSDollarVM.cpp / PinballCompletion.cpp / ShadowRealmPrototype.cpp / wasm/*— Further mechanical updates of the same reject/fulfill/rejectWithCaughtException/performPromiseThenWithInternalMicrotask call sites across the promise host functions, DFG operations, combinator constructors, finally prototype, WebAssembly streaming/promising, ShadowRealm import, and the JS API/shell, all conforming to the realm-from-the-promise contract.Source/WebCore/bindings/js/JSDOMPromiseDeferred.cpp / JSDOMGlobalObject.cpp / ScriptModuleLoader.cpp / bindings/scripts/CodeGeneratorJS.pm (+ generated JSTestCallbackInterface.cpp)— WebCore-side call-site updates: DeferredPromise::callFunction, the DOM module loaders, and the IDL callback code generator emit the new globalObject-free reject/fulfill/rejectAsHandled forms so DOM-facing promise settlement also anchors on the promise’s realm.
Background
Realm / JSGlobalObject — An ECMAScript realm bundles a global object with its own set of intrinsics (%Function%, %Object%, %Promise%, %AggregateError%, …). In JSC a realm is a JSGlobalObject; a single VM can host many, e.g. via createGlobalObject in the shell or same-origin iframes and ShadowRealm in the browser. Objects created by a job carry the intrinsics of the realm the job runs in.
PromiseResolve and the constructor check — The spec’s PromiseResolve (tc39 sec-promise-resolve) returns a promise x unchanged only when SameValue(Get(x,“constructor”), C) holds. Across realms the constructor differs from the current realm’s Promise, so a cross-realm promise must be wrapped through a fresh capability, adding one microtask hop. JSC’s await fast path skipped that check, which is the root of defect 1.
Await job realm (Await step 4) — The Await abstract operation fixes the running-job realm when the job is created, not when the promise settles. JSC’s runInternalMicrotask instead used the settle site’s globalObject, so a promise settled from a foreign realm dragged that foreign realm into the async continuation - defect 2.
Internal microtask jobs — JSC implements PromiseReactionJob, the Promise.all/race/allSettled/any resolve jobs, finally jobs, async-generator drivers, async-from-sync iteration, and async-module execution as internal microtasks dispatched by runInternalMicrotask. Each creates observable objects (resolving functions, result records, AggregateError) whose realm must be the job’s own realm.
Vulnerability window
- Setup — Two realms share a VM; the main realm creates a pending vanilla Promise and hands it to a child realm (e.g. via createGlobalObject / other.subject).
- Await the foreign promise — An async function in the child realm does
await subject. The await fast path adopts the foreign vanilla promise directly, skipping the PromiseResolve intermediate promise, so the continuation is anchored to the main realm. - Settle from the foreign site — Alternatively, then/catch/finally or a combinator invoked on a cross-realm promise causes runInternalMicrotask to run the reaction job with the settle site’s foreign globalObject.
- Observe the leak — The child async function then awaits a thenable
{ then(f) { ... } }. The resolving functionfis constructed in the foreign realm, sof.constructor === MainFunction- a live main-realm Function is handed to child-realm script. - Fix: extra hop — The added
promise->realm() == globalObjectguards push cross-realm await onto the thenable path, whose resolving functions re-anchor to the async function’s realm (one extra microtask, matching V8). - Fix: anchor jobs — runInternalMicrotask derives each job’s realm from the generator/result promise/module it drives, and the settle helpers read realm() internally, so resolving functions and created objects belong to the correct realm.
Proof of concept
This is the commit’s own test reduced to one case. A child realm awaits a pending main-realm promise, then awaits a thenable; the resolving function passed to then is the observable. On the vulnerable build its .constructor is the main realm’s Function (MainFunction), proving a cross-realm function reference has leaked into child-realm code; on the fixed build it is the child realm’s own Function. The companion test cross-realm-promise-internal-reaction-realm.js extends the same observation to then/catch/finally, the Promise combinators, Promise.any’s AggregateError, and async generators. No memory-corruption primitive is used - the leak is a language-level realm confusion observed purely through constructor identity.
// Distilled from JSTests/stress/cross-realm-await-thenable-resolve-realm.js
const results = {};
const other = createGlobalObject();
other.MainFunction = Function; // this (main) realm's Function
other.results = results;
let resolve;
other.subject = new Promise((r) => { resolve = r; }); // pending main-realm promise
new other.Function(`
(async () => {
await subject; // adopt a cross-realm vanilla promise
await { then(f) { // thenable: f is a resolving function
results.k = f.constructor === Function ? "own-realm"
: f.constructor === MainFunction ? "main-realm"
: "unknown";
f(0);
} };
})();
`)();
drainMicrotasks();
resolve(1);
drainMicrotasks();
// Pre-patch: results.k === "main-realm" (foreign Function leaked into child realm)
// Post-patch: results.k === "own-realm"
if (results.k !== "own-realm") throw new Error("realm leak: " + results.k);
Exploitation
- Obtain a foreign realm reference — In a same-VM multi-realm setting (shell createGlobalObject, or same-origin frames / ShadowRealm sharing a VM) get script running in realm A to await or chain onto a promise associated with realm B.
- Capture a foreign constructor — Await a thenable and read
f.constructor(or catch a Promise.any AggregateError, or inspect a Promise.allSettled result object). Pre-patch this yields a live Function/Object/AggregateError from the other realm, i.e. an intrinsic that realm A was never meant to reach. - Abuse the cross-realm object — Use the foreign Function/prototype to pollute or read the other realm’s intrinsics (e.g. reaching its %Function.prototype% or global via constructor chains), turning the boundary confusion into a confused-deputy or prototype-pollution primitive against the other realm’s code. No further leak is direct memory disclosure; impact is scoped to cross-realm object confusion.
Detection & hunting
For defenders and SOC / detection engineers:
- Constructor-identity mismatch —
- Microtask realm audit —
- Microtask-count delta —
- Regression tests —
Audit directions
- Remaining realm-from-argument settle sites —
- Other adopt fast paths —
- Objects created inside internal jobs —
- ShadowRealm and DOM multi-realm surfaces —