High firefox UAF 🔧 Commit mapped

Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impacthigh
DescriptionUse-after-free in the JavaScript: GC component
ComponentSpiderMonkey
Bug ClassUAF
Tracker2079155
Fix commitf08b6dad3968 (firefox) +188/-12
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

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 enter whether the current call site may set a pending exception; GC-time access checks pass false because 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

  1. ENTRY From content JS, obtain a cross-compartment object reachable only through a security wrapper that cannot be unwrapped.
  2. CONTROLLED INPUT Pass that wrapper as the target of FinalizationRegistry.prototype.register(target, …) or new WeakRef(target).
  3. TRIGGER The GC primitive runs a non-throwing (mayThrow == false) access check, which hits the denied branch.
  4. MISSING CHECK enter reports an access-denied exception regardless of mayThrow, leaving a pending exception where the caller expects none.
  5. 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

FunctionChangeNotes
if
js/src/proxy/SecurityWrapper.cpp
modified
if
js/src/shell/js.cpp
modified
assertEq
js/src/jit-test/tests/proxy/security-wrappers.js
modified

Files Changed

  • js/src/jit-test/tests/gc/finalizationRegistry-security-wrapper.js
  • js/src/jit-test/tests/proxy/security-wrappers.js
  • js/src/proxy/SecurityWrapper.cpp
  • js/src/shell/js.cpp

Audit Directions

  • Non-throwing policy hooks
    Audit every proxy/Wrapper policy method that takes bool* bp or mayThrow for side effects (exception reporting, allocation, JS callbacks) on the denied path.
  • GC-time access checks
    Review FinalizationRegistry/WeakRef and other GC primitives for assumptions that a target access check neither throws nor runs script.
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…