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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions