CVE-2025-0240
Overview
Background
- Synthetic module
- an internally-generated
ModuleObject(such as a JSON module) whose evaluation is handled bySyntheticModuleEvaluaterather than by running user bytecode. - Compartment
- SpiderMonkey’s memory/security boundary grouping objects that may share direct references; touching an object from the wrong compartment (a cross-compartment access) is unsafe and can dereference freed memory.
- `MutableHandle<Value>`
- a rooted, writable output slot passed by the caller into which a callee stores a result
Value. - `AsyncFunctionReturned`
- the helper that resolves an async/module result
PromiseObjectwith a suppliedValue.
Root Cause Analysis
When evaluating a JSON module, SyntheticModuleEvaluate received the caller’s MutableHandle<Value> result output slot and passed that same result into AsyncFunctionReturned to resolve the module’s result promise. At that point result was the caller-provided out-parameter whose contents were not owned by the synthetic-module evaluation and, when moduleEvaluate was invoked across a compartment boundary (for example via with (newGlobal()) this.moduleEvaluate(...)), could hold a Value belonging to — or backed by memory freed in — a different compartment. Resolving the promise with that aliased slot performed a cross-compartment access on a possibly-dangling object, yielding a use-after-free.
The fix recognizes that JSON synthetic modules always produce undefined, so it resolves the promise with the compartment-safe constant JS::UndefinedHandleValue instead of the aliased result slot, and only writes the freshly-created resultPromise back through the (renamed) rval output at the end. Because a constant undefined handle carries no cross-compartment pointer, the invalid access can no longer occur.
Value; the fix passes the invariant JSON result JS::UndefinedHandleValue instead, eliminating the dangerous alias.Attack Path
- Create a foreign compartment
Attacker script calls
newGlobalto obtain an object living in a separate compartment from the current global. - Reach evaluation cross-compartment
Using
with (b())the attacker binds the foreign global’s scope and invokesthis.moduleEvaluate(parseModule("45172", "", "json")), driving JSON-module evaluation across the compartment boundary. - Enter synthetic evaluation
Evaluation dispatches to
SyntheticModuleEvaluate, which forwards the caller’sresultoutput slot toAsyncFunctionReturned. - Trigger cross-compartment access
Resolving the result promise reads the aliased
resultslot, which references an object owned by / freed in the other compartment, dereferencing invalid memory. - Achieve use-after-free The stale cross-compartment reference is used, producing a use-after-free that an attacker can shape for memory corruption.
Impact Assessment
Files Changed
js/src/jit-test/tests/modules/evaluation-result.jsjs/src/testcase.jsjs/src/vm/Modules.cpp
Audit Directions
- Output-slot aliasingReview other callees that pass a caller-supplied
MutableHandle<Value>output parameter straight into resolution/consumption helpers before writing their own result, where the slot may still hold foreign data. - Cross-compartment evaluation entry pointsAudit module and promise resolution paths (
AsyncFunctionReturned, synthetic/JSON module evaluation) forValues that may originate in another compartment when invoked viawith/newGlobal-style cross-realm calls. - Invariant-known resultsLook for cases where a result is by construction constant (e.g. always
undefined) yet a mutable, externally-sourced slot is used instead of a safe constant handle likeJS::UndefinedHandleValue.
Patch
diff --git a/js/src/testcase.js b/js/src/testcase.js
new file mode 100644
index 00000000000..41fa48273cb
--- /dev/null
+++ b/js/src/testcase.js
@@ -0,0 +1,4 @@
+a = "".startsWith("")
+function b() { return newGlobal(b) }
+b.newCompartment = a
+with (b()) this.moduleEvaluate(parseModule("45172", "", "json"))
diff --git a/js/src/vm/Modules.cpp b/js/src/vm/Modules.cpp
index 1a017f0308b..67506114e47 100644
--- a/js/src/vm/Modules.cpp
+++ b/js/src/vm/Modules.cpp
@@ -46,9 +46,9 @@ using mozilla::Utf8Unit;
static bool ModuleLink(JSContext* cx, Handle<ModuleObject*> module);
static bool ModuleEvaluate(JSContext* cx, Handle<ModuleObject*> module,
- MutableHandle<Value> result);
+ MutableHandle<Value> rval);
static bool SyntheticModuleEvaluate(JSContext* cx, Handle<ModuleObject*> module,
- MutableHandle<Value> result);
+ MutableHandle<Value> rval);
////////////////////////////////////////////////////////////////////////////////
// Public API
@@ -1493,8 +1493,8 @@ static bool InnerModuleLinking(JSContext* cx, Handle<ModuleObject*> module,
static bool SyntheticModuleEvaluate(JSContext* cx,
Handle<ModuleObject*> moduleArg,
- MutableHandle<Value> result) {
- // Steps 1-12 happens elsewhere in the engine.
+ MutableHandle<Value> rval) {
+ // Steps 1-12 happen elsewhere in the engine.
// Step 13. Let pc be ! NewPromiseCapability(%Promise%).
Rooted<PromiseObject*> resultPromise(cx, CreatePromiseObjectForAsync(cx));
@@ -1502,15 +1502,18 @@ static bool SyntheticModuleEvaluate(JSContext* cx,
return false;
}
+ // Since the only synthetic modules we support are JSON modules, result is
+ // always |undefined|.
+
// Step 14. IfAbruptRejectPromise(result, pc) (Skipped)
// 15. Perform ! pc.[[Resolve]](result).
- if (!AsyncFunctionReturned(cx, resultPromise, result)) {
+ if (!AsyncFunctionReturned(cx, resultPromise, JS::UndefinedHandleValue)) {
return false;
}
// 16. Return pc.[[Promise]].
- result.set(ObjectValue(*resultPromise));
+ rval.set(ObjectValue(*resultPromise));
return true;
}
diff --git a/js/src/jit-test/tests/modules/evaluation-result.js b/js/src/jit-test/tests/modules/evaluation-result.js
new file mode 100644
index 00000000000..6020403a0f2
--- /dev/null
+++ b/js/src/jit-test/tests/modules/evaluation-result.js
@@ -0,0 +1,11 @@
+async function parseAndEvaluate(source, filename, kind) {
+ let m = parseModule(source, filename, kind);
+ moduleLink(m);
+ return await moduleEvaluate(m);
+}
+
+(async () => {
+ assertEq(await parseAndEvaluate("[]", "", "js"), undefined);
+ assertEq(await parseAndEvaluate("[]", "", "json"), undefined);
+})();
+drainJobQueue();
Regression Test / PoC
diff --git a/js/src/jit-test/tests/modules/evaluation-result.js b/js/src/jit-test/tests/modules/evaluation-result.js
new file mode 100644
index 00000000000..6020403a0f2
--- /dev/null
+++ b/js/src/jit-test/tests/modules/evaluation-result.js
@@ -0,0 +1,11 @@
+async function parseAndEvaluate(source, filename, kind) {
+ let m = parseModule(source, filename, kind);
+ moduleLink(m);
+ return await moduleEvaluate(m);
+}
+
+(async () => {
+ assertEq(await parseAndEvaluate("[]", "", "js"), undefined);
+ assertEq(await parseAndEvaluate("[]", "", "json"), undefined);
+})();
+drainJobQueue();