Audit report AR-72

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

Report date
2026-09-28
Kind
Bug
Status on master
Present
Proposed issue state
Open
Linked bugs
Bug 87: Spawned processes can inherit one stray file descriptor

Summary

When libmultiprocess spawns a child process, the child closes inherited file descriptors only below the current soft descriptor limit. A descriptor opened before the limit was lowered keeps its number and is passed to the spawned program. This is the same finding as part of AR-15 and was already reproduced. The report adds two useful points: an unlimited soft limit makes the child close nothing at all, and the obvious fix, listing /proc/self/fd after fork(), could deadlock the child. Bug page: SpawnProcess's child close loop stops one short of the highest descriptor.

The report

SpawnProcess() closes descriptors in the child only below MaxFd(), which is derived from the current soft RLIMIT_NOFILE. A descriptor opened while the limit was higher keeps its number after the limit is lowered, so the child skips it and it survives exec. The same bound has a separate off-by-one at the highest permitted descriptor, and it is narrowed from size_t to int for the loop, which can skip every close when the soft limit is RLIM_INFINITY.

The report includes a test and no fix. The test opens /dev/null, moves it above the other open descriptors, lowers the soft limit below it, spawns a shell, and shows that the shell can still read the descriptor. The report says the test passes on macOS and that it fails when the loop bound is replaced with a large one, so the observation is not a test artifact. It was not run on Linux. The report rates it low: it needs local code to open a descriptor, lower its limit and then spawn an IPC process, and no inherited secret or cross-user access was shown.

Proposed change

None committed, on purpose. The reporter's earlier draft listed /proc/self/fd with opendir() and readdir() in the child after fork(). Those calls are not async-signal-safe: if another thread of the parent held an allocator lock at the moment of fork(), the child could block forever, turning a descriptor leak into a hung child. The report asks for a fix that closes every inherited descriptor except the child's socket without allocating or using directory functions after fork(), covers the lowered limit, the off-by-one and the unlimited case together, and uses Linux close_range where available, with fallbacks for older kernels and other systems reviewed in libmultiprocess.

Assessment

Accurate. On libmultiprocess master, MaxFd() returns rlim_cur - 1 and the child loops fd < maxFd, so descriptors at or above the current soft limit are never visited. A test on Linux on 2026-09-26 had already shown a descriptor at 100 inherited after the soft limit was lowered to 64 (reproduction), which covers the report's untested Linux case.

The unlimited case is right as written: rlim_cur - 1 becomes -2 as an int, and a loop from 3 to -2 closes nothing. It is hard to reach. Linux does not allow an unlimited descriptor limit, and Bitcoin Core only ever raises the limit to a finite value. Other systems were not checked.

The warning about the fix is the most useful part of the report. Earlier suggestions for this bug named close_range or closefrom, with a simple loop as the fallback. A loop that stops at the soft limit would still miss descriptors above a lowered limit, so the fallback needs a higher bound, such as the hard limit, and must not use directory functions.

Impact in Bitcoin Core stays low. Bitcoin Core never lowers its descriptor limit, a descriptor survives exec only if it lacks the close-on-exec flag, and on master SpawnProcess is reached only through the test-only echoipc RPC.

Status

Present on master. Next step: one libmultiprocess change that fixes the close loop for all three cases, together with the close-on-exec window from AR-15. No public issue or PR yet.

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