Computing History · Unix Lineage
The Close-on-Exec Race and Why Descriptor Inheritance Is a Hazard
How exec preserves every descriptor a process holds, why setting the flag after fork is too late, and what O_CLOEXEC closes that fcntl cannot.
exec keeps the descriptor table. That is the rule that makes pipelines and redirection work: the shell opens a file, arranges for it to occupy descriptor 1, and the program that replaces the shell inherits descriptor 1 already pointing at the file. The same rule means that every descriptor a process holds at the moment of exec is handed to the new program image, including ones it has no business seeing. The flag that suppresses this inheritance is the obvious fix, and setting it correctly is harder than it looks.
What inheritance actually transfers
At exec, the kernel discards the address space, the mappings, the signal handlers, and the memory, and builds a new image. The descriptor table is not rebuilt. Each entry that is not marked close-on-exec survives, still pointing at the same open file description, with the same offset and the same status flags.
Several attributes are preserved that are easy to overlook. The offset is preserved because the description is preserved, which is what allows a program to read a file the shell positioned. The access mode is preserved: a descriptor opened read-only stays read-only. O_APPEND and O_NONBLOCK are properties of the description and survive. The close-on-exec flag itself is a property of the descriptor, and it is what determines survival.
Other attributes are cleared or changed. Signal handlers are reset to their default dispositions, because the handler address would point into an address space that no longer exists; ignored signals stay ignored, which is the asymmetry that lets a parent suppress SIGPIPE for a child by ignoring it. The signal mask is preserved, so a process that blocks a signal and then execs hands the block to the new program. Pending signals are preserved. The set of memory mappings is replaced entirely, and any mmap region the parent created is gone unless it was backed by a file and the new image maps it again.
The security consequence is direct: a program inherits the ability to use any descriptor that survived, with whatever authority the descriptor carries. A listening socket inherited by an unrelated program lets that program accept connections on the port. A descriptor to a file opened while the process was privileged lets the unprivileged program read that file. The privilege is not in the credentials, which exec may change; it is in the description, which exec does not touch.
The window between open and fcntl
The historical mechanism for marking a descriptor is fcntl(fd, F_SETFD, FD_CLOEXEC), a separate call after open. That separation is the race.
Consider a multithreaded server. Thread A calls open and receives descriptor 7. Before it can call fcntl to set the flag, thread B calls fork and exec in the child. The child’s descriptor table contains descriptor 7 without the flag, because the flag had not been set yet, and the new program inherits it. In a single-threaded program the window is short but real, and it widens if the open is followed by any other work. In a library, the window is not under the caller’s control at all: the library opens a file, and the application forks at any moment.
The window cannot be closed by reordering, because the descriptor number is not known until open returns. It cannot be closed by a lock unless every fork site takes the same lock, which is not true of code the program does not own. The correct fix is to make the flag part of the creation call, so that the descriptor is never visible without it.
O_CLOEXEC does that. It was added to Linux and then to POSIX, and open, openat, socket, socketpair, pipe2, dup3, accept4, epoll_create1, eventfd, signalfd, timerfd_create and memfd_create accept it. With the flag in the creation call, the descriptor appears in the table already marked, and no other thread can observe it unmarked. The flag is not inherited by the new descriptor in dup or dup2, which is why dup3 exists with an explicit flags argument.
#include <fcntl.h>
#include <unistd.h>
int safe_open(const char *path) {
/* the flag is set atomically with creation: no window for another thread */
int fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) return -1;
return fd;
}
pipe2 deserves separate mention because a pipe has two ends and both must be marked. With pipe followed by two fcntl calls, there are two windows and a moment when one end is marked and the other is not; pipe2(fds, O_CLOEXEC) marks both atomically.
Where the flag is the wrong tool
Close-on-exec is not a security boundary by itself, because a program can clear it. fcntl(fd, F_SETFD, 0) removes the flag, and a program that has inherited a descriptor can do exactly that before execing a further program. If the threat model includes the intermediate program being hostile, the flag is insufficient; the descriptor must not be inherited at all.
The mechanisms that remove the possibility are different in kind. O_PATH descriptors combined with fexecve allow executing a file without a readable descriptor. Descriptor passing over a socket with SCM_RIGHTS gives an explicit handoff, so the receiver gets only what was sent. Privilege separation uses a helper process that holds the sensitive descriptors and performs operations on request, so the main program never holds them. In each case the design removes the descriptor from the process that should not have it, rather than relying on a flag the process can change.
The other case where the flag does not help is a descriptor that must be inherited. A shell redirection deliberately creates an inheritable descriptor, and the flag must be off for it. The posix_spawn file actions handle this by specifying which descriptors to duplicate and which to close, and by default closing everything else, which inverts the default from “inherit everything” to “inherit only what is named”. That inversion is the real fix for the general problem, and it is why posix_spawn is safer than a bare fork/exec even though both are correct when used carefully.
Auditing what a program actually holds
There is no portable way to enumerate a process’s descriptors. Linux exposes /proc/self/fd as a directory whose entries are the open descriptors, and reading it while holding a descriptor to it includes that descriptor in the listing, which is why the count is one higher than expected. The entries are symbolic links whose targets name the file, the socket, the pipe, or the device, and the target is often enough to identify a leak.
The practical discipline is to close everything above descriptor 2 before exec, except what is explicitly intended. A loop from 3 to the descriptor limit calling close works but is slow at high limits and can race with another thread opening a descriptor into the range being closed. close_range was added for this: it closes a contiguous range in one call, optionally with CLOSE_RANGE_CLOEXEC to mark rather than close, which avoids the race entirely because marking is atomic with respect to the range.
The leak that costs the most is the one that prevents a resource from being released. A daemon that forks and execs a child which inherits the listening socket keeps the port bound even after the daemon exits, because the child holds a description referring to it. The symptom is a restart that fails with EADDRINUSE while no process appears to be listening. The cause is a descriptor in a process that does not know it has one.
Inheritance and the pipeline
The pipeline is the case where inheritance is the feature rather than the hazard, and it shows why the default cannot simply be inverted for fork/exec.
A shell builds a | b by creating a pipe, forking twice, and in each child duplicating the appropriate end onto descriptor 0 or 1 and closing the originals. The duplication with dup2 produces a descriptor without the close-on-exec flag, because dup2 does not copy it; that is deliberate, since the whole point is for the descriptor to survive the exec. The original pipe descriptors are marked close-on-exec at creation with pipe2, so they vanish at exec and the child does not hold the unused end, which is what allows the reader to see EOF when the writer exits.
This is the pattern that makes the model work: mark at creation, duplicate explicitly for the descriptors that must survive, and let everything else disappear. The race described above exists only when the marking is deferred, and the fix is to never defer it. The related problem of a child holding an end it should not, which prevents EOF, is the same problem seen from the reading side and is discussed in the pipe article.
The remaining limitation is that the flag is per descriptor, not per description, so duplicating a descriptor with dup produces one without the flag even if the original had it. A library that duplicates a descriptor to return it to a caller must set the flag on the duplicate itself, and there is no dup variant that copies it, which is a gap in the interface that F_DUPFD_CLOEXEC was added to fill.
References
- The Open Group Base Specifications Issue 7, System Interfaces: fcntl — POSIX.1-2017, IEEE Std 1003.1
- Linux man-pages: open(2) — Michael Kerrisk, man-pages project
- Linux man-pages: close(2) — Michael Kerrisk, man-pages project
- Advanced Programming in the UNIX Environment — W. Richard Stevens and Stephen A. Rago; Addison-Wesley