Audit report AR-62

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 100: IPC process aborts if its cleanup thread cannot be started

Summary

When a client releases a libmultiprocess server object without calling destroy, the server's destructor hands the object to a background cleanup thread. That handoff allocates a queue entry and, the first time, creates the thread, all inside a destructor that cannot report failure, so any failure there terminates the process. The report is accurate. In Bitcoin Core the allocation failures change nothing, because the node and GUI already terminate when memory runs out, but a failure to create the thread is a real way to reach it, so it has a bug page: IPC process aborts if its cleanup thread cannot be started. The proposed fix is right in direction and larger than needed for that case.

The report

A ProxyServer object is normally destroyed on the event loop thread when Cap'n Proto releases it. If its client never called destroy(), the destructor passes the application object to EventLoop::addAsyncCleanup() so it is destroyed on the asynchronous cleanup thread. That function wraps the cleanup in a std::function, adds it to a list, and starts the cleanup thread if it is not running yet. Each step can allocate or create a thread, and the destructor cannot throw. A test that injects std::bad_alloc while the first cleanup is queued saw the exception escape the destructor and terminate the process.

The report describes a second path. If building a call's result fails on a worker thread after a new server object was created for it, that object is destroyed on the worker, and starting the cleanup thread there trips an assertion that it only happens on the event-loop thread.

The report rates it low. The trigger is a local allocation or thread creation failure while a server object is released or its connection drops. The effect is a local process abort before the application's cleanup runs. No remote trigger was shown.

Proposed change

Start the cleanup thread when the EventLoop is constructed, so a failure is reported from the constructor before any server object depends on it. Queue a concrete entry (the object plus its cleanup callbacks) instead of a std::function. If adding the entry to the list fails, move it into a single slot that needs no allocation and wait until the cleanup thread has taken it; the thread runs the older queued entries first, then the one from the slot, so the order is preserved.

     KJ_SYSCALL(socketpair(AF_UNIX, SOCK_STREAM, 0, fds));
     m_wait_fd = fds[0];
     m_post_fd = fds[1];
+    m_async_fns.emplace();
+    try {
+        startAsyncThread();
+    } catch (...) {
+        (void)::close(m_wait_fd);
+        (void)::close(m_post_fd);
+        ...
+        throw;
+    }

Two new tests inject the queue failure during a client disconnect and check the ordering of queued and fallback entries. The report considered running the cleanup synchronously after a failure (rejected: server destructors may make IPC calls and deadlock the event loop), leaking the object (rejected: it keeps resources and loop references alive) and retrying the lazy thread start later (rejected: the loop could wait with nothing to wake it).

Assessment

The mechanism is accurate on libmultiprocess master and in Bitcoin Core's copy. The server destructor calls addAsyncCleanup() whenever the application object is still owned, which covers interfaces without a destroy method, clients that drop capabilities, and every object alive at a disconnect. addAsyncCleanup() allocates the queue entry and calls startAsyncThread(), which creates the thread with std::thread the first time. The destructor cannot throw, so any exception terminates the process.

Which failures matter for Bitcoin Core:

The fix reads as correct, though it was not run. Starting the thread in the constructor alone removes the thread-creation trigger and the worker-thread assertion, and is a small change. The fallback slot is hardening for applications that do not terminate on out-of-memory, and it adds a wait on the event-loop thread: the loop waits until the cleanup thread has taken the entry, which happens only after the cleanup it is running returns. If that cleanup made an IPC call, the two threads would wait on each other. That only happens after an allocation failure, so it does not affect Bitcoin Core, but it needs a closer look upstream. The fix also adds one thread per event loop for processes that never need it.

Status

Present on master. Bug page: IPC process aborts if its cleanup thread cannot be started. Suggested next step: a libmultiprocess change that starts the cleanup thread in the EventLoop constructor, with the fallback slot reviewed separately. No public issue or PR yet. Related reports on the same cleanup path: AR-61 and AR-63.

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