CVE-2026-118021
Overview
Background
- `nsTimer` / `nsTimerImpl`
nsTimeris the XPCOMnsITimerobject handed to callers;nsTimerImplholds the real timer state (callback, delay, target) and is driven by theTimerThread. They form a pair:nsTimer::mImplis aRefPtr<nsTimerImpl>, and historicallynsTimerImpl::mITimerheld a strongRefPtr<nsITimer>back to thensTimer, 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
mITimerundermMutex, then runs the callback outside the lock. - `mozilla::SupportsThreadSafeWeakPtr` / `ThreadSafeWeakPtr`
- a thread-safe weak-reference facility. A
ThreadSafeWeakPtrcan be atomically upgraded to a strongRefPtr, 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::Releasewas hand-written: at refcount 1 (only themITimerself-reference left) it calledmImpl->CancelImpl(true)to nullmITimerand break the cycle; at 0 itdelete 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.
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
- 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.) - Race the release
Arrange for the sole external reference to the
nsTimerto be dropped at nearly the same moment the timer fires on the timer thread, sonsTimer::Releaseruns the refcount-1 cycle-break concurrently withnsTimerImpl::FirereadingmITimer. - Stale reference captured
FirecopiesmITimerintotimerwhile thensTimeris transitioning into destruction, so the callback stage holds a reference to a freed/dying object. - 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. - Primitive
Use-after-free of an
nsTimerin a privileged (parent-process) thread context, usable for memory corruption. (inferred escalation to code execution)
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
Releasexpcom/threads/nsTimerImpl.cpp |
modified | |
ifxpcom/threads/nsTimerImpl.cpp |
modified | |
nsIObserverxpcom/threads/nsTimerImpl.h |
modified | |
nsTimerxpcom/threads/nsTimerImpl.h |
modified | |
LogModulexpcom/threads/nsTimerImpl.h |
modified |
Files Changed
xpcom/threads/nsTimerImpl.cppxpcom/threads/nsTimerImpl.h
Audit Directions
- Hand-rolled `Release` with refcount thresholdsgrep for custom
NS_IMETHODIMP_(MozExternalRefCountType) ...::Releaseimplementations that branch oncount == 1/count == 0to break cycles; each is a candidate for the same cross-thread race. - Strong reference cycles broken lazilylook for
RefPtr/nsCOMPtrback-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 callbacksreview other
*Impl::Fire/dispatch paths that copy a back-pointer under a mutex and then use it outside the lock on another thread; preferThreadSafeWeakPtrupgrades over raw strong copies. - XPCOM timer consumersaudit callers that stash the
nsITimer*passed to a callback and use it after dropping their own reference, now that lifetime semantics changed.
Patch
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;
};