A service opens a privileged socket, starts helper programs, and expects those helpers to receive only standard input, output, and error. One descriptor created without close-on-exec can quietly violate that boundary. If it remains present when a new program image is installed, the helper inherits access to the kernel object even when its own credentials could never have opened that object.

Linux treats this as descriptor inheritance, not a new authorization event. The security decision made when the object was opened is embodied in the descriptor. FD_CLOEXEC controls whether that established authority crosses a successful execve().

Exec replaces code but can preserve descriptor authority

A successful execve() replaces the calling process image, but open file descriptors normally remain open. Descriptors marked close-on-exec are the exception: the kernel closes them as part of the successful exec transition.

This means executable replacement is not automatically an authority reset. A newly loaded program can begin execution with sockets, directories, pipes, device handles, or regular files acquired by earlier code. The inherited descriptor remains usable according to the access mode and kernel-object semantics already attached to it.

The new program does not need the pathname permissions that would be required to open the same file again. For a connected socket, there may not even be a meaningful pathname operation to repeat. Descriptor inheritance therefore crosses an identity boundary using authority that was established before the new executable started.

Close-on-exec does not revoke the descriptor from the current process. It only defines behavior at successful exec. Before that transition, the process can still use, duplicate, transfer, or close the descriptor subject to the relevant kernel rules.

FD_CLOEXEC belongs to a descriptor table entry

FD_CLOEXEC is a file descriptor flag. It is distinct from file status flags associated with an open file description. That separation matters when descriptors are duplicated.

dup() and dup2() create a new descriptor whose close-on-exec flag is clear. The new descriptor still refers to the same open file description as the source, but inheritance policy is a property of the new descriptor entry. dup3() can take O_CLOEXEC, and fcntl() with F_DUPFD_CLOEXEC can create a duplicate with close-on-exec already set.

The distinction also prevents a common conceptual error: setting FD_CLOEXEC on one descriptor does not impose a global property on every alias of the same open file description. Another descriptor referring to that object can remain inheritable.

A process that treats close-on-exec as containment must therefore account for descriptor creation and duplication paths, not only the original open().

Setting the flag later leaves a race

A sequence that opens a descriptor and then calls fcntl(fd, F_SETFD, FD_CLOEXEC) has two separate state transitions. In a single-threaded process with tightly controlled execution, that gap may be manageable. In a multithreaded process, another thread can start an exec-related transition while the new descriptor is still inheritable.

The classic hazardous case combines descriptor creation in one thread with fork() followed by execve() in another. If the fork captures the descriptor table before FD_CLOEXEC is set, the child receives the inheritable descriptor. A later flag update in the parent cannot retroactively alter the child’s descriptor table.

Linux therefore provides atomic close-on-exec options on many descriptor-producing interfaces. open() accepts O_CLOEXEC; socket() accepts SOCK_CLOEXEC; accept4() accepts SOCK_CLOEXEC; pipe2() accepts O_CLOEXEC. Other interfaces expose mechanism-specific forms such as EPOLL_CLOEXEC and MFD_CLOEXEC.

Atomic creation changes the security property. There is no intermediate userspace-visible state in which the newly returned descriptor exists without its requested close-on-exec flag.

Fork and exec form separate inheritance stages

fork() and execve() are often discussed as one launch sequence, but descriptor policy spans two different transitions. A forked child initially inherits copies of the parent’s descriptor table entries. Close-on-exec does not close descriptors merely because a fork occurred.

The close happens only when an exec succeeds. If exec fails, the child still has the descriptors it inherited, including entries marked FD_CLOEXEC. Error paths in launcher code therefore remain inside the original descriptor authority domain until they explicitly close resources or terminate.

This distinction matters for helper-launch designs that perform work between fork and exec. That intermediate child may hold sensitive descriptors even when those descriptors are correctly marked close-on-exec. Close-on-exec limits authority in the replacement program; it is not isolation for arbitrary pre-exec code.

Launchers that require a tighter boundary need to constrain the code and operations executed in that interval and make descriptor ownership explicit.

Bulk cleanup and allowlists solve a different part of the problem

Some launchers intentionally pass a small set of descriptors into a child. Others want a replacement program to receive almost none. Close-on-exec supports both models, but only when every relevant descriptor carries the intended flag.

Bulk descriptor-closing facilities and launch APIs with explicit file actions can reduce dependence on ambient process state. They are complementary to atomic close-on-exec creation. A cleanup pass can define the child-side allowlist, while atomic creation prevents a newly opened descriptor from becoming temporarily inheritable before that cleanup policy runs.

The two controls address different timing points. One constrains descriptor state at creation; the other constrains the set retained for a launch. Treating either one as a universal substitute can leave gaps around concurrent descriptor creation, duplication, or pre-exec activity.

Descriptor inheritance is a capability propagation decision

An inherited descriptor can carry more authority than the replacement program’s filesystem identity suggests. It may represent a pre-authorized file, a listening socket, a connected peer, a directory usable for relative operations, or another kernel object whose access was established earlier.

That makes close-on-exec a capability-propagation control. Its value is not that it performs another permission check. Its value is that it prevents selected established references from crossing an executable boundary.

The strongest deployment pattern is consequently mechanical: request close-on-exec in the same operation that creates or duplicates descriptors, deliberately clear or replace that policy only for descriptors intended for the child, and keep the pre-exec interval narrow. Under those conditions, the descriptor table becomes an explicit launch interface rather than an accidental channel for inherited authority.