A multithreaded Linux process can reach an awkward boundary just before execve(): one thread wants to discard every file descriptor above standard input, output, and error, while another thread can still create descriptors in the same table. A loop of close() calls treats descriptor numbers individually, but it does not by itself change the fact that the table is shared.

close_range() with CLOSE_RANGE_UNSHARE addresses that specific race. The kernel first gives the caller a file descriptor table that is no longer shared with the other users of the old table, then applies the requested bulk closure to the caller’s table. The security property is about table ownership during cleanup, not merely fewer system calls.

Descriptor tables can be shared state

Linux file descriptors are integer indexes into a process file descriptor table. Threads created with the usual pthread model share that table because they share CLONE_FILES state. Linux also permits other task arrangements that share the same table.

That sharing is useful during ordinary execution. A descriptor opened by one thread can immediately be used by another. It also means descriptor cleanup is a mutation of shared state.

Consider a pre-exec sequence that tries to close descriptors one at a time:

thread A                         thread B

close(3)
close(4)
                                 open(...) -> 4
close(5)
execve(...)

The exact descriptor allocation depends on process state, but the structural problem is stable: thread B can mutate the same table while thread A is attempting to sanitize it. Enumerating /proc/self/fd first does not turn the later sequence of close() calls into an atomic boundary.

CLOSE_RANGE_UNSHARE changes the ownership boundary

The Linux close_range(first, last, flags) interface operates on an inclusive descriptor-number range. With flags set to zero, descriptors in that range are closed in the caller’s current table.

CLOSE_RANGE_UNSHARE adds a separate property. Before closing the range, the kernel unshares the caller’s descriptor table from other tasks that share it. Linux documentation describes the operation as conceptually similar to:

unshare(CLONE_FILES);
close_range(first, last, 0);

The combined system call can implement that transition more efficiently. In the common case where last extends beyond the highest allocated descriptor, the kernel can construct the new table only up to first rather than copy a large table and then close entries from it.

A pre-exec boundary can therefore be expressed as:

close_range(3, ~0U, CLOSE_RANGE_UNSHARE);
execve(path, argv, envp);

After a successful close_range() call, descriptors 3 and above are absent from the caller’s detached table. A peer that still uses the old shared table can open or close descriptors there without repopulating the caller’s table.

The range boundary remains significant

Unsharing does not imply that every descriptor is closed. The first and last arguments still define the inclusive range affected by the call.

If a process invokes:

close_range(10, 20, CLOSE_RANGE_UNSHARE);

the caller receives an unshared table and the operation targets descriptors 10 through 20. Descriptors outside that range are not selected for closure merely because the table was detached.

This matters when low-numbered descriptors carry deliberate authority. Keeping descriptors 0 through 2 while removing everything above them is a common exec boundary, but applications can choose a different cutoff. That choice is deployment policy; the system call does not classify descriptors as safe or sensitive.

Unsharing is distinct from close-on-exec marking

CLOSE_RANGE_CLOEXEC changes another dimension. It sets the close-on-exec flag across the selected range instead of closing those descriptors immediately. The descriptors remain usable in the current executable image and are closed by a successful exec transition.

CLOSE_RANGE_UNSHARE is concerned with sharing of the descriptor table. CLOSE_RANGE_CLOEXEC is concerned with descriptor survival across exec. Linux permits flags to describe these properties, but they should not be treated as interchangeable controls.

A service may prefer immediate closure before executing a less-trusted program. Another sequence may need descriptors during pre-exec setup and mark them for later closure. The correct choice depends on which authority must remain available before execve() and which table-sharing races are possible during that interval.

Bulk closure does not revoke duplicated authority elsewhere

Detaching the caller’s table does not revoke file references already held by other tasks. Peers that retain the old shared table continue to hold its descriptors. A descriptor duplicated into another process through inheritance or descriptor passing is also outside the caller’s newly detached table.

The boundary is therefore local:

before:
caller ----+
           +--> shared fd table --> open file descriptions
peer   ----+

after CLOSE_RANGE_UNSHARE:
caller ------> private fd table
peer   ------> old fd table -----> open file descriptions

Closing an entry in the caller’s private table removes that reference from the caller. It is not a global revocation primitive for the underlying file, socket, pipe, or other kernel object.

Failure must block the sensitive transition

close_range() returns zero on success and -1 on error. With CLOSE_RANGE_UNSHARE, constructing the new table can fail, including for insufficient kernel memory or descriptor-limit conditions documented by Linux.

Code that treats cleanup as a security boundary must not proceed to the sensitive execve() path after such a failure. Continuing would convert an intended isolation condition into a best-effort cleanup step.

The system call also ignores errors associated with closing individual descriptors in the range. That behavior is part of the Linux interface and differs from a loop that observes each close() result. Applications that require per-descriptor accounting need a different operational contract.

The security effect ends at descriptor-table isolation

CLOSE_RANGE_UNSHARE removes a narrow race class: other users of a previously shared descriptor table cannot refill or mutate the caller’s detached table after the unshare transition. It does not inspect the authority represented by descriptors that remain outside the selected range, and it does not constrain future open(), socket(), dup(), or descriptor-receive operations performed by the caller.

A complete process boundary can require additional controls for filesystem access, system calls, credentials, namespaces, and inherited environment state. Bulk descriptor closure contributes one property to that boundary: the caller can separate its descriptor table and remove a selected range without a peer concurrently restoring entries into that same table.