Medium firefox UAF 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionUse-after-free in the JavaScript Engine component
ComponentSpiderMonkey
Bug ClassUAF
Tracker2013573
Fix commit5f8adac9467c (firefox) +126/-41
CISA KEVNot listed
CreditedEvyatar Ben Asher, Keane Lucas, Nicholas Carlini, Newton Cheng, Daniel Freeman, Alex Gaynor, and Joel Weinberger using Claude from Anthropic
Disclosed2026-03-24

Changed Functions

FunctionChangeNotes
if
js/loader/ImportMap.cpp
modified
MOZ_RAII
js/public/Exception.h
modified

Files Changed

  • dom/base/nsContentUtils.cpp
  • dom/file/FileReader.cpp
  • dom/ipc/ClonedErrorHolder.cpp
  • js/loader/ImportMap.cpp
  • js/public/ErrorReport.h
  • js/public/Exception.h
  • js/src/jit-test/tests/errors/nuke-error-wrapper.js
  • js/src/jsapi-tests/testFrontendErrors.cpp
  • js/src/jsapi-tests/testWeakMap.cpp
  • js/src/jsapi.cpp
  • js/src/vm/ErrorObject.cpp
  • js/src/vm/ErrorObject.h
  • js/xpconnect/src/XPCComponents.cpp
  • js/xpconnect/src/XPCConvert.cpp
diff --git a/dom/base/nsContentUtils.cpp b/dom/base/nsContentUtils.cpp
index be53d54487d..35f77abd536 100644
--- a/dom/base/nsContentUtils.cpp
+++ b/dom/base/nsContentUtils.cpp
@@ -12097,11 +12097,12 @@ void nsContentUtils::ExtractErrorValues(
     // Try to process as an Error object.  Use the file/line/column values
     // from the Error as they will be more specific to the root cause of
     // the problem.
-    if (JSErrorReport* err = JS_ErrorFromException(aCx, obj)) {
+    JS::BorrowedErrorReport err(aCx);
+    if (JS_ErrorFromException(aCx, obj, err)) {
       // Use xpc to extract the error message only.  We don't actually send
       // this report anywhere.
       RefPtr<xpc::ErrorReport> report = new xpc::ErrorReport();
-      report->Init(err,
+      report->Init(err.get(),
                    nullptr,  // toString result
                    false,    // chrome
                    0);       // window ID
diff --git a/dom/file/FileReader.cpp b/dom/file/FileReader.cpp
index bbe07d065b8..f2e962ca4ba 100644
--- a/dom/file/FileReader.cpp
+++ b/dom/file/FileReader.cpp
@@ -224,8 +224,8 @@ void FileReader::OnLoadEndArrayBuffer() {
   JS_ClearPendingException(jsapi.cx());
 
   JS::Rooted<JSObject*> exceptionObject(cx, &exceptionValue.toObject());
-  JSErrorReport* er = JS_ErrorFromException(cx, exceptionObject);
-  if (!er || er->message()) {
+  JS::BorrowedErrorReport er(cx);
+  if (!JS_ErrorFromException(cx, exceptionObject, er) || er->message()) {
     FreeDataAndDispatchError(NS_ERROR_OUT_OF_MEMORY);
     return;
   }
diff --git a/dom/ipc/ClonedErrorHolder.cpp b/dom/ipc/ClonedErrorHolder.cpp
index 72c8bfbaf7f..54b37302e73 100644
--- a/dom/ipc/ClonedErrorHolder.cpp
+++ b/dom/ipc/ClonedErrorHolder.cpp
@@ -50,7 +50,8 @@ void ClonedErrorHolder::Init(JSContext* aCx, JS::Handle<JSObject*> aError,
                              ErrorResult& aRv) {
   JS::Rooted<JSObject*> stack(aCx);
 
-  if (JSErrorReport* err = JS_ErrorFromException(aCx, aError)) {
+  JS::BorrowedErrorReport err(aCx);
+  if (JS_ErrorFromException(aCx, aError, err)) {
     mType = Type::JSError;
     if (err->message()) {
       mMessage = err->message().c_str();
@@ -309,7 +310,8 @@ bool ClonedErrorHolder::ToErrorValue(JSContext* aCx,
 
     if (!mSourceLine.IsVoid()) {
       JS::Rooted<JSObject*> errObj(aCx, &aResult.toObject());
-      if (JSErrorReport* err = JS_ErrorFromException(aCx, errObj)) {
+      JS::BorrowedErrorReport err(aCx);
+      if (JS_ErrorFromException(aCx, errObj, err)) {
         NS_ConvertUTF8toUTF16 sourceLine(mSourceLine);
         // Because this string ends up being consumed as an nsDependentString
         // in nsXPCComponents_Utils::ReportError, this needs to be a null
@@ -320,8 +322,8 @@ bool ClonedErrorHolder::ToErrorValue(JSContext* aCx,
           // Corrupt data, leave linebuf unset.
         } else if (JS::UniqueTwoByteChars buffer =
                        ToNullTerminatedJSStringBuffer(aCx, sourceLine)) {
-          err->initOwnedLinebuf(buffer.release(), sourceLine.Length(),
-                                mTokenOffset);
+          err.get()->initOwnedLinebuf(buffer.release(), sourceLine.Length(),
+                                      mTokenOffset);
         } else {
           // Just ignore OOM and continue if the string copy failed.
           JS_ClearPendingException(aCx);
diff --git a/js/loader/ImportMap.cpp b/js/loader/ImportMap.cpp
index a5b6a213875..6a11e1365dd 100644
--- a/js/loader/ImportMap.cpp
+++ b/js/loader/ImportMap.cpp
@@ -408,7 +408,8 @@ UniquePtr<ImportMap> ImportMap::ParseString(
     }
     MOZ_ASSERT(exn.isObject());
     Rooted<JSObject*> obj(aCx, &exn.toObject());
-    JSErrorReport* err = JS_ErrorFromException(aCx, obj);
+    JS::BorrowedErrorReport err(aCx);
+    MOZ_ALWAYS_TRUE(JS_ErrorFromException(aCx, obj, err));
     if (err->exnType == JSEXN_SYNTAXERR) {
       JS_ClearPendingException(aCx);
       JS_ReportErrorNumberASCII(aCx, js::GetErrorMessage, nullptr,
diff --git a/js/public/ErrorReport.h b/js/public/ErrorReport.h
index 18852c24550..6b711f051dd 100644
--- a/js/public/ErrorReport.h
+++ b/js/public/ErrorReport.h
@@ -32,6 +32,7 @@
 #include "js/AllocPolicy.h"
 #include "js/CharacterEncoding.h"  // JS::ConstUTF8CharsZ
 #include "js/ColumnNumber.h"       // JS::ColumnNumberOneOrigin
+#include "js/Exception.h"          // JS::BorrowedErrorReport
 #include "js/RootingAPI.h"         // JS::HandleObject, JS::RootedObject
 #include "js/UniquePtr.h"          // js::UniquePtr
 #include "js/Value.h"              // JS::Value
@@ -414,14 +415,14 @@ struct MOZ_STACK_CLASS JS_PUBLIC_API ErrorReportBuilder {
   JSString* maybeCreateReportFromDOMException(JS::HandleObject obj,
                                               JSContext* cx);
 
-  // We may have a provided JSErrorReport, so need a way to represent that.
+  // If non-nullptr, this is either |&ownedReport| or |borrowedReport.report_|.
   JSErrorReport* reportp;
 
   // Or we may need to synthesize a JSErrorReport one of our own.
   JSErrorReport ownedReport;
 
-  // Root our exception value to keep a possibly borrowed |reportp| alive.
-  JS::RootedObject exnObject;
+  // Used to keep a possibly borrowed |reportp| alive.
+  JS::BorrowedErrorReport borrowedReport;
 
   // And for our filename.
   JS::UniqueChars filename;
diff --git a/js/public/Exception.h b/js/public/Exception.h
index a2e0c146136..4f62cd380aa 100644
--- a/js/public/Exception.h
+++ b/js/public/Exception.h
@@ -27,6 +27,37 @@ enum class ExceptionStackBehavior : bool {
   // retrieved by JS::GetPendingExceptionStack.
   Capture
 };
+
+// Represents a |JSErrorReport*| borrowed from an ErrorObject. The object root
+// ensures the error report won't be freed in the scope of this class.
+//
+// Typical usage:
+//
+//   BorrowedErrorReport report(cx);
+//   if (JS_ErrorFromException(cx, obj, report)) {
+//     // ... Use report->exnType, report.get(), etc.
+//   }
+class MOZ_RAII BorrowedErrorReport {
+  Rooted<JSObject*> owner_;
+  JSErrorReport* report_ = nullptr;
+
+ public:
+  explicit BorrowedErrorReport(JSContext* cx) : owner_(cx) {}
+
+  void init(JSObject* owner, JSErrorReport* report) {
+    MOZ_ASSERT(owner);
+    MOZ_ASSERT(report);
+    owner_ = owner;
+    report_ = report;
+  }
+
+  JSErrorReport* get() const {
+    MOZ_ASSERT(report_);
+    return report_;
+  }
+  const JSErrorReport* operator->() const { return get(); }
+};
+
 }  // namespace JS
 
 extern JS_PUBLIC_API bool JS_IsExceptionPending(JSContext* cx);
@@ -78,13 +109,13 @@ extern JS_PUBLIC_API void JS_ClearPendingException(JSContext* cx);
 
 /**
  * If the given object is an exception object, the exception will have (or be
- * able to lazily create) an error report struct, and this function will return
- * the address of that struct.  Otherwise, it returns nullptr. The lifetime
- * of the error report struct that might be returned is the same as the
- * lifetime of the exception object.
+ * able to lazily create) an error report struct, and this function will
+ * populate |errorReport| with it and return true. Otherwise, returns false.
+ *
+ * See |BorrowedErrorReport| for a usage example.
  */
-extern JS_PUBLIC_API JSErrorReport* JS_ErrorFromException(JSContext* cx,
-                                                          JS::HandleObject obj);
+extern JS_PUBLIC_API bool JS_ErrorFromException(
+    JSContext* cx, JS::HandleObject obj, JS::BorrowedErrorReport& errorReport);
 
 namespace JS {
 
diff --git a/js/src/jit-test/tests/errors/nuke-error-wrapper.js b/js/src/jit-test/tests/errors/nuke-error-wrapper.js
new file mode 100644
index 00000000000..0250a1e748b
--- /dev/null
+++ b/js/src/jit-test/tests/errors/nuke-error-wrapper.js
@@ -0,0 +1,35 @@
+// |jit-test| error:finished
+var g = newGlobal({newCompartment: true});
+g.evaluate(`
+  // Override Error.prototype.name with a getter that nukes CCWs
+  Object.defineProperty(Error.prototype, 'name', {
+    get: function() {
+      // Nuke all cross-compartment wrappers pointing into this realm.
+      // This makes the CCW in the main compartment (that roots our ErrorObject)
+      // become a dead proxy, removing the only reference to our ErrorObject.
+      nukeAllCCWs();
+
+      // Force a full GC to tenure the ErrorObject and compact heap.
+      // ErrorObject survives this GC because it's 'this' (on the C++ stack).
+      gc();
+      
+      // Set maxBytes to current gcBytes so the NEXT allocation triggers GC.
+      // The next GC will collect the ErrorObject, freeing its JSErrorReport.
+      gcparam('maxBytes', gcparam('gcBytes'));
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/js/src/jit-test/tests/errors/nuke-error-wrapper.js b/js/src/jit-test/tests/errors/nuke-error-wrapper.js
new file mode 100644
index 00000000000..0250a1e748b
--- /dev/null
+++ b/js/src/jit-test/tests/errors/nuke-error-wrapper.js
@@ -0,0 +1,35 @@
+// |jit-test| error:finished
+var g = newGlobal({newCompartment: true});
+g.evaluate(`
+  // Override Error.prototype.name with a getter that nukes CCWs
+  Object.defineProperty(Error.prototype, 'name', {
+    get: function() {
+      // Nuke all cross-compartment wrappers pointing into this realm.
+      // This makes the CCW in the main compartment (that roots our ErrorObject)
+      // become a dead proxy, removing the only reference to our ErrorObject.
+      nukeAllCCWs();
+
+      // Force a full GC to tenure the ErrorObject and compact heap.
+      // ErrorObject survives this GC because it's 'this' (on the C++ stack).
+      gc();
+      
+      // Set maxBytes to current gcBytes so the NEXT allocation triggers GC.
+      // The next GC will collect the ErrorObject, freeing its JSErrorReport.
+      gcparam('maxBytes', gcparam('gcBytes'));
+
+      // Return undefined (not a string) to force fallback to reportp->exnType
+      // and then reportp->newMessageString (both UAF after GC collects ErrorObject)
+      return undefined;
+    }
+  });
+
+  // Create the Error object in this compartment
+  this.err = new Error("finished");
+`);
+
+// Get a CCW (cross-compartment wrapper) to the ErrorObject.
+// Then clear the reference in compartment A so the only reference is through our CCW.
+// Then throw the foreign error at top level.
+var foreignError = g.err;
+g.err = null;
+throw foreignError;
Loading diff…