Skip to content

Fix wake-from-sleep SIGSEGV in asio socket impls (#384) - #596

Merged
mrjimenez merged 3 commits into
amule-project:masterfrom
got3nks:fix/asio-socket-lifetime
May 13, 2026
Merged

Fix wake-from-sleep SIGSEGV in asio socket impls (#384)#596
mrjimenez merged 3 commits into
amule-project:masterfrom
got3nks:fix/asio-socket-lifetime

Conversation

@got3nks

@got3nks got3nks commented May 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Background — the bug

CAsioUDPSocketImpl (and the TCP and acceptor impls) install asio completion handlers that capture a raw [this] (or, before commit 402fa9790, a boost::_bi::value<CAsioUDPSocketImpl*>). On wake-from-sleep, the following race plays out:

  1. Before suspend: an async_receive_from is in flight, its completion handler holds a raw pointer to the impl.
  2. System suspends for ≥ 20 min. asio worker threads are frozen.
  3. On wake, the OS-level UDP socket has gone down/up; CMuleUDPSocket (the upper layer) tears down and recreates the lib socket. The old CLibUDPSocket::Destroy() schedules a 1-second-timer that eventually fires HandleDestroy()delete m_libSocket~CAsioUDPSocketImpldelete m_socket.
  4. But during suspend the wall clock jumped forward by minutes. The 1-second timer is already "ready to fire" the moment asio threads wake — alongside any pending I/O completions queued by the reactor.
  5. asio picks an order to run the strand-bound handlers. Nothing guarantees HandleDestroy runs after the pending recv completion. When HandleDestroy wins, it deletes the impl. The next handler then runs HandleRead on freed memory, which calls StartBackgroundRead()m_socket->async_receive_from(...). m_socket is read from freed memory, yields a garbage pointer, and the dereference inside io_object_impl::get_service SIGSEGVs.

Both reports in #384 show this exact signature — the only differences are the value of the garbage this pointer (different runs, different freed-memory contents) and whether the handler was bound via boost::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 against steady_clock is 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:

class CAsioUDPSocketImpl : public std::enable_shared_from_this<CAsioUDPSocketImpl> {
    void StartBackgroundRead() {
        auto self = shared_from_this();
        m_socket->async_receive_from(buffer(...), m_receiveEndpoint,
            bind_executor(m_strand, [self](const error_code& ec, std::size_t n) {
                self->HandleRead(ec, n);
            }));
    }
};

The self capture keeps the impl alive as long as any callback holds a ref. The wrapper (CLibUDPSocket) holds the impl via std::shared_ptr<>; when the wrapper dies, it nulls a std::atomic<CLibUDPSocket*> m_libSocket back-pointer and drops its ref. Callbacks that fire after the wrapper is gone see the null back-pointer and skip their CoreNotify_* branch; the impl itself destructs cleanly once the last callback releases its self ref.

Destroy() becomes a single strand-posted teardown: null the back-pointer, close the asio socket, and (TCP path) fire CoreNotify_LibSocketDestroy to 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, CMuleUDPSocket tears down and recreates its UDP socket faster than the strand can free port 4672, so bind() fails with EADDRINUSE. CreateSocket() leaves m_socket as NULL on the bind failure. CMuleUDPSocket then sees !IsOk() and calls Close(), which posts DispatchClose to the strand, which calls m_socket->close() on a NULL m_socket → SIGSEGV.

The fix is a one-liner: DispatchClose no-ops when m_socket is NULL. The EADDRINUSE itself is recoverable — CMuleUDPSocket retries 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 gets enable_shared_from_this, atomic m_libSocket/m_libSocketServer, OnWrapperGone(), and a simplified Destroy(). m_isDestroying + m_timer + HandleDestroy() are removed. Plus the DispatchClose NULL-guard.
  • src/LibSocket.h — three wrapper classes (CLibSocket, CLibSocketServer, CLibUDPSocket) hold the impl via std::shared_ptr<> instead of raw pointer; LinkSocketImpl() signature changes to take a shared_ptr by value (movable).
  • No changes outside LibSocketAsio.cpp and LibSocket.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

  • Build clean on macOS arm64 (Apple Clang) — amuled + amulegui + unit tests.
  • Build clean on Linux x86_64 — amuled + amulegui + unit tests.
  • 9/9 unit tests pass on both platforms.
  • Linux smoke test: amuled starts, sockets bind (TCP ECServer, UDP server, UDP client, TCP ListenSocket), connects to live ed2k peer, SIGTERM shutdown clean ("aMule shutdown completed.").
  • Wake-from-sleep first-cycle on @danim7's recipe: no SIGSEGV with commit 1's shared_from_this fix. The second-cycle revealed the DispatchClose NULL-deref now fixed in commit 2; @danim7 is currently re-testing the two-commit branch.

Closes #384.

got3nks added 2 commits May 13, 2026 19:44
…-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.
@danim7

danim7 commented May 13, 2026

Copy link
Copy Markdown
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).
@mrjimenez
mrjimenez merged commit afb9da0 into amule-project:master May 13, 2026
12 checks passed
@got3nks
got3nks deleted the fix/asio-socket-lifetime branch May 14, 2026 13:33
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).
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.

aMule crashes with segmentation fault on system clock jump (e.g. when computer awakens from sleep, or on dailight saving change).

3 participants