Skip to content

fix(nvhttp): use-after-free in clientpairingsecret crashes pair flow#1481

Open
xdzleo wants to merge 1 commit into
ClassicOldSong:masterfrom
xdzleo:fix/clientpairingsecret-uaf
Open

fix(nvhttp): use-after-free in clientpairingsecret crashes pair flow#1481
xdzleo wants to merge 1 commit into
ClassicOldSong:masterfrom
xdzleo:fix/clientpairingsecret-uaf

Conversation

@xdzleo

@xdzleo xdzleo commented May 4, 2026

Copy link
Copy Markdown

Summary

clientpairingsecret() in src/nvhttp.cpp had a use-after-free in its success path that crashes Apollo with a deterministic 0xc0000005 access violation right after a successful pair handshake. The state file gets written with the new named_devices entry, then Sunshine.exe faults, and the client sees the connection drop and reports "pairing failed" even though the host actually paired.

This appears to be the root cause of #1436 and likely contributes to other reports of clients pairing repeatedly without ever succeeding (especially on iOS Moonlight, where I hit it 100% reproducibly with Darwin/25.5.0 over LAN against both v0.4.6 and current master dd99a822).

Root cause

map_id_sess is std::unordered_map<std::string, pair_session_t> — values, not pointers. The success branch did:

auto it = map_id_sess.find(client.uniqueID);
map_id_sess.erase(it);                 // destroys the pair_session_t
add_authorized_client(named_cert_p);   // save_state() + load_state() — heavy alloc, reuses freed slotremove_session(sess);                  // sess.client.uniqueID -> corrupted memory -> 0xc0000005

add_authorized_client() runs save_state() (writes sunshine_state.json) and load_state() (reparses it, rebuilds client_root.named_devices, recreates the cert chain). That heap activity reliably reuses the freed pair_session_t slot, so the remove_session(sess) below dereferences a dangling reference. The crash is at the same offset every run because the allocator reuses the slot the same way.

The hash-mismatch branch never had this problem — it only calls remove_session(sess) once.

Fix

Drop the eager erase. remove_session(sess) at the end already handles both branches, and sess is still valid at that point because nothing in between mutates map_id_sess.

-      auto it = map_id_sess.find(client.uniqueID);
-      map_id_sess.erase(it);
-
       add_authorized_client(named_cert_p);
     } else {
       tree.put("root.paired", 0);
       BOOST_LOG(warning) << "Pair attempt failed due to same_hash: " << same_hash << ", verify: " << verify;
     }
 
+    // (extended comment in the diff explaining the prior UAF)
     remove_session(sess);

Net: +9 / -3 lines including the explanatory comment.

Test plan

  • Reproduced the crash on master dd99a822 with iOS Moonlight (Darwin/25.5.0) on LAN — Application Error 0xc0000005 at fixed offset right after the /pair?phrase=clientpairingsecret request, sunshine_state.json already updated with the new device.
  • Same repro on v0.4.6 release (different binary offset, same call stack).
  • After this patch: pair completes cleanly, no application errors, client transitions to paired state, streaming works.
  • Hash-mismatch path still cleans up the session (verified by injecting a wrong PIN — remove_session(sess) is hit once, no leak).
  • No new warnings under -Wall -Wextra (gcc 16, ucrt64).

Notes for reviewers

While auditing this path I noticed two adjacent issues that I'm happy to send as separate PRs if you're interested:

  1. map_id_sess and client_root are mutated from multiple threads (HTTPS pair handler, HTTP pair handler, web-UI pin() thread) with no lock. The same UAF class is reachable concurrently — two simultaneous pair attempts can race the destructor.
  2. nvhttp::pin() uses std::begin(map_id_sess)->second to pick a session, which doesn't correlate the user-typed PIN with the specific pending device. With two concurrent pair attempts the wrong device can be paired with the legitimate user's PIN.

Both are pre-existing but worth fixing while pair-flow ownership is in mind. Happy to file follow-ups.

The success branch of clientpairingsecret() erased the session from
map_id_sess (an std::unordered_map<std::string, pair_session_t>) before
calling add_authorized_client() and the unconditional remove_session(sess)
below. Because map_id_sess stores values by value, that early erase
destroys the pair_session_t and leaves `sess` and its inner `client`
reference dangling.

add_authorized_client() then runs save_state() and load_state(), which
do non-trivial heap activity (parse and rewrite sunshine_state.json,
rebuild client_root.named_devices, recreate the cert chain). That
activity reliably reuses the freed pair_session_t slot, so
remove_session(sess) -> map_id_sess.erase(sess.client.uniqueID) reads
sess.client.uniqueID out of corrupted memory and crashes the process
with a 0xc0000005 access violation at a deterministic offset (because
the heap allocator reuses the slot the same way every run).

Repro: pair any client that finishes the full pair flow (e.g. iOS
Moonlight on Darwin/25.5.0). The state file gets written with the new
named_device entry, then Sunshine.exe segfaults right after sending
the response, the client sees the connection drop and reports
"pairing failed".

Fix: remove the eager erase. remove_session(sess) at the end of
clientpairingsecret() handles cleanup for both the success and the
hash-mismatch branches and `sess` stays valid until that point.
@ClassicOldSong

Copy link
Copy Markdown
Owner

Thanks but I'm not accepting commits from Claude. You can do a review yourself and commit with your own email address. For example, those comments are pure noises for future maintenance because the problematic code is already gone.

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.

2 participants