Fix wake-from-sleep SIGSEGV in asio socket impls (#384) - #596
Merged
mrjimenez merged 3 commits intoMay 13, 2026
Conversation
…-project#384) The wake-from-sleep crash in issue amule-project#384 is a use-after-free: pending asio async_receive_from / async_read_some completions survive a long suspend, fire on wake (with operation_aborted or stale data), and re-enter Handle* methods after the impl has been destroyed by the post-resume socket- recreation path. The crash signature is a garbage 'this' pointer inside boost::asio::detail::io_object_impl::get_service, dereferenced from StartBackgroundRead -> m_socket->async_receive_from. The previous mitigation was a 1-second timer in Destroy() that scheduled HandleDestroy on the strand, intended to let pending completions drain first. That works during normal operation but breaks on wake-from-sleep because the system clock jumps forward during suspend: the timer and the pending I/O completions all become "ready to fire" at the same moment, and asio picks an order in which HandleDestroy can run before the pending completion, deleting the impl out from under it. The fix: replace the timer band-aid with std::enable_shared_from_this. Every async callback now captures [self = shared_from_this()], so the impl stays alive as long as any in-flight completion holds a ref. The wrapper's raw back-pointer is std::atomic<>; Destroy() posts a single strand teardown task that nulls the back-pointer and (for TCP) fires CoreNotify_LibSocketDestroy or (for UDP) deletes the wrapper inline. The impl dies cleanly once all callback selfs drain — no race possible. Applied to all three asio impl classes (CAsioSocketImpl, CAsioSocketServerImpl, CAsioUDPSocketImpl) and their wrappers (CLibSocket, CLibSocketServer, CLibUDPSocket). The UDP path is the load-bearing change for the reported crash; TCP and the acceptor are fixed in the same PR because they share the exact vulnerability pattern (m_isDestroying flag + 1-sec timer + HandleDestroy choreography) and a half-refactored asio lifetime would leave two different models in the same file. Verified: - Full build (amuled + amulegui + unit tests) on macOS arm64 - 9/9 unit tests pass - TODO: wake-from-sleep repro on Linux per danim7's recipe in amule-project#384 Reported by danim7 in amule-project#384#issuecomment-4433290146.
CAsioUDPSocketImpl::CreateSocket() leaves m_socket NULL on bind failure. This happens during the post-resume socket-recreation path when the old socket's close hasn't been processed on the strand before the new bind runs, causing EADDRINUSE. After CreateSocket fails, CMuleUDPSocket sees !IsOk() and calls DestroySocket() → Close() → posts DispatchClose to strand → m_socket->close(ec) on a NULL m_socket → SIGSEGV. This is a pre-existing latent bug masked by the wake-from-sleep UAF that the previous commit fixes — once amuled survives the first wake, the second wake reaches this NULL-deref path. Reported by danim7 on issue amule-project#384 testing fix/asio-socket-lifetime: amule-project#384 (comment) Trace excerpt: #0 io_object_impl::get_service (this=0x0) io_object_impl.hpp:125 amule-project#1 basic_socket::close (this=0x0, ec=...) basic_socket.hpp:547 amule-project#2 CAsioUDPSocketImpl::DispatchClose (this=0x55555ce344a0) at LibSocketAsio.cpp:1262 The EADDRINUSE itself is recoverable — CMuleUDPSocket retries the socket creation. The crash is purely the missing NULL guard in DispatchClose. TCP path doesn't need the same guard: its ctor unconditionally allocates m_socket and no code path sets it back to NULL.
Contributor
|
Thanks @got3nks ! I reported my tests results here: #384 (comment) |
CUDPFirewallTester is a small state machine that fires N UDP probes to other Kad nodes and waits for replies to determine whether our UDP port is open. It tracks the number of probes still in flight in m_fwChecksRunningUDP, and tallies replies in m_fwChecksFinishedUDP. Two assertions trip on a system suspend/resume cycle (amule-project#384), where Kad restarts mid-check while late UDP packets from the pre-suspend probes are still arriving: * SetUDPFWCheckResult line 129 (wxFAIL): a reply lands while m_fwChecksRunningUDP is 0 -- we've forgotten about the original probe. Today the code logs the assert, doesn't decrement, then falls through to mutate m_firewalledUDP / m_fwChecksFinishedUDP based on stale data. Treat the late reply as what it is and drop it -- the new check cycle will produce its own results. * ReCheckFirewallUDP line 176 (wxASSERT): we begin a fresh check while m_fwChecksRunningUDP != 0. The next line forcibly resets the counter to 0 anyway, so this is genuinely cosmetic noise. Downgrade to a debug log so the assertion doesn't pollute Debug builds, and rely on the (now-strengthened) stale-response guard in SetUDPFWCheckResult to ignore any in-flight late replies. Reported by @danim7 in amule-project#384 after testing the LibSocketAsio NULL m_socket fix (commit 2b9492266): the SIGSEGV is gone, but the Kad restart sequence on resume produces these two non-fatal asserts on every cycle. No more SIGSEGV regression observed in the linked test loop after this change; behavioural difference is just the late-reply path becoming a clean drop instead of a state-mutating race. Build: amuled compiles clean on macOS Apple Silicon (wxBase OSX Cocoa 3.3.2).
got3nks
added a commit
to got3nks/amule
that referenced
this pull request
Jul 25, 2026
Address the amuleapi /preferences backend items from the amule-project#596 audit: - connection: drop max_download_cap_kbps / max_upload_cap_kbps. These map to EC_TAG_CONN_DL_CAP / UL_CAP -- the Statistics graph vertical scale, not bandwidth limits -- and don't belong under connection prefs. - connection: rename udp_disabled -> extended_udp_port_enabled (positive sense, true = enabled). The EC tag EC_TAG_CONN_UDP_DISABLE keeps its negative meaning on the wire; the API inverts on read and write. - files: expose endgame (bool). The daemon already reads/writes EC_TAG_FILES_ENDGAME; only the amuleapi (de)serialization was missing. - security: expose can_see_shares as a 3-state integer (0 everybody / 1 friends / 2 nobody) instead of a lossy bool -- the middle state was unreachable through the API. create_normal is intentionally left as-is (functional INI-only pref). The message_filter items need new EC tags and are deferred. Web UI + docs + tests updated in lockstep: preferences.js (can_see_shares select, positive-sense UDP field, endgame), en/es i18n, REFERENCE.md, the RefresherTest cctest, and curl smoke 05.
got3nks
added a commit
to got3nks/amule
that referenced
this pull request
Jul 25, 2026
The Filters panel's "Show received messages in the log", "Filter comments containing", and the comment keyword list were amuled-local: no EC tags, so neither amuleGUI (remote mode) nor amuleapi could read or write them on the daemon. Message filtering (0x1401-0x1406) was already wired; these three were the gap. Add EC_TAG_MSGFILTER_SHOW_IN_LOG (0x1407), _FILTER_COMMENTS (0x1408), and _COMMENT_KEYWORDS (0x1409) under EC_TAG_PREFS_MESSAGEFILTER, serialize + apply them in ECSpecialMuleTags (same presence/value pattern as the existing message-filter tags), and add SetShowMessagesInLog. amuleGUI needs no change: EC_PREFS_MESSAGEFILTER is already in its exchange masks, so the panel round-trips the new fields automatically. Expose them through amuleapi /preferences.message_filter as show_in_log, filter_comments, comment_keywords, with the Web UI (new Comments group), en/es i18n, REFERENCE.md, the RefresherTest cctest, and curl 05/15. Closes amule-project#596 (items 7-8).
mrjimenez
pushed a commit
to mrjimenez/amule
that referenced
this pull request
Jul 25, 2026
Address the amuleapi /preferences backend items from the amule-project#596 audit: - connection: drop max_download_cap_kbps / max_upload_cap_kbps. These map to EC_TAG_CONN_DL_CAP / UL_CAP -- the Statistics graph vertical scale, not bandwidth limits -- and don't belong under connection prefs. - connection: rename udp_disabled -> extended_udp_port_enabled (positive sense, true = enabled). The EC tag EC_TAG_CONN_UDP_DISABLE keeps its negative meaning on the wire; the API inverts on read and write. - files: expose endgame (bool). The daemon already reads/writes EC_TAG_FILES_ENDGAME; only the amuleapi (de)serialization was missing. - security: expose can_see_shares as a 3-state integer (0 everybody / 1 friends / 2 nobody) instead of a lossy bool -- the middle state was unreachable through the API. create_normal is intentionally left as-is (functional INI-only pref). The message_filter items need new EC tags and are deferred. Web UI + docs + tests updated in lockstep: preferences.js (can_see_shares select, positive-sense UDP field, endgame), en/es i18n, REFERENCE.md, the RefresherTest cctest, and curl smoke 05.
mrjimenez
pushed a commit
to mrjimenez/amule
that referenced
this pull request
Jul 25, 2026
… (amule-project#600) The Filters panel's "Show received messages in the log", "Filter comments containing", and the comment keyword list were amuled-local: no EC tags, so neither amuleGUI (remote mode) nor amuleapi could read or write them on the daemon. Message filtering (0x1401-0x1406) was already wired; these three were the gap. Add EC_TAG_MSGFILTER_SHOW_IN_LOG (0x1407), _FILTER_COMMENTS (0x1408), and _COMMENT_KEYWORDS (0x1409) under EC_TAG_PREFS_MESSAGEFILTER, serialize + apply them in ECSpecialMuleTags (same presence/value pattern as the existing message-filter tags), and add SetShowMessagesInLog. amuleGUI needs no change: EC_PREFS_MESSAGEFILTER is already in its exchange masks, so the panel round-trips the new fields automatically. Expose them through amuleapi /preferences.message_filter as show_in_log, filter_comments, comment_keywords, with the Web UI (new Comments group), en/es i18n, REFERENCE.md, the RefresherTest cctest, and curl 05/15. Closes amule-project#596 (items 7-8).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Destroy()withstd::enable_shared_from_thislifetime, eliminating the use-after-free that the timer never reliably prevented.CAsioSocketImpl(TCP),CAsioSocketServerImpl(TCP acceptor), andCAsioUDPSocketImpl(UDP) since they share the exact vulnerability pattern.CAsioUDPSocketImpl::DispatchClosefor the post-resumeEADDRINUSEpath that surfaced once the UAF was fixed.Background — the bug
CAsioUDPSocketImpl(and the TCP and acceptor impls) install asio completion handlers that capture a raw[this](or, before commit402fa9790, aboost::_bi::value<CAsioUDPSocketImpl*>). On wake-from-sleep, the following race plays out:async_receive_fromis in flight, its completion handler holds a raw pointer to the impl.CMuleUDPSocket(the upper layer) tears down and recreates the lib socket. The oldCLibUDPSocket::Destroy()schedules a 1-second-timer that eventually firesHandleDestroy()→delete m_libSocket→~CAsioUDPSocketImpl→delete m_socket.HandleDestroyruns after the pending recv completion. WhenHandleDestroywins, it deletes the impl. The next handler then runsHandleReadon freed memory, which callsStartBackgroundRead()→m_socket->async_receive_from(...).m_socketis read from freed memory, yields a garbage pointer, and the dereference insideio_object_impl::get_serviceSIGSEGVs.Both reports in #384 show this exact signature — the only differences are the value of the garbage
thispointer (different runs, different freed-memory contents) and whether the handler was bound viaboost::bind(pre-402fa9790) or a[this]lambda. The underlying bug is unchanged across that refactor.Root cause
The 1-second-timer guard works during normal operation because the strand processes pending completions before the timer expires. It cannot survive a real system-clock jump, by design —
expires_after(1s)measured againststeady_clockis unaffected by wall-clock jumps, but the queueing order on the strand on wake-up is undefined.More fundamentally, the design tries to express "let pending callbacks drain" by inserting a sentinel delay rather than by holding a reference. Any "delay" approach is racy; only reference-counted lifetime is correct.
Fix
Replace the timer band-aid with
std::enable_shared_from_this:The
selfcapture keeps the impl alive as long as any callback holds a ref. The wrapper (CLibUDPSocket) holds the impl viastd::shared_ptr<>; when the wrapper dies, it nulls astd::atomic<CLibUDPSocket*> m_libSocketback-pointer and drops its ref. Callbacks that fire after the wrapper is gone see the null back-pointer and skip theirCoreNotify_*branch; the impl itself destructs cleanly once the last callback releases itsselfref.Destroy()becomes a single strand-posted teardown: null the back-pointer, close the asio socket, and (TCP path) fireCoreNotify_LibSocketDestroyto delete the wrapper on the GUI thread. No timer, no race.Follow-up: DispatchClose NULL-guard
The second commit fixes a pre-existing latent bug that surfaced once the UAF was fixed: on the second wake-from-sleep,
CMuleUDPSockettears down and recreates its UDP socket faster than the strand can free port 4672, sobind()fails withEADDRINUSE.CreateSocket()leavesm_socketas NULL on the bind failure.CMuleUDPSocketthen sees!IsOk()and callsClose(), which postsDispatchCloseto the strand, which callsm_socket->close()on a NULLm_socket→ SIGSEGV.The fix is a one-liner:
DispatchCloseno-ops whenm_socketis NULL. TheEADDRINUSEitself is recoverable —CMuleUDPSocketretries the socket creation. Reported by @danim7 testing the first commit on the branch.Scope
src/LibSocketAsio.cpp— three impl classes refactored:CAsioSocketImpl,CAsioSocketServerImpl,CAsioUDPSocketImpl. Each getsenable_shared_from_this, atomicm_libSocket/m_libSocketServer,OnWrapperGone(), and a simplifiedDestroy().m_isDestroying+m_timer+HandleDestroy()are removed. Plus theDispatchCloseNULL-guard.src/LibSocket.h— three wrapper classes (CLibSocket,CLibSocketServer,CLibUDPSocket) hold the impl viastd::shared_ptr<>instead of raw pointer;LinkSocketImpl()signature changes to take ashared_ptrby value (movable).LibSocketAsio.cppandLibSocket.h. The 14 callback sites are updated mechanically: every[this]capture becomes[self = shared_from_this()].TCP and the acceptor are fixed in the same PR because they share the exact vulnerability pattern. Leaving them on the old timer-guard model would create two different asio lifetime models in the same file — bad for future maintainers, and a latent crash risk (no reported TCP/acceptor crash today, but the race is the same).
Validation
shared_from_thisfix. The second-cycle revealed theDispatchCloseNULL-deref now fixed in commit 2; @danim7 is currently re-testing the two-commit branch.Closes #384.