Audit report AR-04

Reported privately in an independent security review, 2026-08-07.

Report date
2026-08-07
Kind
Hardening
Status on master
Present
Proposed issue state
Open

Summary

Any IPC peer holding a ThreadMap capability can ask the other process to create operating-system threads without limit, through makeThread or through makePool with a 32-bit count. Bitcoin Core hands a ThreadMap to every client that connects, so a client could exhaust bitcoin-node's threads and stall its event loop while doing so. The gap is real, but only a client abusing the API on purpose would hit it, and such a client already has unauthenticated RPC access. Hardening, worth doing before less trusted clients are supported.

The report

The ThreadMap interface exposes:

makeThread(name :Text) -> (result :Thread)
makePool(count :UInt32) -> ()

ProxyServer<ThreadMap>::makePool loops from zero to the peer-supplied count. On each iteration it starts a persistent std::thread, waits for the thread to publish its thread-local context, and stores it in the connection's pool. makeThread creates one persistent thread per call, alive as long as the returned handle is. The report lists what is missing: no maximum pool size, no per-connection or process-wide quota, no combined budget for pool and dedicated threads, no rollback if creation fails partway, and no asynchronous scheduling, so a large request runs synchronously on the event-loop thread.

It notes that Bitcoin Core's root Init.construct call exchanges ThreadMap capabilities in both directions, so a client receives this authority during ordinary bootstrap, and the node receives the client's. Its impact statement assumes a compromised wallet, GUI, node or custom client: exhaust the other process's thread quota and virtual memory, block its event loop, and terminate or degrade it.

The reproduction is a standalone C++ program that sets RLIMIT_AS to 256 MiB and creates std::threads in a loop, modelling makePool(100000). It creates 31 threads and then fails with Resource temporarily unavailable. It does not use the library.

Proposed change

The report suggests removing raw thread creation from the peer capability or making it a separately negotiated privileged capability; hard per-connection and process-wide quotas; capping makePool to a small configured value and rejecting a second call; rate-limiting makeThread and counting it together with pool threads; rolling back a partially created pool; and considering a fixed local executor instead of one thread per remote context. Its sketch:

 kj::Promise<void> ProxyServer<ThreadMap>::makePool(MakePoolContext context)
 {
     const uint32_t count = context.getParams().getCount();
+    if (count == 0 || count > m_connection.m_limits.max_pool_threads ||
+        m_connection.threadCount() + count > m_connection.m_limits.max_total_threads) {
+        throw std::range_error("ThreadMap pool request exceeds connection quota");
+    }
 ...
 kj::Promise<void> ProxyServer<ThreadMap>::makeThread(MakeThreadContext context)
 {
+    if (m_connection.threadCount() >= m_connection.m_limits.max_total_threads) {
+        throw std::range_error("ThreadMap thread quota exceeded");
+    }

m_limits and threadCount() do not exist, so this is an outline. Suggested tests: makePool(UINT32_MAX), repeated makeThread, and rollback after partial creation.

Assessment

The code is as described on libmultiprocess master: makePool loops for (uint32_t i = 0; i < count; ++i) on the event-loop thread, makeThread has no limit, and threads created before a failure stay in the pool until disconnect. The bootstrap claim is also right: init.capnp has construct @0 (threadMap: Proxy.ThreadMap) -> (threadMap :Proxy.ThreadMap). Ordinary clients need makeThread for any call that carries a Context, so it cannot simply be removed.

Two details differ from the code:

A well-behaved client, or a buggy but honest one, asks for a small pool once. Bitcoin Core documents that -ipcbind gives local processes that can access the socket unauthenticated RPC access, so a client able to do this can already call stop; under the current trust model the finding adds no capability. In the review of mp#300, ryanofsky wrote that "Clients need to be trusted anyway right now" while noting the server could put caps on the number of threads clients create.

Related resource-limit reports: AR-07 (no per-method quotas), AR-09 and AR-19 (connection count), AR-22 (unresolved calls), AR-23 (block templates).

Reproduction (2026-09-26). A test against Bitcoin Core master bfdcd97 (libmultiprocess v13 subtree) sent one raw makePool request with a count of 4000. It succeeded: the process went from 2 to 4002 threads in 176 ms, all created on the event-loop thread, with no limit or error. The test did not push the system to thread exhaustion. It confirms the missing cap and does not change the two differences noted above. The test patch is available on request.

Two later reports cover parts of this: AR-46 (a pool whose creation fails partway leaves its workers behind) and AR-74 (pool requests start excessive worker threads, with a proposed cap of 256 as a first step).

Status

Present on master. Next step: a configurable per-connection thread cap in libmultiprocess, counting pool and dedicated threads together, with rollback of a partially created pool. Worth doing before any less trusted client class (for example a TCP bridge for Stratum v2) is supported. Idea; no issue open.

Reported privately in an independent security review on 2026-08-07.