Firefox · SpiderMonkey
CVE-2026-117909
UAF in SpiderMonkey
Overview
High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Background
- Security wrapper
- a cross-compartment proxy SpiderMonkey places around an object from a differently-privileged compartment; it denies operations unless the subject may legitimately be unwrapped.
- `SecurityWrapper::enter`
- the policy hook run before any operation on a wrapped object; it sets a
bool* bp(allowed?) and, when denied, may report an error. - `mayThrow` flag
- tells
enterwhether the current call site may set a pending exception; GC-time access checks passfalsebecause they must not perturb exception state or run script. - FinalizationRegistry / WeakRef
- GC primitives that register a target object and consult its access policy while the engine is in a state that must stay exception-clean.
Root Cause Analysis
The only behavioural change in the patch is in
SecurityWrapper<Base>::enter: it called ReportAccessDenied(cx) on every denied access, and the fix now gates that on if (mayThrow). The diff-provable defect is therefore a logic error — a policy query made with mayThrow == false (a context that must not raise) still set a pending exception on cx. The advisory attributes the security impact to a use-after-free in the GC component, reached via FinalizationRegistry.prototype.register and the WeakRef constructor (the APIs the added test exercises): those paths run a non-throwing access check on an unwrappable security-wrapper target, and the spurious pending exception leaves the engine in a state the GC code mishandles. (The non-throwing-check assumption, the pending-exception→UAF chain, and the freed object are all inferred from the advisory and test; the diff shows only the exception-reporting fix.) The APIs still throw a clean “Permission denied” on their proper path, so register/WeakRef must have both a buggy non-throwing internal check and a separate throwing path — a two-path structure that is inferred, not shown.Key insight
The wrapper broke its caller’s
mayThrow contract by reporting an exception during a non-throwing access check; gating ReportAccessDenied on mayThrow restores the invariant the GC paths rely on.Attack Path
- ENTRY From content JS, obtain a cross-compartment object reachable only through a security wrapper that cannot be unwrapped.
- CONTROLLED INPUT
Pass that wrapper as the target of
FinalizationRegistry.prototype.register(target, …)ornew WeakRef(target). - TRIGGER
The GC primitive runs a non-throwing (
mayThrow == false) access check, which hits the denied branch. - MISSING CHECK
enterreports an access-denied exception regardless ofmayThrow, leaving a pending exception where the caller expects none. - IMPACT The GC component proceeds in this inconsistent state and ends in a use-after-free. (The specific freed object is not shown by the patch — inferred from the advisory’s UAF label and the added test, which asserts only the corrected throw behavior.)
Impact Assessment
Untrusted content-process JavaScript can reach a use-after-free in the garbage collector, a strong primitive that typically enables memory corruption and potentially content-process RCE (escalation inferred; the patch demonstrates only the exception-state defect). Preconditions are modest: script must create a non-unwrappable security wrapper and hand it to a FinalizationRegistry/WeakRef. The advisory rates this high; no in-the-wild exploitation is stated.
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifjs/src/proxy/SecurityWrapper.cpp |
modified | |
ifjs/src/shell/js.cpp |
modified | |
assertEqjs/src/jit-test/tests/proxy/security-wrappers.js |
modified |
Files Changed
js/src/jit-test/tests/gc/finalizationRegistry-security-wrapper.jsjs/src/jit-test/tests/proxy/security-wrappers.jsjs/src/proxy/SecurityWrapper.cppjs/src/shell/js.cpp
Audit Directions
- Non-throwing policy hooksAudit every proxy/
Wrapperpolicy method that takesbool* bpormayThrowfor side effects (exception reporting, allocation, JS callbacks) on the denied path. - GC-time access checksReview FinalizationRegistry/WeakRef and other GC primitives for assumptions that a target access check neither throws nor runs script.
Patch
diff --git a/js/src/proxy/SecurityWrapper.cpp b/js/src/proxy/SecurityWrapper.cpp
index 9cd39a56233..f02e7109b24 100644
--- a/js/src/proxy/SecurityWrapper.cpp
+++ b/js/src/proxy/SecurityWrapper.cpp
@@ -15,7 +15,9 @@ template <class Base>
bool SecurityWrapper<Base>::enter(JSContext* cx, HandleObject wrapper,
HandleId id, Wrapper::Action act,
bool mayThrow, bool* bp) const {
- ReportAccessDenied(cx);
+ if (mayThrow) {
+ ReportAccessDenied(cx);
+ }
*bp = false;
return false;
}
diff --git a/js/src/shell/js.cpp b/js/src/shell/js.cpp
index 44ac9793342..2fc532c2080 100644
--- a/js/src/shell/js.cpp
+++ b/js/src/shell/js.cpp
@@ -853,6 +853,10 @@ already_AddRefed<JS::Stencil> OffThreadJob::stealStencil(JSContext* cx) {
struct ShellCompartmentPrivate {
HeapPtr<ArrayObject*> blackRoot;
HeapPtr<ArrayObject*> grayRoot;
+
+ // If true, objects from other compartments are wrapped with security
+ // wrappers.
+ bool useSecurityWrappers = false;
};
struct MOZ_STACK_CLASS EnvironmentPreparer
@@ -917,10 +921,11 @@ enum class ShellGlobalKind {
static void SetStandardRealmOptions(JSContext* cx, JS::RealmOptions& options);
static JSObject* NewStringInterruptCallbackGlobal(JSContext* cx);
-static JSObject* NewGlobalObject(
- JSContext* cx, JS::RealmOptions& options, JSPrincipals* principals,
- ShellGlobalKind kind, bool immutablePrototype,
- JS::HandleObject existingWindowProxy = nullptr);
+static JSObject* NewGlobalObject(JSContext* cx, JS::RealmOptions& options,
+ JSPrincipals* principals, ShellGlobalKind kind,
+ bool immutablePrototype,
+ JS::HandleObject existingWindowProxy = nullptr,
+ bool securityWrappers = false);
/*
* A toy WindowProxy class for the shell. This is intended for testing code
@@ -948,6 +953,8 @@ JSObject* NewShellWindowProxy(JSContext* cx, JS::HandleObject global) {
return obj;
}
+static const js::CrossCompartmentSecurityWrapper ShellSecurityWrapper(0);
+
// A wrap callback that matches the behavior of the browser which reuses an
// existing wrapper object via Wrapper::Renew when one is supplied. This allows
// exercising Wrapper::Renew.
@@ -955,11 +962,15 @@ static JSObject* ShellWrapObjectCallback(JSContext* cx,
JS::HandleObject existing,
JS::HandleObject obj) {
MOZ_ASSERT(!obj->is<js::WrapperObject>() || js::IsWindowProxy(obj));
+ auto* priv = static_cast<ShellCompartmentPrivate*>(
+ JS_GetCompartmentPrivate(cx->compartment()));
+ const js::Wrapper* handler = (priv && priv->useSecurityWrappers)
+ ? &ShellSecurityWrapper
+ : &js::CrossCompartmentWrapper::singleton;
if (existing) {
- return js::Wrapper::Renew(existing, obj,
- &js::CrossCompartmentWrapper::singleton);
+ return js::Wrapper::Renew(existing, obj, handler);
}
- return js::Wrapper::New(cx, obj, &js::CrossCompartmentWrapper::singleton);
+ return js::Wrapper::New(cx, obj, handler);
}
static const JSWrapObjectCallbacks ShellWrapObjectCallbacks = {
@@ -7482,6 +7493,7 @@ static bool NewGlobal(JSContext* cx, unsigned argc, Value* vp) {
JS::RealmBehaviors& behaviors = options.behaviors();
ShellGlobalKind kind = ShellGlobalKind::WindowProxy;
bool immutablePrototype = true;
+ bool securityWrappers = false;
SetStandardRealmOptions(cx, options);
@@ -7577,6 +7589,13 @@ static bool NewGlobal(JSContext* cx, unsigned argc, Value* vp) {
immutablePrototype = v.toBoolean();
}
+ if (!JS_GetProperty(cx, opts, "securityWrappers", &v)) {
+ return false;
+ }
+ if (v.isBoolean()) {
+ securityWrappers = v.toBoolean();
+ }
+
if (!JS_GetProperty(cx, opts, "systemPrincipal", &v)) {
return false;
}
@@ -7665,13 +7684,20 @@ static bool NewGlobal(JSContext* cx, unsigned argc, Value* vp) {
return false;
}
+ if (securityWrappers && creationOptions.compartmentSpecifier() ==
+ JS::CompartmentSpecifier::ExistingCompartment) {
+ JS_ReportErrorASCII(cx, "securityWrappers requires a new compartment");
+ return false;
+ }
+
if (!CheckRealmOptions(cx, options, principals.get())) {
return false;
}
- RootedObject global(cx,
- NewGlobalObject(cx, options, principals.get(), kind,
- immutablePrototype, existingWindowProxy));
+ RootedObject global(
+ cx,
+ NewGlobalObject(cx, options, principals.get(), kind, immutablePrototype,
+ existingWindowProxy, securityWrappers));
if (!global) {
return false;
}
@@ -10512,6 +10538,9 @@ static const JSFunctionSpecWithHelp shell_functions[] = {
" their prototypes will be sealed. These constructors will be defined on the\n"
" global as non-configurable and non-writable.\n"
" immutablePrototype: whether the global's prototype is immutable.\n"
+" securityWrappers: If true, objects from other compartments will be\n"
+" wrapped in the new global's compartment with security wrappers.\n"
+" Requires a new compartment.\n"
" principal: if present, its value converted to a number must be an\n"
" integer that fits in 32 bits; use that as the new realm's\n"
" principal. Shell principals are toys, meant only for testing; one\n"
@@ -12028,7 +12057,8 @@ static JSObject* NewStringInterruptCallbackGlobal(JSContext* cx) {
static JSObject* NewGlobalObject(JSContext* cx, JS::RealmOptions& options,
JSPrincipals* principals, ShellGlobalKind kind,
bool immutablePrototype,
- JS::HandleObject existingWindowProxy) {
+ JS::HandleObject existingWindowProxy,
+ bool securityWrappers) {
RootedObject glob(cx,
JS_NewGlobalObject(cx, &global_class, principals,
JS::DontFireOnNewGlobalHook, options));
@@ -12046,6 +12076,14 @@ static JSObject* NewGlobalObject(JSContext* cx, JS::RealmOptions& options,
{
JSAutoRealm ar(cx, glob);
+ if (securityWrappers) {
+ ShellCompartmentPrivate* priv = EnsureShellCompartmentPrivate(cx);
+ if (!priv) {
+ return nullptr;
+ }
+ priv->useSecurityWrappers = true;
+ }
+
if (kind == ShellGlobalKind::WindowProxy) {
RootedObject proxy(cx, NewShellWindowProxy(cx, glob));
if (!proxy) {
diff --git a/js/src/jit-test/tests/gc/finalizationRegistry-security-wrapper.js b/js/src/jit-test/tests/gc/finalizationRegistry-security-wrapper.js
new file mode 100644
index 00000000000..08cdf6ae80d
--- /dev/null
+++ b/js/src/jit-test/tests/gc/finalizationRegistry-security-wrapper.js
@@ -0,0 +1,12 @@
+// FinalizationRegistry.prototype.register and the WeakRef constructor must
+// throw if the target is a wrapper that can't be unwrapped.
+
+load(libdir + "asserts.js");
+
+var g = newGlobal({newCompartment: true, securityWrappers: true});
+var target = {};
+var fr = new g.FinalizationRegistry(() => {});
+assertErrorMessage(() => fr.register(target, 1), g.Error,
+ "Permission denied to access object");
+assertErrorMessage(() => new g.WeakRef(target), g.Error,
+ "Permission denied to access object");
diff --git a/js/src/jit-test/tests/proxy/security-wrappers.js b/js/src/jit-test/tests/proxy/security-wrappers.js
new file mode 100644
index 00000000000..7243964972d
--- /dev/null
+++ b/js/src/jit-test/tests/proxy/security-wrappers.js
@@ -0,0 +1,124 @@
+// Test cross-compartment operations with newGlobal's securityWrappers option.
+
+load(libdir + "asserts.js");
+
+var g = newGlobal({newCompartment: true, securityWrappers: true});
+g.libdir = libdir;
+g.eval(`load(libdir + "asserts.js");`);
+
+var obj = {x: 1, f() { return 2; }};
+function fun() { return 3; }
+var arr = [1, 2];
+
+// Objects can be passed into |g| and are wrapped with a security wrapper there.
+g.obj = obj;
+g.fun = fun;
+g.arr = arr;
+assertEq(g.obj, obj);
+assertEq(g.eval("obj"), obj);
+assertEq(g.eval("(o => o)")(obj), obj);
+
+g.eval(`
+ function assertDenied(f) {
+ assertErrorMessage(f, Error, "Permission denied to access object");
+ }
+
+ assertEq(isProxy(obj), true);
+ assertEq(typeof obj, "object");
+ assertEq(typeof fun, "function");
+ assertEq(obj === obj, true);
+ assertEq(obj === fun, false);
+
+ // Property access is denied.
+ assertDenied(() => obj.x);
+ assertDenied(() => obj["x"]);
+ assertDenied(() => obj[0]);
+ assertDenied(() => obj[Symbol.iterator]);
+ assertDenied(() => { obj.x = 2; });
+ assertDenied(() => "x" in obj);
+ assertDenied(() => delete obj.x);
+ assertDenied(() => Object.prototype.hasOwnProperty.call(obj, "x"));
+ assertDenied(() => Object.getOwnPropertyDescriptor(obj, "x"));
+ assertDenied(() => Object.defineProperty(obj, "y", {value: 1}));
+ assertDenied(() => Object.defineProperty(obj, "y", {get() {}}));
+ assertDenied(() => Object.keys(obj));
+ assertDenied(() => Reflect.ownKeys(obj));
+ assertDenied(() => { for (var k in obj) {} });
+ assertDenied(() => obj.f());
+ assertDenied(() => JSON.stringify(obj));
+ assertDenied(() => String(obj));
+ assertDenied(() => Object.prototype.toString.call(obj));
+
+ // Calls and constructs are denied.
+ assertDenied(() => fun());
+ assertDenied(() => new fun());
+ assertDenied(() => fun.call());
+ assertDenied(() => Reflect.apply(fun, null, []));
+
+ // Function.prototype.toString doesn't throw and doesn't leak the source.
+ assertEq(Function.prototype.toString.call(fun),
+ "function () {\\n [native code]\\n}");
+ assertErrorMessage(() => Function.prototype.toString.call(obj), TypeError,
+ /incompatible object/);
+
+ // Prototype and extensibility.
+ assertEq(Object.getPrototypeOf(obj) !== null, true);
+ assertDenied(() => Object.setPrototypeOf(obj, null));
+ assertEq(Object.isExtensible(obj), true);
+ assertErrorMessage(() => Object.preventExtensions(obj), TypeError,
+ /can't change object's extensibility/);
+
+ // Class checks don't reveal the target's class.
+ assertEq(Array.isArray(arr), false);
+ assertDenied(() => Map.prototype.has.call(obj, 1));
+
+ // The wrapper can be used as a key in collections.
+ var wm = new WeakMap();
+ wm.set(obj, 1);
+ assertEq(wm.get(obj), 1);
+ assertEq(new Set([obj]).has(obj), true);
+
+ // The wrapper can't be unwrapped to find the target's global.
+ assertEq(objectGlobal(obj), null);
+`);
+assertEq(g.eval("typeof fun"), "function");
+
+// Primitives pass through unchanged.
+g.num = 1;
+g.str = "foo";
+g.sym = Symbol.iterator;
+assertEq(g.eval("num + str.length"), 4);
+assertEq(g.eval("sym === Symbol.iterator"), true);
+
+// The original object is unaffected.
+assertEq(obj.x, 1);
+assertEq(fun(), 3);
+
+// Objects from |g| are wrapped with normal cross-compartment wrappers in this
+// compartment, so all operations work.
+var o = g.eval(`({y: 5, f() { return this.y; }, arr: [1, 2, 3]})`);
+assertEq(isProxy(o), true);
+assertEq(o.y, 5);
+assertEq(o.f(), 5);
+o.z = 6;
+assertEq(g.eval("(o => o.z)")(o), 6);
+assertEq("z" in o, true);
+assertEq(delete o.z, true);
+assertEq("z" in o, false);
+assertEq(Object.keys(o).join(), "y,f,arr");
+assertEq(Array.isArray(o.arr), true);
+assertEq(o.arr.length, 3);
+var gfun = g.eval("(function(a, b) { return a + b; })");
+assertEq(gfun(1, 2), 3);
+assertEq(new (g.eval("(function C() { this.v = 7; })"))().v, 7);
+assertEq(Function.prototype.toString.call(gfun), "function(a, b) { return a + b; }");
+
+// Objects from |g| passed back into |g| are unwrapped.
+assertEq(g.eval("(x => x)")(o), o);
+g.o = o;
+assertEq(g.eval("isProxy(o)"), false);
+assertEq(g.eval("o.y"), 5);
+
+// Wrappers in |g| for objects from this compartment can be passed back out.
+assertEq(g.eval("(function() { return obj; })")(), obj);
+assertEq(g.eval("[obj, fun]")[1], fun);
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/js/src/jit-test/tests/gc/finalizationRegistry-security-wrapper.js b/js/src/jit-test/tests/gc/finalizationRegistry-security-wrapper.js
new file mode 100644
index 00000000000..08cdf6ae80d
--- /dev/null
+++ b/js/src/jit-test/tests/gc/finalizationRegistry-security-wrapper.js
@@ -0,0 +1,12 @@
+// FinalizationRegistry.prototype.register and the WeakRef constructor must
+// throw if the target is a wrapper that can't be unwrapped.
+
+load(libdir + "asserts.js");
+
+var g = newGlobal({newCompartment: true, securityWrappers: true});
+var target = {};
+var fr = new g.FinalizationRegistry(() => {});
+assertErrorMessage(() => fr.register(target, 1), g.Error,
+ "Permission denied to access object");
+assertErrorMessage(() => new g.WeakRef(target), g.Error,
+ "Permission denied to access object");
diff --git a/js/src/jit-test/tests/proxy/security-wrappers.js b/js/src/jit-test/tests/proxy/security-wrappers.js
new file mode 100644
index 00000000000..7243964972d
--- /dev/null
+++ b/js/src/jit-test/tests/proxy/security-wrappers.js
@@ -0,0 +1,124 @@
+// Test cross-compartment operations with newGlobal's securityWrappers option.
+
+load(libdir + "asserts.js");
+
+var g = newGlobal({newCompartment: true, securityWrappers: true});
+g.libdir = libdir;
+g.eval(`load(libdir + "asserts.js");`);
+
+var obj = {x: 1, f() { return 2; }};
+function fun() { return 3; }
+var arr = [1, 2];
+
+// Objects can be passed into |g| and are wrapped with a security wrapper there.
+g.obj = obj;
+g.fun = fun;
+g.arr = arr;
+assertEq(g.obj, obj);
+assertEq(g.eval("obj"), obj);
+assertEq(g.eval("(o => o)")(obj), obj);
+
+g.eval(`
+ function assertDenied(f) {
+ assertErrorMessage(f, Error, "Permission denied to access object");
+ }
+
+ assertEq(isProxy(obj), true);
+ assertEq(typeof obj, "object");
+ assertEq(typeof fun, "function");
+ assertEq(obj === obj, true);
+ assertEq(obj === fun, false);
+
+ // Property access is denied.
+ assertDenied(() => obj.x);
+ assertDenied(() => obj["x"]);
+ assertDenied(() => obj[0]);
+ assertDenied(() => obj[Symbol.iterator]);
+ assertDenied(() => { obj.x = 2; });
+ assertDenied(() => "x" in obj);
+ assertDenied(() => delete obj.x);
+ assertDenied(() => Object.prototype.hasOwnProperty.call(obj, "x"));
+ assertDenied(() => Object.getOwnPropertyDescriptor(obj, "x"));
+ assertDenied(() => Object.defineProperty(obj, "y", {value: 1}));
+ assertDenied(() => Object.defineProperty(obj, "y", {get() {}}));
+ assertDenied(() => Object.keys(obj));
+ assertDenied(() => Reflect.ownKeys(obj));
+ assertDenied(() => { for (var k in obj) {} });
+ assertDenied(() => obj.f());
+ assertDenied(() => JSON.stringify(obj));
+ assertDenied(() => String(obj));
+ assertDenied(() => Object.prototype.toString.call(obj));
+
+ // Calls and constructs are denied.
+ assertDenied(() => fun());
+ assertDenied(() => new fun());
+ assertDenied(() => fun.call());
+ assertDenied(() => Reflect.apply(fun, null, []));
+
+ // Function.prototype.toString doesn't throw and doesn't leak the source.
+ assertEq(Function.prototype.toString.call(fun),
+ "function () {\\n [native code]\\n}");
+ assertErrorMessage(() => Function.prototype.toString.call(obj), TypeError,
+ /incompatible object/);
+
+ // Prototype and extensibility.
+ assertEq(Object.getPrototypeOf(obj) !== null, true);
+ assertDenied(() => Object.setPrototypeOf(obj, null));
+ assertEq(Object.isExtensible(obj), true);
+ assertErrorMessage(() => Object.preventExtensions(obj), TypeError,
+ /can't change object's extensibility/);
+
+ // Class checks don't reveal the target's class.
+ assertEq(Array.isArray(arr), false);
+ assertDenied(() => Map.prototype.has.call(obj, 1));
+
+ // The wrapper can be used as a key in collections.
+ var wm = new WeakMap();
+ wm.set(obj, 1);
+ assertEq(wm.get(obj), 1);
+ assertEq(new Set([obj]).has(obj), true);
+
+ // The wrapper can't be unwrapped to find the target's global.
+ assertEq(objectGlobal(obj), null);
+`);
+assertEq(g.eval("typeof fun"), "function");
+
+// Primitives pass through unchanged.
+g.num = 1;
+g.str = "foo";
+g.sym = Symbol.iterator;
+assertEq(g.eval("num + str.length"), 4);
+assertEq(g.eval("sym === Symbol.iterator"), true);
+
+// The original object is unaffected.
+assertEq(obj.x, 1);
+assertEq(fun(), 3);
+
+// Objects from |g| are wrapped with normal cross-compartment wrappers in this
+// compartment, so all operations work.
+var o = g.eval(`({y: 5, f() { return this.y; }, arr: [1, 2, 3]})`);
+assertEq(isProxy(o), true);
+assertEq(o.y, 5);
+assertEq(o.f(), 5);
+o.z = 6;
+assertEq(g.eval("(o => o.z)")(o), 6);
+assertEq("z" in o, true);
+assertEq(delete o.z, true);
+assertEq("z" in o, false);
+assertEq(Object.keys(o).join(), "y,f,arr");
+assertEq(Array.isArray(o.arr), true);
+assertEq(o.arr.length, 3);
+var gfun = g.eval("(function(a, b) { return a + b; })");
+assertEq(gfun(1, 2), 3);
+assertEq(new (g.eval("(function C() { this.v = 7; })"))().v, 7);
+assertEq(Function.prototype.toString.call(gfun), "function(a, b) { return a + b; }");
+
+// Objects from |g| passed back into |g| are unwrapped.
+assertEq(g.eval("(x => x)")(o), o);
+g.o = o;
+assertEq(g.eval("isProxy(o)"), false);
+assertEq(g.eval("o.y"), 5);
+
+// Wrappers in |g| for objects from this compartment can be passed back out.
+assertEq(g.eval("(function() { return obj; })")(), obj);
+assertEq(g.eval("[obj, fun]")[1], fun);
Loading diff…
References
On This Page