A service process can accumulate sockets, pipes, directory handles, log files, and control descriptors long before it launches a helper. If those descriptors survive into the new program, the helper receives capabilities that its command-line arguments and environment do not reveal. A connected socket can carry authenticated access; an open directory can preserve reachability to a filesystem location; a pipe can expose another component’s data path.
Linux close_range() gives pre-exec code a range operation over file descriptors. Its security value is not that descriptors become harmless. It is that a process can narrow the descriptor set that crosses an execve() boundary without enumerating /proc/self/fd or issuing one close() call per candidate descriptor.
The inherited object is an open descriptor, not a pathname
A file descriptor is a process-local integer referring through the descriptor table to an open file description or another kernel object. Once a descriptor exists, later pathname restrictions do not revoke the authority already represented by that reference.
Across execve(), descriptors remain open unless their close-on-exec flag is set. This makes descriptor inheritance a distinct trust boundary from executable selection. A helper can be the intended binary and still receive unintended access because the launching process retained descriptors that were never part of the helper’s design.
The common policy shape is simple: preserve a small set deliberately prepared for the child, then remove everything above that set. For a process that intends to retain only standard input, output, and error, the range starts at descriptor 3.
if (close_range(3, ~0U, CLOSE_RANGE_UNSHARE) == -1) {
/* handle failure before exec */
}
execve(path, argv, envp);The upper bound is inclusive. Using ~0U expresses the rest of the unsigned descriptor range without first querying a process limit.
A range close avoids descriptor discovery as policy
One older pattern scans /proc/self/fd, identifies open descriptors, and closes them individually. That approach makes descriptor discovery part of the cleanup mechanism and depends on procfs being available. A loop bounded by a guessed or queried descriptor limit has a different problem: it spends work on integers that may not refer to open descriptors.
close_range(first, last, 0) asks the kernel to close open descriptors in the inclusive interval. Errors from closing individual descriptors in that range are currently ignored; the syscall reports errors associated with the range operation itself.
This interface does not decide which descriptors are safe to retain. The application still owns that policy. The syscall only makes a contiguous exclusion rule expressible directly at the descriptor-table boundary.
Shared descriptor tables change the concurrency problem
Threads normally share a file descriptor table. Closing a descriptor in that shared table can therefore affect another thread that still expects to use it. A pre-exec cleanup path that races with concurrent descriptor activity can also leave application reasoning dependent on timing.
CLOSE_RANGE_UNSHARE changes that boundary. Linux first unshares the calling task’s descriptor table from other tasks that share it, then applies the close operation to the requested range. Conceptually, the operation corresponds to unsharing CLONE_FILES and then closing the range, although the kernel can implement the combined operation more efficiently.
The flag is especially relevant when the caller is preparing its own descriptor state for a subsequent exec. Other threads keep their references through the old shared table, while the caller obtains a private table on which the range restriction is applied.
This does not freeze other process state. It specifically separates the file descriptor table. Memory, credentials, filesystem context, and other shared resources follow their own Linux sharing rules.
CLOEXEC defers removal to the execution boundary
CLOSE_RANGE_CLOEXEC has a different effect. Instead of immediately closing descriptors in the range, it sets their close-on-exec flag. The descriptors remain usable by the current program until a successful exec replaces the process image, at which point the close-on-exec rule removes them.
if (close_range(3, ~0U, CLOSE_RANGE_CLOEXEC) == -1) {
/* handle failure */
}
/* setup that may still need existing descriptors */
execve(path, argv, envp);This sequencing can matter when later setup steps still require open descriptors. It also allows descriptor policy to be established before installing a seccomp filter that may restrict later syscall choices.
The flag does not make a descriptor private before exec. Code running in the current process can still use it, and a failed execve() leaves the process image in place with those descriptors still open but marked close-on-exec. Error handling must account for that state.
Creation-time close-on-exec still closes a narrower race
close_range() is not a substitute for creation-time flags such as O_CLOEXEC, SOCK_CLOEXEC, or equivalent atomic descriptor-creation options. In a multithreaded process, creating a descriptor and only later setting FD_CLOEXEC can race with another thread performing fork plus exec. The descriptor can cross that execution boundary before the later flag update occurs.
Creation-time close-on-exec prevents that window for the descriptor being created. A range operation serves a different role: it establishes a broad policy over descriptors that already exist when a process prepares an execution boundary.
Robust designs often use both layers. Descriptors are created non-inheritable by default, and the launch path applies a final range policy so that accidental omissions do not silently expand the child program’s authority.
Preserved descriptors require deliberate placement
A contiguous close policy is easiest to audit when descriptors intended for the child occupy known numbers below the closed range or are duplicated into an explicit allowlist before cleanup. Descriptor remapping must itself avoid collisions and preserve the intended close-on-exec state.
This is where range-based denial reaches its limit. close_range() does not accept a sparse allowlist, label descriptors by purpose, or verify that descriptor 4 still denotes the object the launcher expected. Those properties belong to application logic.
The security claim should therefore remain narrow: a successful range operation constrains descriptor-table entries in the specified interval according to its flags. It does not authenticate retained objects or prove that the remaining descriptor set matches a higher-level policy.
Exec hygiene is capability hygiene
Descriptor inheritance matters because open kernel references carry usable authority independent of the new program’s textual configuration. A helper that inherits an already-connected privileged socket may bypass controls that would have applied if it had to establish a new connection itself.
close_range() provides a compact enforcement point for that boundary. CLOSE_RANGE_UNSHARE separates the caller’s descriptor table before closure; CLOSE_RANGE_CLOEXEC defers closure to exec; creation-time close-on-exec flags prevent a separate race when descriptors first appear.
These mechanisms cover different moments in a descriptor’s lifetime. Treating them as complementary keeps the operational claim precise: the launch path can reduce unintended inherited references, but only application policy can decide which surviving references are legitimate capabilities for the new process image.