Audit report AR-57

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 101: Failed IPC socket setup is ignored: client crashes, listener silently missing

Summary

The report finds two problems in how libmultiprocess sets up IPC sockets. First, if allocating Cap'n Proto's wrapper object for a socket fails, nothing owns the socket and its file descriptor leaks. Second, the setup runs as a function on the event loop whose exceptions are logged and dropped, so a failed setup goes unnoticed: a client goes on to crash on a missing connection, and a listener reports success without listening. Both are accurate. The leak needs an allocation failure, which the Bitcoin Core node and GUI already treat as fatal, so it is hardening. The dropped error can also be caused by a system call failing, so it is recorded as a low-confidence bug. Bug page: Failed IPC socket setup is ignored: client crashes, listener silently missing.

The report

ConnectStream, ServeStream and ListenConnections hand a socket descriptor to Cap'n Proto's kj library, which wraps it in a stream or listener object that takes ownership and closes it later. kj allocates that object before the part that owns the descriptor is constructed, so if the allocation throws, the descriptor is left open with no owner.

Two of these functions run the setup inside EventLoop::sync(), which runs a function on the event loop thread and waits for it. The event loop logs an exception from that function and returns normally. In Bitcoin Core, the report found that the client then carried on with a null connection and crashed with SIGSEGV, and that ListenConnections returned success while keeping the descriptor. On libmultiprocess master, MakeStream can return an empty stream in the same way.

The reproduction is three Linux unit tests that make a testing hook throw std::bad_alloc just before each wrapper is created and check that the descriptor is closed and the error reaches the caller. The report rates it low: the trigger is a local allocation or setup failure, each failed attempt leaks one descriptor, and the demonstrated client path crashes. No message from a peer, peer-to-peer path, stored state or funds are involved.

Proposed change

Keep a local owner for the descriptor until kj returns its wrapper, and run the setup through a new helper that catches the exception on the event loop thread and rethrows it to the caller:

+inline void SyncWithException(EventLoop& loop, kj::FunctionParam<void()> fn)
+{
+    std::exception_ptr exception;
+    loop.sync([&] {
+        try {
+            fn();
+        } catch (...) {
+            exception = std::current_exception();
+        }
+    });
+    if (exception) std::rethrow_exception(exception);
+}
 ...
+    kj::AutoCloseFd fd_owner{fd};
-    loop.sync([&] {
-        auto stream =
-            loop.m_io_context.lowLevelProvider->wrapSocketFd(fd, kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP);
+    SyncWithException(loop, [&] {
+        auto stream = loop.m_io_context.lowLevelProvider->wrapSocketFd(
+            fd_owner.get(), kj::LowLevelAsyncIoProvider::TAKE_OWNERSHIP);
+        (void)fd_owner.release();

The same guard goes into ServeStream and ListenConnections. The report considered kj's overload that takes an owned descriptor (it releases the descriptor before the same allocation, so it does not help) and fixing kj itself (which would not stop libmultiprocess from dropping the error).

Assessment

Both mechanisms are accurate on libmultiprocess master and in Bitcoin Core's copy.

The descriptor leak needs the wrapper's memory allocation to fail. The other steps before ownership only fail for an invalid descriptor. Bitcoin Core's node and GUI install a handler that terminates the process when memory runs out, so for them nothing changes; the guard is a cheap improvement for other users of the library.

The dropped error is the more serious half. After a failed setup, ConnectStream builds the client object with a null connection pointer and the constructor dereferences it, which crashes the connecting process (bitcoin-cli when it connects over IPC). ListenConnections returns as if it were listening, so bitcoin-node -ipcbind would report success while refusing every client. Besides allocation, the setup can fail when kj registers the new socket with the operating system's event notification (epoll_ctl on Linux, kevent on macOS), for example when the kernel is out of memory or the user has hit the per-user epoll watch limit. That is unlikely in practice but is a real system-call failure, not only an allocation one. The descriptor is not leaked in that case, because kj already owns it, but the error is dropped in the same way.

The same pattern, an exception inside EventLoop::sync() that the caller never sees, causes a client to wait forever when building a request fails (AR-14, AR-58). Rather than a helper at each call site, making sync() itself rethrow on the calling thread would cover all of them. An open change, mp#376, moves the exception to the calling thread but still only logs it there. The report's test hook takes a std::any and is broader than needed, and its tests depend on fork() and Linux fcntl; a test in libmultiprocess's own suite could inject the failure through a wrapper around the I/O provider.

Status

Present on master. Next step: a libmultiprocess change that rethrows exceptions from EventLoop::sync() (or guards these call sites), plus the descriptor guard as hardening. No public issue or PR yet.

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