Audit report AR-61

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

Report date
2026-09-27
Kind
Hardening
Status on master
Present
Proposed issue state
Open

Summary

When a libmultiprocess server runs a call on a worker thread, it keeps a reference to that thread's server object until the call is finished, and releases it at the very end by scheduling a small task on the event loop. Scheduling that task allocates memory. If the allocation failed after the client had disconnected, the server object would be destroyed while its worker thread was still finishing, and the process would stop. The mechanism is real, but only an out-of-memory failure reaches it, and the Bitcoin Core node and GUI already terminate when memory runs out. Robustness hardening for other users of the library, not a bug that can be hit in Bitcoin Core.

The report

ProxyServer<Thread>::post() keeps a reference to its own capability (self) while the worker runs, so the object survives if the client drops its reference mid-call. When the call finishes, the worker hands its result back on the event-loop thread inside a sync() block, and that block schedules a deferred task to release self after the block exits, because the object's destructor joins the worker thread. If scheduling the task fails after the client has dropped the worker capability, the last reference is released immediately, the destructor tries to join a worker that has not returned, and the process aborts.

The reproduction is a unit test that blocks a call inside the server, disconnects the client, makes the deferred-release step throw std::bad_alloc through a test hook, and then lets the call finish. It expects the caller to see a disconnect error and the process to exit cleanly.

The report rates it low. The trigger needs a client disconnect during an active call plus an allocation failure at that last step. The effect is a local process abort; a client that can reach the socket can already stop the node, and no remote trigger was shown.

Proposed change

Register the release task before the call is posted to the worker, holding self, and have the worker signal it through a cross-thread fulfiller once it has left its final sync() block. The final step then allocates nothing:

+    auto done = kj::newPromiseAndCrossThreadFulfiller<void>(); // Signaled when waiter is done executing.
     ...
     auto self = thisCap();
+    m_loop->m_task_set->add(kj::mv(done.promise).catch_([](kj::Exception&&) {}).attach(kj::mv(self)));
     ...
-                m_loop->m_task_set->add(kj::evalLater([self = kj::mv(self)] {}));
             });
+            done_fulfiller->fulfill();
+            done_fulfiller = nullptr;

The report considered fulfilling an ordinary event-loop promise inside the final sync() block (rejected: it could release self before the worker re-acquires the loop lock) and leaking the reference on failure (rejected: it keeps the worker and event-loop state alive indefinitely).

Assessment

The mechanism is accurate. On libmultiprocess master and in Bitcoin Core's copy, the final sync() block moves self into a kj::evalLater callback and adds it to the loop's task set. If either step throws, the callback holding self is destroyed on the event-loop thread while the worker is still waiting for that same block to finish, and the destructor's join cannot succeed. Reading the code, on master this would more likely leave the event loop stuck in the join than abort as it did on the report's branch; either way the process stops serving. This was not run.

Only memory allocation can fail at that step: kj::evalLater and adding a task to the task set allocate, and nothing there makes a system call or calls application code. Bitcoin Core installs an out-of-memory handler at startup that terminates the process instead of throwing, so in bitcoin-node and the GUI the result is the same with or without the fix. Only the injected test exception reaches this path. This follows the call made for AR-47, another allocation-only failure in worker thread setup.

The fix reads as correct: the only step that can fail now happens before any work is posted, where an error reaches the caller normally, and the release cannot run before the worker returns. It is a reasonable change if this function is touched again, best reviewed with the other reports on failure paths in the same function (AR-58, AR-59, AR-60).

Status

Present on master. Hardening; no public issue or PR yet.

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