Bug 87

Spawned processes can inherit one stray file descriptor

Status
Open
Symptom
File descriptor leak; worst effect seen: test failure only
Where seen
Libmultiprocess tests (mptest)
Trigger
Starting a child process (fork and exec)
First reported
Reported privately in an independent security review, 2026-08-07
Fixed
Not yet
In released binaries
Yes
Defect ever on master
Yes

Summary

Before exec'ing a spawned process, SpawnProcess closes inherited file descriptors in a loop that stops one short of the highest descriptor, and it never visits descriptors above a lowered RLIMIT_NOFILE. A descriptor without FD_CLOEXEC in one of those slots leaks into the spawned process. It was reported privately in an independent security review, and a test reproduces both cases. It has not been seen in practice: the top slot is normally free, Bitcoin Core never lowers the limit, and on master SpawnProcess only runs for the test-only echoipc RPC. Released binaries since v30.0 contain the code. No fix yet.

What happened

Root cause

Between fork() and execvp(), SpawnProcess (src/mp/util.cpp) closes every inherited descriptor except the ones it passes to the child:

const int maxFd = MaxFd();
for (int fd = 3; fd < maxFd; ++fd) {
    if (fd != fds[0] && fd != error_fds[0]) close(fd);
}

MaxFd() is documented as "Return highest possible file descriptor" and returns nofile.rlim_cur - 1, an inclusive bound, but the loop treats it as exclusive, so descriptor rlim_cur - 1 is never closed (1023 when getrlimit fails and the fallback is used). The bound also comes from the current soft limit rather than from the descriptors actually open, so a process that opened descriptors and then lowered the limit leaks everything above the new limit.

A leaked descriptor survives exec only if it lacks FD_CLOEXEC, and ryanofsky noted in review of mp#312 that Bitcoin Core does not apply that flag everywhere. The kernel hands out the lowest free number, so the top slot is occupied only near descriptor exhaustion or after an explicit dup2 to a high number. RaiseFileDescriptorLimit in Bitcoin Core only raises the limit. The effect would be an unrelated file or socket held open by the spawned process, possibly delaying EOF on it.

Later hardening of this function did not touch the loop bound: mp#237 ("Made SpawnProcess() behavior safe post fork()"), mp#311, and mp#312 ("report back child errors to parent and throw") reviewed the child code for async-signal safety and CLOEXEC ordering.

Fix

No fix yet. The usual fixes are close_range(3, ~0U, ...) or closefrom(3) where available, or making the loop inclusive.

Reproduction

Two tests in mptest, the libmultiprocess unit test program, check which descriptors a spawned process inherits. The first puts a /dev/null descriptor opened without O_CLOEXEC at rlim_cur - 1 and at rlim_cur - 2, and for each runs sh -c 'test -e /proc/$$/fd/N' through SpawnProcess. The second puts such a descriptor at 100, lowers the soft RLIMIT_NOFILE to 64, spawns the same check, and restores the limit. It was run on 2026-09-26 against Bitcoin Core master bfdcd97, which carries libmultiprocess v13 in its subtree.

Patch: mptest-repro.patch. It applies to Bitcoin Core master; last checked against badc957 on 2026-10-15. From a Bitcoin Core checkout:

curl -sO https://ipcbugs.ofsky.org/repro/mptest-repro.patch
git apply mptest-repro.patch
cmake --build build --target mptest
build/src/ipc/libmultiprocess/test/mptest -f spawn_tests.cpp:132
build/src/ipc/libmultiprocess/test/mptest -f spawn_tests.cpp:152

The descriptor at rlim_cur - 1 was inherited and the one below it was closed; the descriptor above the lowered limit was inherited:

rlim_cur; lim.rlim_cur = 524288; fd; below = 524286; leaked_below = false; fd; top = 524287; leaked_top = true
fd 100 with soft limit 64 inherited; leaked = true

Both results are the same on every run. The test patch is available on request and will be included in the GitHub issue for this bug when it is opened.

Next steps

Timeline

Lessons

What would have prevented this bug, found it sooner, or made it easier to debug. The patterns page groups these lessons across all bugs.

Pattern Process spawning, file descriptors and signals: An off-by-one loop bound

  • Prevented by a different API shape, a written spec or documentation
  • Earliest detector a unit test (did not exist then)
  • Diagnosis easy
  • Fix design easy
  • Debugging aids a reproduction script
  • Rust no

Would Rust have prevented it? No. An off-by-one on a documented inclusive bound is plain logic; Rust's .. versus ..= ranges make the choice visible but do not make it, and the close loop is libc FFI in either language. More on Rust

Could it have been prevented?

Yes, in two ways. The API shape: closefrom(3) or close_range(3, ~0U, ...) closes everything above a descriptor number with no bound to compute, which removes both halves of the bug (the top slot and everything above a lowered RLIMIT_NOFILE). The contract: MaxFd() is documented as "Return highest possible file descriptor", an inclusive value, and the loop for (int fd = 3; fd < maxFd; ++fd) uses it as exclusive; the docstring and the loop disagree on the same line of f324c666150b, and a bound derived from the soft limit rather than from what is open was never going to cover descriptors above a lowered limit. Both were knowable in 2019; closefrom was not portable then, but the inclusive loop was.

Could it have been found sooner?

Yes, by a spawn test that plants a descriptor without FD_CLOEXEC at rlim_cur - 1 and asks the child whether it inherited it, which is what the survey's 2026-09-26 mptest case does (the slot below is closed, the top slot is not; a descriptor at 100 survives a limit lowered to 64). No test of the child's descriptor set existed, and three rounds of hardening on this function (mp#237, mp#311, mp#312) reviewed the child code for async-signal safety and CLOEXEC ordering without checking the loop bound, because each was answering a different report. Nothing in the field could show it: the kernel allocates the lowest free number, so the top slot is empty unless the process is near exhaustion, and Bitcoin Core never lowers the limit.

What made it hard to debug?

Nothing. The finding came from an independent security review modelling descriptor inheritance, and the two-line reproduction reads directly from the loop. The only judgement was whether it matters: on master SpawnProcess runs only for the test-only echoipc RPC, and a leaked descriptor would at worst hold an unrelated file or socket open in the spawned process. That is also why it has no fix yet.

Lesson

When closing "everything else" before exec, do not compute the bound yourself: use closefrom or close_range where available, and otherwise loop to the limit inclusively. A helper documented as returning an inclusive maximum should not be used with <; check the docstring and the loop together whenever either is touched.

Classification

The survey's classification of this bug. The explorer charts these fields across all bugs; the method page explains them.

Field Value
Symptom fd-leak
Worst effect observed test-failure-only
Seen in mptest
Scope production
Trigger Starting a child process (fork-exec)
Phase any
Side spawn
Component spawn
Root cause kind off-by-one
Fix kind none
Detection review
Needs a peer that is not libmultiprocess C++ no
Needed a sanitizer to see no
Defect ever on master yes
Released binary
Confidence low