Skip to content

[Bug] Desktop Database: use-after-free in WebSocketListenResponse callback (dangling View*) #1881

Description

@CrowSoda

[REQUIRED] Please fill in the following fields:

  • Pre-built SDK from the website or open-source from this repo: Pre-built (shipped inside the Firebase Unity SDK)
  • Firebase C++ SDK version: 12.5.0 (FirebaseCppApp-12_5_0.dll); the affected code is unchanged on main today
  • Problematic Firebase Component: Database (desktop implementation)
  • Other Firebase Components in use: Auth
  • Platform you are using the C++ SDK on: Windows
  • Platform you are targeting: desktop (Windows, via Unity editor and standalone)

[REQUIRED] Please describe the issue here:

The desktop Realtime Database implementation crashes with a use-after-free when a listener is removed while a listen response for the same query is still in flight. I hit this repeatedly in a Unity project on Windows (8 crash dumps over one week, all at the identical RVA), and I traced it to a dangling const View* in WebSocketListenResponse.

Root cause

database/src/desktop/core/web_socket_listen_provider.cc stores a raw, non-owning View* on the response object:

class WebSocketListenResponse : public connection::Response {
  ...
  QuerySpec query_spec_;   // stored by value
  const View* view_;       // raw pointer, not lifetime-guarded
};

The completion callback carefully guards the Repo lifetime with Repo::ThisRefLock, but then dereferences the unguarded view_ two lines later:

Repo::ThisRefLock lock(&response->repo_ref());
Repo* repo = lock.GetReference();
if (repo == nullptr) { return; }               // Repo lifetime IS guarded

std::vector<Event> events;
if (!response->HasError()) {
  const QuerySpec& query_spec = response->view()->query_spec();   // view_ is NOT guarded

View objects are owned by the SyncTree. SyncTree::RemoveEventRegistration destroys them (subtree->value().reset(), sync_tree.cc:623) as soon as a query's registration count hits zero. Nothing removes or invalidates the in-flight WebSocketListenResponse when that happens, so the callback reads freed memory.

The race window is widest around connection churn. A dropped connection synthesizes error responses for every outstanding listen, and a reconnect re-sends all listens and creates a fresh in-flight response for each one. Any listener detached in that window (screen teardown, GoOffline() during a network flap) leaves a doomed response behind.

Crash signature

All 8 dumps fault at the same instruction, FirebaseCppApp-12_5_0.dll+0x12D18B (movups xmm0, [rdi], an inlined MSVC small-string copy helper reading the freed View's query_spec_.path):

Crash_2026-07-03  ACCESS_VIOLATION  FirebaseCppApp-12_5_0.dll+0x12d18b  READ 0xffff1000
Crash_2026-07-04  ACCESS_VIOLATION  FirebaseCppApp-12_5_0.dll+0x12d18b  READ 0x0
Crash_2026-07-05  ACCESS_VIOLATION  FirebaseCppApp-12_5_0.dll+0x12d18b  READ 0x0
Crash_2026-07-08  ACCESS_VIOLATION  FirebaseCppApp-12_5_0.dll+0x12d18b  READ 0xffffffffffffffff
Crash_2026-07-09  ACCESS_VIOLATION  FirebaseCppApp-12_5_0.dll+0x12d18b  READ 0x0

One note on symbolication, because it has sent existing reports down the wrong path: the Windows crash handler labels this stack uS::Socket::write / uS::Socket::freeMessage. That's an artifact. The DLL exports almost none of the RTDB desktop code, so the reporter snaps unexported addresses to the nearest named uS:: template export. I disassembled the DLL and matched the faulting function's call sites back to source using the Log* format strings embedded in .rdata; one call site sits inside the WebSocketListenResponse callback (its "Listen at %s failed: %s" literal is on line 91 of web_socket_listen_provider.cc). The bug is in the listener lifecycle code, not in uWebSockets. firebase/firebase-unity-sdk#1391 and firebase/firebase-unity-sdk#540 show the same misleading uS:: stacks, and #1391 reproduces on Unity SDK 13.7.0, so current releases still carry it.

Steps to reproduce:

Repro rate is intermittent by nature (it's a thread race). In my project it fired roughly once a day during normal development. The recipe:

  1. Windows desktop app with one or more active RTDB listeners (ValueChanged etc.).
  2. Induce connection churn: drop DNS/network, or call GoOffline()/GoOnline(), or let a flaky network do it for you.
  3. Detach a listener (remove the last event registration for a query) while a listen response is pending, i.e. shortly after attaching, or during the churn.
  4. The pending response completes and dereferences the freed View.

I have not built a quickstart-cpp repro; the analysis above is from disassembly of the shipped DLL cross-checked against the v12.5.0 source tree, plus the 8 minidumps.

Relevant Code:

The fix looks like a one-liner. The response already stores its QuerySpec by value, the error branch already uses it (response->query_spec()), and QuerySpecForListening preserves the path, which is the only field the success branch reads. So the success branch can use the stored copy too:

// web_socket_listen_provider.cc, in the WebSocketListenResponse callback:
-  const QuerySpec& query_spec = response->view()->query_spec();
+  const QuerySpec& query_spec = response->query_spec();

With that change nothing reads view_ anymore, so the member and its constructor argument can be deleted outright and the dangling pointer is gone. Happy to send a PR if this looks right to you.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions