Moderate firefox UAF 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionSandbox escape due to use-after-free in the XPCOM component
ComponentCore
Bug ClassUAF
Tracker2076695
Fix commitdbb5bdc9f175 (firefox) +29/-51
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

`nsTimer` / `nsTimerImpl`
nsTimer is the XPCOM nsITimer object handed to callers; nsTimerImpl holds the real timer state (callback, delay, target) and is driven by the TimerThread. They form a pair: nsTimer::mImpl is a RefPtr<nsTimerImpl>, and historically nsTimerImpl::mITimer held a strong RefPtr<nsITimer> back to the nsTimer, creating a deliberate reference cycle.
`nsTimerImpl::Fire`
called on the timer’s target thread when the timer expires; it copies the callback and the back-pointer mITimer under mMutex, then runs the callback outside the lock.
`mozilla::SupportsThreadSafeWeakPtr` / `ThreadSafeWeakPtr`
a thread-safe weak-reference facility. A ThreadSafeWeakPtr can be atomically upgraded to a strong RefPtr, which succeeds only if the object has not begun destruction; otherwise the upgrade yields null.
Manual `Release` cycle-break (old code)
because of the strong cycle, nsTimer::Release was hand-written: at refcount 1 (only the mITimer self-reference left) it called mImpl->CancelImpl(true) to null mITimer and break the cycle; at 0 it delete this.

Root Cause Analysis

The invariant that was violated is that nsTimerImpl::Fire, running on the timer thread, must hold a valid, safely-owned reference to the nsTimer it passes into the user callback, across threads, without a window in which the nsTimer can be freed. The old design tried to guarantee this with a strong reference cycle (nsTimerImpl::mITimer is RefPtr<nsITimer>) plus a hand-rolled nsTimer::Release that watched for refcount 1 and called mImpl->CancelImpl(true) to break the cycle. This refcount-threshold logic is inherently racy: nsTimer::Release can run on one thread while Fire runs on the timer thread, and Fire took its reference simply as timer = mITimer (an nsCOMPtr copy) under mMutex. The transition “last external ref dropped → count becomes 1 → break cycle → object dies” is not atomic with respect to Fire reading and copying mITimer, so Fire could end up operating on, or handing the callback, an nsTimer whose destruction had already been triggered — a use-after-free of the nsTimer during callback dispatch.

The patch (in xpcom/threads/nsTimerImpl.cpp / .h) removes the hand-written nsTimer::Release and the refcount-1 cycle-break entirely. nsTimer now derives from mozilla::SupportsThreadSafeWeakPtr<nsTimer> and uses NS_IMPL_ADDREF_INHERITED / NS_IMPL_RELEASE_INHERITED and NS_DECL_ISUPPORTS_INHERITED, so refcounting is the standard thread-safe scheme with no special self-reference. nsTimerImpl::mITimer becomes const mozilla::ThreadSafeWeakPtr<nsTimer> (a weak back-pointer, no cycle). Fire now does timer = RefPtr<nsTimer>(mITimer) — an atomic upgrade of the weak pointer — and returns immediately if the upgrade fails (if (!timer) return;), which is exactly the case where the owner has dropped its last reference and the nsTimer is being destroyed. Destruction is made safe by giving nsTimer::~nsTimer() a body (mImpl->Cancel();) so teardown cancels the timer (removing it from the timer thread and dropping the callback) instead of relying on the refcount-1 heuristic. CancelImpl(bool aClearITimer) is collapsed back into a plain Cancel() because there is no longer an owning mITimer ref to swap out.

Key insight
The timer’s cross-thread back-reference was protected by a non-atomic “refcount == 1” cycle-breaking trick in nsTimer::Release; the window between the last external release and the cycle break let nsTimerImpl::Fire obtain and use an nsTimer that was concurrently being destroyed. Replacing the strong cycle with an atomically-upgradeable ThreadSafeWeakPtr closes that race.

Attack Path

  1. Setup Content triggers creation of a timer with a callback (any web-exposed API that schedules a timer in the parent/chrome, or internal timer usage), producing a paired nsTimer/nsTimerImpl. (web-reachability of the specific timer is inferred; the diff only shows the mechanism.)
  2. Race the release Arrange for the sole external reference to the nsTimer to be dropped at nearly the same moment the timer fires on the timer thread, so nsTimer::Release runs the refcount-1 cycle-break concurrently with nsTimerImpl::Fire reading mITimer.
  3. Stale reference captured Fire copies mITimer into timer while the nsTimer is transitioning into destruction, so the callback stage holds a reference to a freed/dying object.
  4. Use-after-free The expired-timer callback runs against the dangling nsTimer, dereferencing freed memory. With heap grooming to reoccupy the slot, this becomes a controlled UAF.
  5. Primitive Use-after-free of an nsTimer in a privileged (parent-process) thread context, usable for memory corruption. (inferred escalation to code execution)

Impact Assessment

The code lives in XPCOM timer infrastructure, used across the browser including the parent process; the advisory classifies it as a sandbox escape (moderate), consistent with a parent-process UAF reachable from a compromised/content-driven renderer. The attacker needs to win a tight timing race between the last Release and a concurrent Fire on the timer thread, which is a real but non-deterministic precondition. The demonstrated primitive is a use-after-free of the nsTimer object during callback dispatch; escalation to arbitrary code execution and thus full sandbox escape is inferred, not shown by the diff.

Changed Functions

FunctionChangeNotes
Release
xpcom/threads/nsTimerImpl.cpp
modified
if
xpcom/threads/nsTimerImpl.cpp
modified
nsIObserver
xpcom/threads/nsTimerImpl.h
modified
nsTimer
xpcom/threads/nsTimerImpl.h
modified
LogModule
xpcom/threads/nsTimerImpl.h
modified

Files Changed

  • xpcom/threads/nsTimerImpl.cpp
  • xpcom/threads/nsTimerImpl.h

Audit Directions

  • Hand-rolled `Release` with refcount thresholds
    grep for custom NS_IMETHODIMP_(MozExternalRefCountType) ...::Release implementations that branch on count == 1/count == 0 to break cycles; each is a candidate for the same cross-thread race.
  • Strong reference cycles broken lazily
    look for RefPtr/nsCOMPtr back-pointers documented as “breaking the cycle” in a destructor or release path, especially where one side is touched from a second thread.
  • Cross-thread object handoff in callbacks
    review other *Impl::Fire/dispatch paths that copy a back-pointer under a mutex and then use it outside the lock on another thread; prefer ThreadSafeWeakPtr upgrades over raw strong copies.
  • XPCOM timer consumers
    audit callers that stash the nsITimer* passed to a callback and use it after dropping their own reference, now that lifetime semantics changed.
diff --git a/xpcom/threads/nsTimerImpl.cpp b/xpcom/threads/nsTimerImpl.cpp
index 656b881cee4..129e6f7d568 100644
--- a/xpcom/threads/nsTimerImpl.cpp
+++ b/xpcom/threads/nsTimerImpl.cpp
@@ -359,7 +359,8 @@ static void myNS_MeanAndStdDev(double n, double sumOfValues,
 }
 
 NS_IMPL_QUERY_INTERFACE(nsTimer, nsITimer)
-NS_IMPL_ADDREF(nsTimer)
+NS_IMPL_ADDREF_INHERITED(nsTimer, mozilla::SupportsThreadSafeWeakPtr<nsTimer>)
+NS_IMPL_RELEASE_INHERITED(nsTimer, mozilla::SupportsThreadSafeWeakPtr<nsTimer>)
 
 NS_IMPL_ISUPPORTS(nsTimerManager, nsITimerManager)
 
@@ -367,22 +368,7 @@ NS_IMETHODIMP nsTimerManager::GetTimers(nsTArray<RefPtr<nsITimer>>& aRetVal) {
   return gThreadWrapper.GetTimers(aRetVal);
 }
 
-NS_IMETHODIMP_(MozExternalRefCountType)
-nsTimer::Release(void) {
-  nsrefcnt count = --mRefCnt;
-  NS_LOG_RELEASE(this, count, "nsTimer");
-
-  if (count == 1) {
-    // Last ref, in nsTimerImpl::mITimer. Make sure the cycle is broken.
-    mImpl->CancelImpl(true);
-  } else if (count == 0) {
-    delete this;
-  }
-
-  return count;
-}
-
-nsTimerImpl::nsTimerImpl(nsITimer* aTimer, nsIEventTarget* aTarget)
+nsTimerImpl::nsTimerImpl(nsTimer* aTimer, nsIEventTarget* aTarget)
     : mEventTarget(aTarget),
       mIsInTimerThread(false),
       mType(0),
@@ -536,13 +522,7 @@ nsresult nsTimerImpl::InitWithClosureCallback(
 }
 
 nsresult nsTimerImpl::Cancel() {
-  CancelImpl(false);
-  return NS_OK;
-}
-
-void nsTimerImpl::CancelImpl(bool aClearITimer) {
   Callback cbTrash{UnknownCallback{}};
-  RefPtr<nsITimer> timerTrash;
 
   {
     MutexAutoLock lock(mMutex);
@@ -552,17 +532,9 @@ void nsTimerImpl::CancelImpl(bool aClearITimer) {
     // unlocked.
     std::swap(cbTrash, mCallback);
     mTimerSeq = 0;
-
-    // Don't clear this if we're firing; once Fire returns, we'll get this call
-    // again.
-    if (aClearITimer && !mFiring) {
-      MOZ_RELEASE_ASSERT(
-          mITimer,
-          "mITimer was nulled already! "
-          "This indicates that someone has messed up the refcount on nsTimer!");
-      timerTrash.swap(mITimer);
-    }
   }
+
+  return NS_OK;
 }
 
 nsresult nsTimerImpl::SetDelay(uint32_t aDelay) {
@@ -702,10 +674,10 @@ void nsTimerImpl::Fire(uint64_t aTimerSeq) {
   uint32_t oldDelay;
   TimeStamp oldTimeout;
   Callback callbackDuringFire{UnknownCallback{}};
-  nsCOMPtr<nsITimer> timer;
+  RefPtr<nsTimer> timer;
 
   {
-    // Don't fire callbacks or fiddle with refcounts when the mutex is locked.
+    // Don't fire callbacks or release references while the mutex is locked.
     // If some other thread Cancels/Inits after this, they're just too late.
     MutexAutoLock lock(mMutex);
     if (aTimerSeq != mTimerSeq) {
@@ -718,15 +690,19 @@ void nsTimerImpl::Fire(uint64_t aTimerSeq) {
     // mTimers list.
     MOZ_ASSERT(!mIsInTimerThread);
 
+    // Keep the nsITimer alive during Fire, so the callback can use it even if
+    // its owner drops its reference meanwhile. If the owner already dropped
+    // its last reference, the nsTimer is being destroyed and will cancel us.
+    timer = RefPtr<nsTimer>(mITimer);
+    if (!timer) {
+      return;
+    }
+
     ++mFiring;
     callbackDuringFire = mCallback;
     oldType = mType;
     oldDelay = mDelay.ToMilliseconds();
     oldTimeout = mTimeout;
-    // Ensure that the nsITimer does not unhook from the nsTimerImpl during
-    // Fire; this will cause null pointer crashes if the user of the timer drops
-    // its reference, and then uses the nsITimer* passed in the callback.
-    timer = mITimer;
   }
 
   AUTO_PROFILER_LABEL("nsTimerImpl::Fire", OTHER);
@@ -851,7 +827,7 @@ nsresult nsTimerImpl::GetName(nsACString& aName) {
   return NS_OK;
 }
 
-nsTimer::~nsTimer() = default;
+nsTimer::~nsTimer() { mImpl->Cancel(); }
 
 size_t nsTimer::SizeOfIncludingThis(mozilla::MallocSizeOf aMallocSizeOf) {
   return aMallocSizeOf(this);
diff --git a/xpcom/threads/nsTimerImpl.h b/xpcom/threads/nsTimerImpl.h
index 8baf413c5be..698f4a90e08 100644
--- a/xpcom/threads/nsTimerImpl.h
+++ b/xpcom/threads/nsTimerImpl.h
@@ -8,6 +8,7 @@
 #include "mozilla/Logging.h"
 #include "mozilla/Mutex.h"
 #include "mozilla/StaticMutex.h"
+#include "mozilla/ThreadSafeWeakPtr.h"
 #include "mozilla/TimeStamp.h"
 #include "mozilla/Variant.h"
 #include "nsCOMPtr.h"
@@ -26,6 +27,7 @@ extern mozilla::LogModule* GetTimerLog();
    {0x84, 0x27, 0xfb, 0xab, 0x44, 0xf2, 0x9b, 0xc8}}
 
 class nsIObserver;
+class nsTimer;
 
 namespace mozilla {
 class LogModule;
@@ -44,10 +46,9 @@ class nsTimerImpl {
     // and used the timer.
     //
     // Further, note that this should never fail even with a misbehaving user,
-    // because nsTimer::Release checks for a refcount of 1 with an armed timer
-    // (a timer whose only reference is from the timer thread) and when it hits
-    // this will remove the timer from the timer thread and thus destroy the
-    // last reference, preventing this situation from occurring.
+    // because nsTimer's destructor cancels the timer, which removes it from the
+    // timer thread and drops the callback, preventing this situation from
+    // occurring.
     MOZ_ASSERT(
         mCallback.is<UnknownCallback>() || mEventTarget->IsOnCurrentThread(),
         "Must not release mCallback off-target without canceling");
@@ -56,7 +57,7 @@ class nsTimerImpl {
  public:
   typedef mozilla::TimeStamp TimeStamp;
 
-  nsTimerImpl(nsITimer* aTimer, nsIEventTarget* aTarget);
+  nsTimerImpl(nsTimer* aTimer, nsIEventTarget* aTarget);
   NS_INLINE_DECL_THREADSAFE_REFCOUNTING(nsTimerImpl)
   NS_DECL_NON_VIRTUAL_NSITIMER
 
@@ -64,7 +65,6 @@ class nsTimerImpl {
   static void Shutdown();
 
   void SetDelayInternal(uint32_t aDelay, TimeStamp aBase = TimeStamp::Now());
-  void CancelImpl(bool aClearITimer);
 
   void Fire(uint64_t aTimerSeq);
 
@@ -160,7 +160,7 @@ class nsTimerImpl {
   // been removed from the TimerThread's list.
   mozilla::TimeStamp mTimeout MOZ_GUARDED_BY(mMutex);
 
-  RefPtr<nsITimer> mITimer MOZ_GUARDED_BY(mMutex);
+  const mozilla::ThreadSafeWeakPtr<nsTimer> mITimer;
   mozilla::Mutex mMutex;
   nsCString mName MOZ_GUARDED_BY(mMutex);
   Callback mCallback MOZ_GUARDED_BY(mMutex);
@@ -173,17 +173,21 @@ class nsTimerImpl {
   static double sDeltaNum MOZ_GUARDED_BY(sDeltaMutex);
 };
 
-class nsTimer final : public nsITimer {
+class nsTimer final : public mozilla::SupportsThreadSafeWeakPtr<nsTimer>,
+                      public nsITimer {
   explicit nsTimer(nsIEventTarget* aTarget)
       : mImpl(new nsTimerImpl(this, aTarget)) {}
 
   virtual ~nsTimer();
 
+  friend class mozilla::SupportsThreadSafeWeakPtr<nsTimer>;
+
  public:
   friend class TimerThread;
   friend class nsTimerEvent;
 
-  NS_DECL_THREADSAFE_ISUPPORTS
+  MOZ_DECLARE_REFCOUNTED_TYPENAME(nsTimer)
+  NS_DECL_ISUPPORTS_INHERITED
   NS_FORWARD_SAFE_NSITIMER(mImpl);
 
   // NOTE: This constructor is not exposed on `nsITimer` as NS_FORWARD_SAFE_
@@ -204,8 +208,6 @@ class nsTimer final : public nsITimer {
   static nsresult XPCOMConstructor(REFNSIID aIID, void** aResult);
 
  private:
-  // nsTimerImpl holds a strong ref to us. When our refcount goes to 1, we will
-  // null this to break the cycle.
   RefPtr<nsTimerImpl> mImpl;
 };
 
Loading diff…