Firefox · SpiderMonkey
CVE-2026-4723
UAF in SpiderMonkey
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifjs/loader/ImportMap.cpp |
modified | |
MOZ_RAIIjs/public/Exception.h |
modified | |
ifjs/src/vm/ErrorObject.cpp |
modified | |
ifjs/xpconnect/src/XPCComponents.cpp |
modified | |
ifjs/xpconnect/src/XPCConvert.cpp |
modified |
Files Changed
dom/base/nsContentUtils.cppdom/file/FileReader.cppdom/ipc/ClonedErrorHolder.cppjs/loader/ImportMap.cppjs/public/ErrorReport.hjs/public/Exception.hjs/src/jit-test/tests/errors/nuke-error-wrapper.jsjs/src/jsapi-tests/testFrontendErrors.cppjs/src/jsapi-tests/testWeakMap.cppjs/src/jsapi.cppjs/src/vm/ErrorObject.cppjs/src/vm/ErrorObject.hjs/xpconnect/src/XPCComponents.cppjs/xpconnect/src/XPCConvert.cpp
Patch
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/jsapi-tests/testFrontendErrors.cpp b/js/src/jsapi-tests/testFrontendErrors.cpp
index 47acf559d79..9a0e725cb2f 100644
--- a/js/src/jsapi-tests/testFrontendErrors.cpp
+++ b/js/src/jsapi-tests/testFrontendErrors.cpp
@@ -89,8 +89,8 @@ BEGIN_TEST(testFrontendErrors_error) {
CHECK(exception.isObject());
JS::Rooted<JSObject*> exceptionObj(cx, &exception.toObject());
- const JSErrorReport* report = JS_ErrorFromException(cx, exceptionObj);
- CHECK(report);
+ JS::BorrowedErrorReport report(cx);
+ CHECK(JS_ErrorFromException(cx, exceptionObj, report));
CHECK(report->errorNumber == JSMSG_UNEXPECTED_TOKEN_NO_EXPECT);
// Runtime's error report doesn't borrow the filename.
@@ -269,8 +269,8 @@ BEGIN_TEST(testFrontendErrors_overRecursed) {
CHECK(exception.isObject());
JS::Rooted<JSObject*> exceptionObj(cx, &exception.toObject());
- const JSErrorReport* report = JS_ErrorFromException(cx, exceptionObj);
- CHECK(report);
+ JS::BorrowedErrorReport report(cx);
+ CHECK(JS_ErrorFromException(cx, exceptionObj, report));
CHECK(report->errorNumber == JSMSG_OVER_RECURSED);
}
@@ -327,8 +327,8 @@ BEGIN_TEST(testFrontendErrors_allocationOverflow) {
CHECK(exception.isObject());
JS::Rooted<JSObject*> exceptionObj(cx, &exception.toObject());
- const JSErrorReport* report = JS_ErrorFromException(cx, exceptionObj);
- CHECK(report);
+ JS::BorrowedErrorReport report(cx);
+ CHECK(JS_ErrorFromException(cx, exceptionObj, report));
CHECK(report->errorNumber == JSMSG_ALLOC_OVERFLOW);
}
diff --git a/js/src/jsapi-tests/testWeakMap.cpp b/js/src/jsapi-tests/testWeakMap.cpp
index ab945594515..943482e1ad7 100644
--- a/js/src/jsapi-tests/testWeakMap.cpp
+++ b/js/src/jsapi-tests/testWeakMap.cpp
@@ -90,7 +90,8 @@ BEGIN_TEST(testWeakMap_setWeakMapEntry_invalid_key) {
JS::Rooted<JS::Value> exn(cx);
CHECK(JS_GetPendingException(cx, &exn));
JS::Rooted<JSObject*> obj(cx, &exn.toObject());
- JSErrorReport* err = JS_ErrorFromException(cx, obj);
+ JS::BorrowedErrorReport err(cx);
+ CHECK(JS_ErrorFromException(cx, obj, err));
CHECK(err->exnType == JSEXN_TYPEERR);
JS_ClearPendingException(cx);
diff --git a/js/src/jsapi.cpp b/js/src/jsapi.cpp
index fdd31505b77..e75ece4ff41 100644
--- a/js/src/jsapi.cpp
+++ b/js/src/jsapi.cpp
@@ -4121,12 +4121,12 @@ JS::AutoSaveExceptionState::~AutoSaveExceptionState() {
}
}
-JS_PUBLIC_API JSErrorReport* JS_ErrorFromException(JSContext* cx,
- HandleObject obj) {
+JS_PUBLIC_API bool JS_ErrorFromException(JSContext* cx, HandleObject obj,
+ JS::BorrowedErrorReport& errorReport) {
AssertHeapIsIdle();
CHECK_THREAD(cx);
cx->check(obj);
- return ErrorFromException(cx, obj);
+ return ErrorFromException(cx, obj, errorReport);
}
void JSErrorReport::initBorrowedLinebuf(const char16_t* linebufArg,
diff --git a/js/src/vm/ErrorObject.cpp b/js/src/vm/ErrorObject.cpp
index 4bab5897dd6..41a6a26d141 100644
--- a/js/src/vm/ErrorObject.cpp
+++ b/js/src/vm/ErrorObject.cpp
@@ -1264,7 +1264,8 @@ JSString* js::ComputeStackString(JSContext* cx) {
return str.get();
}
-JSErrorReport* js::ErrorFromException(JSContext* cx, HandleObject objArg) {
+bool js::ErrorFromException(JSContext* cx, HandleObject objArg,
+ JS::BorrowedErrorReport& errorReport) {
// It's ok to UncheckedUnwrap here, since all we do is get the
// JSErrorReport, and consumers are careful with the information they get
// from that anyway. Anyone doing things that would expose anything in the
@@ -1273,16 +1274,21 @@ JSErrorReport* js::ErrorFromException(JSContext* cx, HandleObject objArg) {
// will fail if they can't unwrap it.
RootedObject obj(cx, UncheckedUnwrap(objArg));
if (!obj->is<ErrorObject>()) {
- return nullptr;
+ return false;
}
JSErrorReport* report = obj->as<ErrorObject>().getOrCreateErrorReport(cx);
if (!report) {
MOZ_ASSERT(cx->isThrowingOutOfMemory());
cx->recoverFromOutOfMemory();
+ return false;
}
- return report;
+ // Note: it's important to use the unwrapped object here. CCWs can be cut when
+ // nuking wrappers so they're not guaranteed to keep the target object and its
+ // JSErrorReport alive.
+ errorReport.init(obj, report);
+ return true;
}
JS_PUBLIC_API JSObject* JS::ExceptionStackOrNull(HandleObject objArg) {
@@ -1502,7 +1508,7 @@ static JSString* ErrorReportToString(JSContext* cx, HandleObject exn,
}
JS::ErrorReportBuilder::ErrorReportBuilder(JSContext* cx)
- : reportp(nullptr), exnObject(cx) {}
+ : reportp(nullptr), borrowedReport(cx) {}
JS::ErrorReportBuilder::~ErrorReportBuilder() = default;
@@ -1566,14 +1572,20 @@ bool JS::ErrorReportBuilder::init(JSContext* cx,
MOZ_ASSERT(!cx->isExceptionPending());
MOZ_ASSERT(!reportp);
+ Rooted<JSObject*> exnObject(cx);
+
if (exnStack.exception().isObject()) {
// Because ToString below could error and an exception object could become
// unrooted, we must root our exception object, if any.
exnObject = &exnStack.exception().toObject();
- reportp = ErrorFromException(cx, exnObject);
- if (reportp && reportp->isMuted) {
- sniffingBehavior = SniffingBehavior::NoSideEffects;
+ if (ErrorFromException(cx, exnObject, borrowedReport)) {
+ reportp = borrowedReport.get();
+ if (reportp->isMuted) {
+ sniffingBehavior = SniffingBehavior::NoSideEffects;
+ }
+ } else {
+ reportp = nullptr;
}
}
diff --git a/js/src/vm/ErrorObject.h b/js/src/vm/ErrorObject.h
index e6bf7af363b..6d1fe4566df 100644
--- a/js/src/vm/ErrorObject.h
+++ b/js/src/vm/ErrorObject.h
@@ -202,7 +202,8 @@ JSString* ComputeStackString(JSContext* cx);
extern bool ErrorToException(JSContext* cx, JSErrorReport* reportp,
JSErrorCallback callback, void* userRef);
-extern JSErrorReport* ErrorFromException(JSContext* cx, HandleObject obj);
+extern bool ErrorFromException(JSContext* cx, HandleObject obj,
+ JS::BorrowedErrorReport& errorReport);
/*
* Make a copy of errobj parented to cx's compartment's global.
diff --git a/js/xpconnect/src/XPCComponents.cpp b/js/xpconnect/src/XPCComponents.cpp
index c96e67eee53..a4474f8f09a 100644
--- a/js/xpconnect/src/XPCComponents.cpp
+++ b/js/xpconnect/src/XPCComponents.cpp
@@ -1406,8 +1406,8 @@ nsXPCComponents_Utils::ReportError(HandleValue error, HandleValue stack,
scripterr = CreateScriptError(win, exception, nullptr, nullptr);
}
- JSErrorReport* err = errorObj ? JS_ErrorFromException(cx, errorObj) : nullptr;
- if (err) {
+ JS::BorrowedErrorReport err(cx);
+ if (errorObj && JS_ErrorFromException(cx, errorObj, err)) {
// It's a proper JS Error
uint32_t flags = err->isWarning() ? nsIScriptError::warningFlag
: nsIScriptError::errorFlag;
diff --git a/js/xpconnect/src/XPCConvert.cpp b/js/xpconnect/src/XPCConvert.cpp
index 59f97da9416..54faeb6447f 100644
--- a/js/xpconnect/src/XPCConvert.cpp
+++ b/js/xpconnect/src/XPCConvert.cpp
@@ -1191,15 +1191,15 @@ nsresult XPCConvert::JSValToXPCException(JSContext* cx, MutableHandleValue s,
// If it is an engine Error with an error report then let's
// extract the report and build an xpcexception from that
- const JSErrorReport* report;
- if (nullptr != (report = JS_ErrorFromException(cx, obj))) {
+ JS::BorrowedErrorReport report(cx);
+ if (JS_ErrorFromException(cx, obj, report)) {
JS::UniqueChars toStringResult;
RootedString str(cx, ToString(cx, s));
if (str) {
toStringResult = JS_EncodeStringToUTF8(cx, str);
}
return JSErrorToXPCException(cx, toStringResult.get(), ifaceName,
- methodName, report, exceptn);
+ methodName, report.get(), exceptn);
}
// XXX we should do a check against 'js_ErrorClass' here and
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;
+ }
+ });
+
... (file diff truncated)
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…
References
On This Page