Audit report AR-50

Reported privately in an independent security review, 2026-09-27.

Report date
2026-09-27
Kind
Bug
Status on master
Present
Proposed issue state
Open
Linked bugs
Bug 98: Process aborts instead of reporting an error when the IPC event loop fails to start

Summary

Bitcoin Core creates its IPC event loop on a background thread, and an exception while creating it escapes that thread and aborts the whole process instead of reaching the caller. The report is accurate and its fix is small and correct. A real trigger needs a system error, such as running out of file descriptors, the first time a process uses IPC. In most cases the process would have stopped anyway, but with a clear error message instead of an abort. Bug page: Process aborts instead of reporting an error when the IPC event loop fails to start.

The report

CapnpProtocol::startLoop() starts a background thread and waits on a promise until the EventLoop has been constructed. The thread's code has no exception handling before it fulfils the promise. A test that throws an exception just before the loop is constructed showed the exception escaping the thread and the process aborting with SIGABRT, instead of connect, listen or makeStream reporting an error.

The report rates it low. The trigger is an allocation, system or other error while the loop is first being created. No untrusted IPC request or remote trigger is involved, and the caller already controls the local IPC operation. The Ipc interface documents connection and listener errors as exceptions, so aborting instead is a robustness problem.

Proposed change

Catch exceptions during loop setup on the background thread and pass them to the caller through the existing promise. The background thread clears any partly built loop state, the caller joins it and rethrows, and the same protocol object can try again later. Exceptions after the loop reports ready are not changed.

-            m_loop.emplace(m_exe_name, std::move(opts), &m_context);
-            m_loop_ref.emplace(*m_loop);
-            promise.set_value();
+            try {
+                ...
+                m_loop.emplace(m_exe_name, std::move(opts), &m_context);
+                m_loop_ref.emplace(*m_loop);
+                promise.set_value();
+            } catch (...) {
+                m_loop_ref.reset();
+                m_loop.reset();
+                promise.set_exception(std::current_exception());
+                return;
+            }

A test hook throws during setup. The unit test runs in a child process so the unfixed abort cannot take down the test runner, and checks that makeStream throws and that a second attempt succeeds. The report considered constructing the event loop on the caller's thread (it would change which thread owns the loop) and saving the exception until the protocol object is destroyed (too late, and the caller would stay blocked).

Assessment

Accurate. In src/ipc/capnp/protocol.cpp the thread's code constructs the loop with no try/catch, and has done so since the IPC code was added in 2021. The EventLoop constructor sets up Cap'n Proto's asynchronous I/O and creates a socket pair, and both throw if a system call fails. Running out of memory is not a practical trigger, because the Bitcoin Core node and GUI already terminate on out-of-memory.

The callers do expect an exception. bitcoin-node wraps its -ipcbind setup in a try/catch that reports "Unable to bind to IPC address" and exits cleanly, and the interfaces::Ipc methods that bitcoin-cli -ipcconnect uses are documented to throw on errors. The loop is created early in startup, after the node has checked that it has enough file descriptors, so running out at that point needs a low limit or a system-wide shortage. A test-only RPC that spawns a process can create the loop later in a running node, where an abort would be worse. Overall the exposure is small, and the bug page records it as a low-confidence bug.

The fix reads as correct, though it was not run here. If code added after the loop's construction ever threw, resetting the loop in the error path would trip an assertion in its destructor, because only a loop that has run closes its internal socket. Nothing there throws today. The test does not need a child process once the fix is in, since the failure then becomes an ordinary exception.

Status

Present on master. Next step: a Bitcoin Core PR with the fix and an in-process test. No public issue or PR yet.

Reported privately in an independent security review on 2026-09-27.