Audit report AR-15

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

Report date
2026-08-07
Kind
Bug
Status on master
Partly fixed in mp#274, mp#311
Proposed issue state
Open
Linked bugs
Bug 87: Spawned processes can inherit one stray file descriptor
Bug 69: Spawned process's socket could leak into another child started at the same moment

Summary

SpawnProcess can leak file descriptors into child processes in several ways: a socket without the close-on-exec flag (libmultiprocess v13.0), a short window between creating the socket pair and setting that flag (current libmultiprocess), a close loop in the child that stops one descriptor short of the limit, and descriptors above a lowered limit that the loop never visits. The report is right about the code. The first is fixed in libmultiprocess but not yet in Bitcoin Core's copy; the window and the off-by-one are open and minor. The realistic effect is a connection that does not see end-of-file when it should.

The report

The report describes three situations.

v13.0. The socket pair is created without close-on-exec. The parent keeps one end open, so any later, unrelated exec from the parent process inherits the live IPC socket. An unrelated child holding that socket can keep the connection alive, read or inject protocol bytes, and interfere with shutdown.

Current development version. SocketPair() sets FD_CLOEXEC with fcntl only after socketpair() returns. In a multithreaded process, another thread's fork+exec in that window inherits both descriptors.

The child's close loop. MaxFd() returns rlim_cur - 1, and the child closes for (int fd = 3; fd < maxFd; ++fd), so the highest permitted descriptor is never closed: with a limit of 64, fd 63 survives. If a program opens a descriptor and later lowers RLIMIT_NOFILE (for example as sandbox hardening), any descriptor above the new limit is never visited at all.

The reproduction is four standalone C/C++ programs that copy the relevant system-call sequences; none links the library:

Proposed change

Create socket pairs atomically with SOCK_CLOEXEC where supported; use dup3 or F_DUPFD_CLOEXEC for controlled inheritance; close descriptors in the child with close_range, closefrom or /proc/self/fd enumeration rather than a loop bounded by the current limit; prefer posix_spawn file actions where practical. The sketch:

-    socketpair(AF_UNIX, SOCK_STREAM, 0, sockets);
+    socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sockets);
@@
-    for (int fd = 3; fd < maxFd; ++fd) close(fd);
+    // Duplicate the intended IPC endpoint to a known descriptor, then use
+    // close_range/closefrom or enumerate open descriptors.

Suggested tests: concurrent socket creation with fork+exec, the highest allowed descriptor, a descriptor above a lowered limit, and v13-style parent-endpoint inheritance.

Assessment

Checked against src/mp/util.cpp on libmultiprocess master.

On impact: the race needs a second thread doing fork+exec (in Bitcoin Core, the -blocknotify, -alertnotify or -startupnotify hooks) within microseconds of socket creation. The leaked duplicate keeps the connection from reaching end-of-file when the intended peer exits. The off-by-one leaks at most one unrelated descriptor into the spawned child, and only if something occupies the top slot. The "read or inject protocol bytes" scenario needs an unrelated child that is already hostile. On Bitcoin Core master SpawnProcess is reached only through the test-only echoipc RPC; spawning matters on the unmerged multiprocess branch, where the GUI spawns the node and the node spawns wallets.

Reproduction (2026-09-26). Tests against Bitcoin Core master bfdcd97 (libmultiprocess v13 subtree) reproduced three of the four points. A non-close-on-exec descriptor at rlim_cur - 1 was inherited by a spawned process while the one below it was closed, and a descriptor at 100 was inherited after the soft limit was lowered to 64 (reproduction). A background shell command started between socketpair() and fork(), standing in for a -blocknotify hook on another thread, inherited the socket with no timing luck needed. That delayed end-of-file by 3 seconds, and a child started later inherited the parent's end as well (reproduction). That is the v13 behaviour Bitcoin Core master ships until its next subtree update. The narrow socketpair() to fcntl window on libmultiprocess master was not tested.

Status

Partly fixed. The close-on-exec fix is in libmultiprocess and reaches Bitcoin Core with the next subtree update. The creation window and the close-loop off-by-one are open: SOCK_CLOEXEC where available closes the window, and close_range(3, ~0U, 0) or closefrom(3), with an inclusive loop as fallback, fixes the close loop. Both are small libmultiprocess changes; no PR yet.

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