Skip to content

Fix use-after-free in the pre-hop part of the socket callbacks - #466

Open
marekl11 wants to merge 2 commits into
getnamo:mainfrom
marekl11:fix/callback-lifetime-pre-hop
Open

Fix use-after-free in the pre-hop part of the socket callbacks#466
marekl11 wants to merge 2 commits into
getnamo:mainfrom
marekl11:fix/callback-lifetime-pre-hop

Conversation

@marekl11

Copy link
Copy Markdown
Contributor

Follow-on to #462. That one gave the game-thread hops inside the internal listeners a weak self to pin, but the listener bodies themselves still ran off a captured this — and those bodies execute on the asio network thread, for as long as sio holds the listener. So everything before the hop (reading VerboseLog, writing SessionId / bIsConnected, testing which callback is bound) was still touching an FSocketIONative that may already be gone. Same bug, one level up.

SetupInternalCallbacks now takes one weak ref and each listener pins it first, which covers the body and the hop together — the hops reuse that ref instead of calling AsShared() again from inside. Both callers (ClearAllCallbacks, RebindCurrentEventMap) run well after construction, so AsShared() at registration time is safe.

Three more with the same shape:

  • Connect's background lambda touches PrivateClient, MaxReconnectionAttempts and ReconnectionDelay on a pool thread. connect() can sit there for the length of a TCP timeout, which is plenty of room for a level transition to release the socket underneath it.
  • EmitRaw's ack callback and OnRawBinaryEvent's listener both read bCallbackOnGameThread on the asio thread before deciding where to dispatch.

And on the component side, the three bindings that SetupCallbacks' weak-this treatment never got extended to: BindEventToGenericEvent broadcasts a member delegate, BindEventToFunction and EmitWithCallBack call member functions.

No behaviour change while the object is alive, which is every normal case.

Note on overlap: #465 touches some of the same lambdas in SocketIONative.cpp, but only the ones whose capture lists were already dead ([&] alongside explicit captures of everything the body used). This branch deliberately leaves those alone and only changes the ones that genuinely reach for a member, so the two shouldn't fight. Happy to rebase whichever lands second.

getnamo#462 gave the game-thread hops inside the internal listeners a weak self to
pin, but the listener bodies themselves still ran off a captured `this`.
Those bodies execute on the asio network thread, and sio holds the listeners
for as long as the client lives — so everything before the hop (reading
VerboseLog, writing SessionId/bIsConnected, testing which callback is bound)
was still reading and writing a FSocketIONative that may already be gone.
Same shape as the bug that PR fixed, one level up.

SetupInternalCallbacks now takes one weak ref and each listener pins it
first, which covers the body and the hop together — the hops reuse the same
weak ref instead of calling AsShared() again from inside. Both callers run
well after construction, so AsShared() at registration time is fine.

Three more with the same problem:

- Connect's background lambda touches PrivateClient, MaxReconnectionAttempts
  and ReconnectionDelay on a pool thread. connect() can sit there for a TCP
  timeout, which is plenty of time for a level transition to release the
  socket underneath it.
- EmitRaw's ack callback and OnRawBinaryEvent's listener both read
  bCallbackOnGameThread on the asio thread before deciding where to dispatch.

And on the component side, the three bindings SetupCallbacks' weak-this
treatment never got extended to: BindEventToGenericEvent broadcasts a member
delegate, BindEventToFunction and EmitWithCallBack call member functions.

No behaviour change when the object is alive, which is every normal case.
@getnamo
getnamo self-requested a review September 1, 2026 19:59
SetupInternalCallbacks() takes a weak reference to the client so each
listener body can check that the object is still alive before touching a
member. That reference comes from AsShared(), which requires the object to
already be owned by a TSharedPtr.

The constructor calls ClearAllCallbacks(), which calls
SetupInternalCallbacks() — at that point MakeShareable() has not yet run, so
AsShared() trips the DoesSharedInstanceExist() assertion and the process
dies as soon as anything creates a client.

Skip the listener install on that pass and repeat ClearAllCallbacks() in
NewValidNativePointer() once the shared pointer exists. Callers after
construction are unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants