Skip to content

Fix use-after-free when Firebase instances are destroyed during static destruction - #1949

Draft
AustinBenoit wants to merge 6 commits into
mainfrom
fix/static-destruction-order
Draft

AustinBenoit wants to merge 6 commits into
mainfrom
fix/static-destruction-order

Conversation

@AustinBenoit

@AustinBenoit AustinBenoit commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Description

Provide details of the change, and generalize the change in the PR title above.

Fixes #110.

Eliminates C++ static initialization order fiasco (SIOF) and exit-time static destruction order hazards across firebase_app (App, CleanupNotifier, AppCallback, callback, log, ReferenceCountedFutureImpl, UserSecureManager, app/rest) and all C++ SDK product libraries (Analytics, AppCheck, Auth, Database, Firestore, Functions, Installations, Messaging, RemoteConfig, Storage, and UMP).

Root Cause

If an application holds firebase::App or any Firebase product instance in an object with static storage duration (for example, a namespace-scope static std::unique_ptr<Auth>), the C++ runtime destroys static objects in reverse order of completion of their construction (__cxa_atexit / atexit). Whenever any SDK-internal registry map, mutex, logger, CleanupNotifier owner table, or constant object was constructed after the user's static wrapper, its destructor ran before the user's destructor—causing heap-use-after-free or destroyed-mutex UB during exit() (as reported in #110). Conversely, namespace-scope new Mutex() pointers avoided exit-time destruction at the cost of cross-TU dynamic initialization hazards before main().

Solution

  1. firebase::NoDestructor<T> (app/src/no_destructor.h):
    • Stores T inline in alignas(T) storage via placement new, never invokes ~T(), and is trivially destructible (std::is_trivially_destructible<NoDestructor<T>>::value == true), so no __cxa_atexit / atexit handler is registered and static storage remains reachable for leak checkers.
    • Guards the variadic forwarding constructor via SFINAE so non-const lvalue NoDestructor arguments cannot hijack types with broad converting constructors (such as firebase::Variant), supports brace-enclosed initializers, and uses std::launder on storage access.
  2. firebase_app & app/rest Core Infrastructure:
    • Converts all process-wide registries, mutexes, loggers, callback queues, CleanupNotifier owner maps, cURL/REST globals, and InvalidHandle() sentinels to function-local static NoDestructor<T> accessors—initialized thread-safely on first call (C++11 [stmt.dcl]) and alive through process termination.
    • Preserves lock-free pre-main() AppCallback::AddCallback registration during FIREBASE_APP_REGISTER_CALLBACKS while synchronizing runtime App lifecycle notifications.
  3. All SDK Product Libraries (Analytics, AppCheck, Auth, Database, Firestore, Functions, Installations, Messaging, RemoteConfig, Storage, UMP):
    • Standardizes per-App instance registries, mutexes, JNI class-init locks/initializers, iOS delegate/instance locks, and desktop internal static constants (Variant / QuerySpec / OperationSource / PruneForest) onto NoDestructor<T> accessors across Desktop, Android, and iOS.
    • Installations & RemoteConfig lifecycle fixes: Fixes Installations zombie-pointer retention when App is destroyed before Installations (erasing from InstallationsByApp() in DeleteInternal() and delegating ~Installations() to DeleteInternal() without recursive locking or ABA eviction), null-guards gutted Installations shells, and cleans up failed GetInstance() allocations in Installations and RemoteConfig.
    • Auth teardown lookup: Uses direct O(log N) key lookup (auths.find(auth_data_->app)) in Auth::DeleteInternal().
  4. Compile-Time & Runtime Regression Guards:
    • Enables -Werror=exit-time-destructors for Clang and AppleClang across firebase_app, firebase_rest_lib, and all 11 product CMake targets so accidental introduction of non-trivially destructible statics fails at compile time on macOS/iOS/Android Clang builds.
    • Adds firebase_app_static_destruction_test (exercising real post-main() __cxa_atexit teardown in both normal and reverse App/subscriber destruction orders plus multi-cycle App reinitialization), expands firebase_app_no_destructor_test, and adds InstallationsTest.DestroyAppBeforeInstallationsClearsCachedInstance so GCC (Linux) and MSVC (Windows) CI also enforce exit-time and lifecycle invariants at runtime.

Testing

Describe how you've tested these changes. Link any manually triggered Integration tests or CPP binary SDK Packaging Github Action workflows, if applicable.

  • Zero __cxa_atexit audit (Linux desktop build): Built firebase_app, firebase_rest_lib, firebase_analytics, firebase_app_check, firebase_auth, firebase_database, firebase_functions, firebase_installations, firebase_messaging, firebase_remote_config, firebase_storage, and firebase_ump and verified via nm -u that 0 object files across all 12 static archives reference __cxa_atexit.
  • Clang -Werror=exit-time-destructors verification: Verified modified C++ translation units (including firestore/src/common/) compile cleanly with clang++ -Werror=exit-time-destructors.
  • Unit & regression tests:
    • firebase_app_no_destructor_test (type traits, Variant SFINAE guard, brace init, over-aligned storage, non-destruction)
    • firebase_app_static_destruction_test (real post-main() static destructor teardown & multi-cycle App reinitialization)
    • firebase_installations_test (including out-of-order App-before-Installations deletion and App recreation)
    • firebase_app_cleanup_notifier_test, firebase_app_callback_test, firebase_app_log_test, firebase_app_desktop_test, and firebase_rtdb_desktop_* unit tests
  • Before/after repro under AddressSanitizer (Linux desktop): Verified namespace-scope static std::unique_ptr instances destroyed after main() in both App-first and product-first construction orders exit cleanly (exit 0, zero ASan findings) vs. the heap-use-after-free in __run_exit_handlers on main.

Type of Change

Place an x the applicable box:

  • Bug fix. Add the issue # below if applicable.
  • New feature. A non-breaking change which adds functionality.
  • Other, such as a build process or documentation change.

#110


Notes

  • Bug fixes and feature changes require an update to the Release Notes section of release_build_files/readme.md.
  • Read the contribution guidelines CONTRIBUTING.md.
  • Changes to the public API require an internal API review. If you'd like to help us make Firebase APIs better, please propose your change in a feature request so that we can discuss it together.

Each product keeps a process-wide map of App -> instance plus a mutex, and
both are touched from the instance's destructor via DeleteInternal(). When
a user holds an instance in a static (e.g. a static std::unique_ptr<Auth>)
that is destroyed after the SDK's own globals, the destructor accesses an
already-destroyed map or mutex.

Auth and Remote Config stored the map as a static std::map, which produced a
heap-use-after-free in ~Auth()/~RemoteConfig(). Database, Storage, Functions,
Installations and App Check already heap-allocated the map but still used a
static Mutex, which is destroyed at exit before the lock in DeleteInternal().

Bring every product in line with the layout App and Firestore already use:
the map is lazily allocated and freed when the last instance goes away, and
the mutex is allocated once and never freed.

Fixes #110

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses a potential crash at process exit by ensuring that global mutexes and maps for various Firebase SDK components (Auth, RemoteConfig, Database, Storage, Functions, Installations, and AppCheck) are handled safely. The changes involve dynamically allocating these objects to prevent destruction order issues during C++ runtime shutdown. The reviewer correctly identified that the current implementation of global mutex initialization is susceptible to the Static Initialization Order Fiasco (SIOF) and suggested using a function-local static pointer ('construct on first use' idiom) to mitigate this risk.

Comment thread app_check/src/common/app_check.cc Outdated
Comment thread app_check/src/common/app_check.cc Outdated
Comment thread app_check/src/common/app_check.cc Outdated
Comment thread app_check/src/common/app_check.cc Outdated
Comment thread database/src/common/database.cc Outdated
Comment thread remote_config/src/remote_config.cc Outdated
Comment thread remote_config/src/remote_config.cc Outdated
Comment thread storage/src/common/storage.cc Outdated
Comment thread storage/src/common/storage.cc Outdated
Comment thread storage/src/common/storage.cc Outdated
@AustinBenoit
AustinBenoit marked this pull request as draft October 26, 2026 15:42
Replace the namespace-scope `Mutex* g_x = new Mutex();` globals with
accessor functions that own a function-local static. Dynamic initialization
of a namespace-scope pointer is subject to static initialization order, so a
static initializer in another translation unit that called GetInstance()
would have found a null mutex. A function-local static is initialized on
first use, thread-safely, regardless of translation unit order, and the
pointer it holds is still never freed.

Auth and Remote Config now also keep their map behind the same kind of
accessor instead of lazily allocating and freeing a raw pointer, which
removes the null checks and manual lifetime management from the
GetInstance/Find/DeleteInternal paths. Callers only ever see references.
Introduce firebase::NoDestructor<T>, a small wrapper (modelled on
absl::NoDestructor) that constructs a T in place and never runs its
destructor. Use it for the registry mutexes of every product and the
registry maps of Auth and Remote Config in place of `static T* const t =
new T;`.

The objects are now stored inline in the function-local static rather than
on the heap, there is no `new` or raw pointer anywhere in the registry code,
and leak checkers have nothing to report since nothing is allocated. The
semantics are otherwise unchanged: constructed on first use, thread-safely,
regardless of translation unit order, and never destroyed.

Includes a unit test for NoDestructor.
@AustinBenoit

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a NoDestructor utility class to wrap function-local statics, such as global maps and mutexes, ensuring they are constructed on first use and never destroyed. This change prevents crashes at process exit when Firebase SDK instances (such as Auth, RemoteConfig, Database, Storage, Functions, Installations, or AppCheck) are owned by static objects and destroyed after the SDK's globals. The review feedback highlights several critical issues regarding redundant recursive locking or potential deadlocks in Auth, RemoteConfig, and Installations where locked functions call other functions that attempt to acquire the same mutex. Additionally, minor improvements are suggested for NoDestructor to use decayed array pointers instead of pointer-to-array types for cleaner and more portable casts.

Comment thread app/src/no_destructor.h
Comment thread app/src/no_destructor.h Outdated
Comment thread auth/src/auth.cc
Comment thread remote_config/src/remote_config.cc
Comment thread installations/src/installations.cc Outdated
Comment thread installations/src/installations.cc Outdated
- In NoDestructor, cast the decayed array pointer `storage_` rather than
  `&storage_` in placement new and `get()`.
- In Auth, RemoteConfig, and Installations, factor out `Find*Locked(App*)`
  helpers (and inline the internal cleanup in `~Installations()`) so callers
  that already hold the registry mutex do not recursively re-lock it.
@AustinBenoit

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the NoDestructor template class to wrap function-local statics, preventing process-exit crashes when Firebase SDK instances (such as Auth, RemoteConfig, Database, Storage, Functions, Installations, and AppCheck) are owned by static objects and destroyed after the SDK's globals. The feedback suggests optimizing the Auth map deletion by performing a direct key lookup instead of iterating through the map, and refactoring the Installations cleanup logic into a helper function to eliminate code duplication.

Comment thread auth/src/auth.cc
Comment on lines +177 to 184
AuthMap& auths = Auths();
for (auto it = auths.begin(); it != auths.end(); ++it) {
if (it->second == this) {
LogDebug("Deleting Auth %p for App %p", this, it->first);
g_auths.erase(it);
auths.erase(it);
break;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of iterating through the entire Auths map to find the entry for this instance, you can perform a direct lookup using auth_data_->app as the key. This is more efficient ($O(\log N)$ instead of $O(N)$) and aligns with how other products (like RemoteConfig) remove themselves from their respective registry maps.

    AuthMap& auths = Auths();\n    auto it = auths.find(auth_data_->app);\n    if (it != auths.end() && it->second == this) {\n      LogDebug(\"Deleting Auth %p for App %p\", this, it->first);\n      auths.erase(it);\n    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f0bad88 — replaced the linear scan in Auth::DeleteInternal() with a direct auths.find(auth_data_->app) key lookup (verifying it->second == this before erasing).

Comment on lines +34 to 39
static Mutex& InstallationsMutex() {
static NoDestructor<Mutex> mutex;
return *mutex;
}

using internal::InstallationsInternal;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid duplicating the cleanup logic for installations_internal_ in both the destructor and DeleteInternal(), we can define a static helper function CleanupInternal in this file.

static Mutex& InstallationsMutex() {\n  static NoDestructor<Mutex> mutex;\n  return *mutex;\n}\n\nusing internal::InstallationsInternal;\n\nstatic void CleanupInternal(InstallationsInternal*& installations_internal) {\n  if (installations_internal) {\n    installations_internal->Cleanup();\n    delete installations_internal;\n    installations_internal = nullptr;\n  }\n}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f0bad88 — deduplicated teardown by having Installations::~Installations() delegate directly to DeleteInternal(), which also erases app_ from InstallationsByApp() on both App cleanup and ~Installations().

Comment thread installations/src/installations.cc Outdated
Comment on lines +116 to +120
if (installations_internal_) {
installations_internal_->Cleanup();
delete installations_internal_;
installations_internal_ = nullptr;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the new CleanupInternal helper function to clean up installations_internal_ without duplicating the code.

  CleanupInternal(installations_internal_);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f0bad88 — ~Installations() now delegates directly to DeleteInternal() instead of duplicating the installations_internal_ teardown.

…odules

- Harden NoDestructor<T> with SFINAE forwarding-ctor guard, brace-init overloads, and std::launder access
- Migrate firebase_app, app/rest, and all product libraries (Analytics, AppCheck, Auth, Database, Firestore, Functions, Installations, Messaging, RemoteConfig, Storage, UMP) across Desktop, Android, and iOS onto function-local NoDestructor accessors
- Fix Installations out-of-order App deletion map retention, ABA safety, failed-init cleanup in Installations/RemoteConfig, and O(log N) Auth map removal
- Enable -Werror=exit-time-destructors on Clang/AppleClang across all 12 static library targets
- Add firebase_app_static_destruction_test, expanded NoDestructor unit tests, and Installations out-of-order teardown regression test
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use after free triggered on application exit

1 participant