Audit report AR-15
Reported privately in an independent security review, 2026-08-07.
- Report date
- 2026-08-07
- Kind
- Bug
- Proposed issue state
- Open
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:
- the v13 model creates a socket pair without
FD_CLOEXEC, closes one end, fork+execs an unrelated child, and the child findsunrelated_child_inherited_fd=4; - the race model holds a thread between
socketpair()and the twofcntlcalls while the main thread fork+execs, and the child finds both descriptors; - the off-by-one model sets the limit to 64,
dup2s a pipe to fd 63, runs the copied loop, and the exec'd child readssandbox-secretfrom fd 63; - the lowered-limit model opens fd 100, lowers the limit to 64, and the child reads from fd 100.
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.
- Parent endpoint without close-on-exec. Fixed in libmultiprocess by mp#274 (added
SocketPair()withFD_CLOEXEC) and mp#311 (merged 2026-07-30, which moved clearing the flag on the child's socket to afterfork()). Neither is in v13.0, and Bitcoin Core master's libmultiprocess subtree (imported 2026-07-14) still calls plainsocketpair()with no close-on-exec, so Bitcoin Core has the v13 behaviour until its next subtree update. Bug page for the related fixed race: SpawnProcess clears FD_CLOEXEC on the child socket before fork(). - Window between
socketpair()andfcntl. Still present, now for two socket pairs, since mp#312 added a second pair for error reporting. ryanofsky pointed out this remaining window in the mp#311 review, and the mp#311 bug page describes it; there is no separate issue. - Off-by-one and lowered limit. Present:
MaxFd()returnsnofile.rlim_cur - 1and the loop isfd < maxFd. The code dates from 2019. Bitcoin Core only ever raisesRLIMIT_NOFILE, so the lowered-limit case does not occur there. Bug page: SpawnProcess's child close loop stops one short of the highest descriptor.
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.