Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionPrivilege escalation in the Application Update component
ComponentToolkit
Bug ClassLogic Error
Tracker2021757
Fix commitb9ccc4b5e502 (firefox) +85/-3
CISA KEVNot listed
CreditedTomoya Nakanishi
Disclosed2026-08-18

Background

Application Update / updater
Firefox’s standalone updater binary that applies MAR updates and, on macOS, runs with elevated privileges to modify the installed app bundle.
`UpdateLog`
the class in updatecommon.cpp that 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 existing path (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.

Key insight
The single core mistake was letting a privileged process open its log/status files with 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

  1. 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.
  2. Trigger an update The attacker induces the elevated updater process to run, so it reaches UpdateLog::Init, UpdateLog::Finish, or WriteToFile.
  3. 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.
  4. 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

An attacker with local, unprivileged access gains a symlink-mediated file create/truncate/write primitive in the context of the elevated updater process, principally on macOS where the updater runs with elevated privileges. This yields a moderate privilege escalation within the Application Update component; preconditions are local access to plant the link and the ability to cause the updater to run.

Changed Functions

FunctionChangeNotes
if
toolkit/mozapps/update/common/updatecommon.cpp
modified
if
toolkit/mozapps/update/updater/updater.cpp
modified

Files Changed

  • toolkit/mozapps/update/common/updatecommon.cpp
  • toolkit/mozapps/update/common/updatecommon.h
  • toolkit/mozapps/update/updater/updater.cpp

Audit Directions

  • Privileged fixed-path file opens
    Audit any elevated or setuid process that opens files at predictable paths with fopen/open in write mode and no O_EXCL/O_NOFOLLOW, since those follow attacker-planted symlinks.
  • Create-then-use races
    Look for code that assumes a fixed output path is freshly created; prefer mkstemp + rename (or O_EXCL) over truncating an existing path in a security boundary.
  • Log and status files as attack surface
    Treat updater log, status, and temp files written under shared or world-known directories as security-relevant sinks, not benign debug output.
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.
Loading diff…