CVE-2026-74952
Overview
Background
- Application Update / updater
- Firefox’s standalone
updaterbinary that applies MAR updates and, on macOS, runs with elevated privileges to modify the installed app bundle. - `UpdateLog`
- the class in
updatecommon.cppthat opens and writes the updater’s log file at a fixed destination path (mDstFilePath). - `mkstemp`
- a POSIX call that atomically creates and opens a brand-new, unpredictably-named file, refusing to reuse or follow an existing path.
- Symlink following
- the behavior of
fopen(path, "w"), which opens and truncates whatever an existingpath(including an attacker-planted symlink) points to.
Root Cause Analysis
On macOS and Linux the elevated updater created its log file (UpdateLog::Init, UpdateLog::Finish) and its status file (WriteToFile) by calling NS_tfopen(path, "w") / NS_tfopen(path, "wb+") on a predetermined, attacker-guessable path. Because fopen with a write mode follows an existing symlink and truncates its target, an unprivileged attacker could pre-create a symlink at the log or status path so that the privileged updater would create, truncate, or write through it into a location the attacker could not otherwise touch.
The invariant that was violated is that a privileged process must create its output files at a fixed, world-known location without following pre-existing filesystem objects it did not create.
The fix routes all these opens through the new CreateAndOpenFile, which on POSIX uses mkstemp to create a fresh, randomly-named file in the target directory and then renames it into place, so the final file is always one the updater just created rather than an attacker-planted symlink. This closes the window in which the destination path could be substituted with a link to a sensitive file.
fopen("w") on a fixed path, which silently follows an attacker-planted symlink; the fix replaces that with mkstemp + rename so the file is always freshly and exclusively created by the updater itself.Attack Path
- Plant a symlink An unprivileged local attacker creates a symlink at the updater’s known log or status path pointing at a file they cannot normally write.
- Trigger an update
The attacker induces the elevated
updaterprocess to run, so it reachesUpdateLog::Init,UpdateLog::Finish, orWriteToFile. - Follow the link
The elevated
NS_tfopen(path, "w")follows the symlink and truncates or writes to the attacker-chosen target with the updater’s privileges. - Escalate By steering writes to a privileged location, the attacker corrupts or controls a file they should not be able to modify, gaining elevated capability.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
iftoolkit/mozapps/update/common/updatecommon.cpp |
modified | |
iftoolkit/mozapps/update/updater/updater.cpp |
modified |
Files Changed
toolkit/mozapps/update/common/updatecommon.cpptoolkit/mozapps/update/common/updatecommon.htoolkit/mozapps/update/updater/updater.cpp
Audit Directions
- Privileged fixed-path file opensAudit any elevated or setuid process that opens files at predictable paths with
fopen/openin write mode and noO_EXCL/O_NOFOLLOW, since those follow attacker-planted symlinks. - Create-then-use racesLook for code that assumes a fixed output path is freshly created; prefer
mkstemp+rename(orO_EXCL) over truncating an existing path in a security boundary. - Log and status files as attack surfaceTreat updater log, status, and temp files written under shared or world-known directories as security-relevant sinks, not benign debug output.
Patch
diff --git a/toolkit/mozapps/update/common/updatecommon.cpp b/toolkit/mozapps/update/common/updatecommon.cpp
index 05fdf6d60b6..ab967f68c51 100644
--- a/toolkit/mozapps/update/common/updatecommon.cpp
+++ b/toolkit/mozapps/update/common/updatecommon.cpp
@@ -2,6 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+#include "updatedefines.h"
#if defined(XP_WIN)
# include <windows.h>
# include <winioctl.h> // for FSCTL_GET_REPARSE_POINT
@@ -11,6 +12,10 @@
# endif
#endif
+#if defined(XP_MACOSX)
+# include <os/log.h>
+#endif
+
#include <stdio.h>
#include <stdarg.h>
@@ -63,7 +68,10 @@ void UpdateLog::Init(NS_tchar* logFilePath) {
if (dstFilePathLen > 0 && dstFilePathLen < MAXPATHLEN - 1) {
NS_tstrncpy(mDstFilePath, logFilePath, MAXPATHLEN);
#if defined(XP_WIN) || defined(XP_MACOSX)
- logFP = NS_tfopen(mDstFilePath, NS_T("w"));
+ logFP = CreateAndOpenFile(mDstFilePath, false);
+ if (logFP == nullptr) {
+ LogToOS(NS_T("Failed to create FILE*"));
+ }
#else
// On platforms that have an updates directory in the installation directory
// (e.g. platforms other than Windows and Mac) the update log is written to
@@ -87,7 +95,11 @@ void UpdateLog::Finish() {
fflush(logFP);
rewind(logFP);
- FILE* updateLogFP = NS_tfopen(mDstFilePath, NS_T("wb+"));
+ FILE* updateLogFP = CreateAndOpenFile(mDstFilePath, true);
+ if (updateLogFP == nullptr) {
+ return;
+ }
+
while (!feof(logFP)) {
size_t read = fread(buffer, 1, blockSize, logFP);
if (ferror(logFP)) {
@@ -190,6 +202,52 @@ void UpdateLog::WarnPrintf(const char* fmt, ...) {
fflush(logFP);
}
+/**
+ * Creates and opens a file for logging in read/write mode.
+ *
+ * @param filePath The path of the log file.
+ * @param binary If the file should be opened as binary (ignored on
+ * macOS/Linux).
+ * @return a pointer to the FILE struct or nullptr on error.
+ */
+FILE* CreateAndOpenFile(NS_tchar* filePath, bool binary) {
+#ifdef XP_WIN
+ return NS_tfopen(filePath, binary ? NS_T("wb+") : NS_T("w+"));
+#else
+ LogToOS(NS_T("Opening logfile"));
+ NS_tchar* lastSeperator = NS_tstrrchr(filePath, '/');
+ if (lastSeperator == NULL) {
+ // No separator, disable logging.
+ return nullptr;
+ }
+
+ long dirLength = lastSeperator - filePath;
+ if (dirLength < 0 || dirLength >= MAXPATHLEN - 13) {
+ // Too short, or too long. Disable logging.
+ return nullptr;
+ }
+
+ NS_tchar tmpFilePath[MAXPATHLEN] = {L'\0'};
+ NS_tstrncpy(tmpFilePath, filePath, MAXPATHLEN);
+ NS_tstrncpy(tmpFilePath + dirLength, "/temp.XXXXXX", 13);
+
+ int fd = mkstemp(tmpFilePath);
+ if (fd == -1) {
+ LogToOS(NS_T("Failed to open tmp"));
+ return nullptr;
+ }
+
+ if (rename(tmpFilePath, filePath) == -1) {
+ LogToOS(NS_T("Failed to rename"));
+ close(fd);
+ return nullptr;
+ }
+
+ LogToOS(NS_T("Opening file*"));
+ return fdopen(fd, "w+");
+#endif
+}
+
#ifdef XP_WIN
/**
* Determine if a path contains symlinks or junctions to disallowed locations
@@ -474,3 +532,21 @@ bool IsValidFullPath(NS_tchar* origFullPath) {
#endif
return true;
}
+
+#if defined(XP_MACOSX)
+// This is never deallocated by the system
+static os_log_t updaterLogger = os_log_create("org.mozilla.updater", "Updater");
+#endif
+
+/**
+ * Logs a message to the system log for debugging purposes before our log
+ * file has been set up.
+ *
+ * @param message
+ * The message to log.
+ */
+void LogToOS(const NS_tchar* message) {
+#if defined(XP_MACOSX)
+ os_log(updaterLogger, "%{public}s", message);
+#endif
+}
diff --git a/toolkit/mozapps/update/common/updatecommon.h b/toolkit/mozapps/update/common/updatecommon.h
index 9023dc6bda9..be2e552c0d8 100644
--- a/toolkit/mozapps/update/common/updatecommon.h
+++ b/toolkit/mozapps/update/common/updatecommon.h
@@ -35,6 +35,8 @@ class UpdateLog {
bool IsValidFullPath(NS_tchar* fullPath);
bool IsProgramFilesPath(NS_tchar* fullPath);
+FILE* CreateAndOpenFile(NS_tchar* logFilePath, bool binary);
+void LogToOS(const NS_tchar* message);
#define LOG_WARN(args) UpdateLog::GetPrimaryLog().WarnPrintf args
#define LOG(args) UpdateLog::GetPrimaryLog().Printf args
diff --git a/toolkit/mozapps/update/updater/updater.cpp b/toolkit/mozapps/update/updater/updater.cpp
index f11eae197d8..634935a9ec8 100644
--- a/toolkit/mozapps/update/updater/updater.cpp
+++ b/toolkit/mozapps/update/updater/updater.cpp
@@ -2510,7 +2510,7 @@ static bool WriteToFile(const NS_tchar* aFilename, const char* aStatus) {
}
#endif
- AutoFile statusFile(NS_tfopen(statusFilePath, NS_T("wb+")));
+ AutoFile statusFile(CreateAndOpenFile(statusFilePath, true));
if (statusFile == nullptr) {
LOG(("WriteToFile failed to open status file: %d", errno));
return false;
@@ -3303,6 +3303,8 @@ bool ShouldRunSilently(int argc, NS_tchar** argv) {
}
int NS_main(int argc, NS_tchar** argv) {
+ LogToOS(NS_T("Updater started"));
+
// We may need to tweak our argument list when we launch the Second Updater
// Invocation (SUI), so we are going to make a copy of our arguments to
// modify.
@@ -3339,6 +3341,7 @@ int NS_main(int argc, NS_tchar** argv) {
#ifdef XP_MACOSX
if (argc > 2 && NS_tstrcmp(argv[1], NS_T("--openAppBundle")) == 0) {
+ LogToOS(NS_T("Opening App Bundle"));
// We have been asked to open a .app bundle. The path to the .app bundle and
// any command line arguments have been passed to us as arguments after
// "--openAppBundle", so remove the first two arguments and launch the .app
@@ -3398,6 +3401,7 @@ int NS_main(int argc, NS_tchar** argv) {
#ifdef XP_MACOSX
if (isElevated) {
+ LogToOS(NS_T("Updater is elevated"));
if (!ObtainUpdaterArguments(&argc, &argv, &gMARStrings)) {
// Won't actually get here because ObtainUpdaterArguments will terminate
// the current process on failure.