Linux execveat: Execute an Open Program Without Re-Resolving Its Path
Use execveat() with a trusted directory or open executable descriptor to control Linux path lookup, and account for script, descriptor, and version boundaries.
A launcher that checks a pathname and later executes the same string can resolve a different file on the second lookup. Linux execveat(2) provides an execution interface anchored to a directory descriptor, or directly to an already-open executable descriptor. That can make the handoff more explicit, but the syscall is not a sandbox and does not automatically constrain all path traversal.
execveat() replaces the calling process image, like execve(). It does not create a child process or return on success. The Linux-specific interface arrived in Linux 3.19; glibc has a wrapper starting with 2.34. Older headers, libc versions, kernels, or non-Linux systems may need a different design.
Choose between a directory-relative path and an executable descriptor
The syscall takes a dirfd, path, argument vector, environment vector, and flags. A relative path is looked up from dirfd; if dirfd is AT_FDCWD, it is relative to the current working directory. An absolute path ignores dirfd, so passing a trusted directory descriptor does not make an absolute path relative to it.
For a directory-relative launch, open a trusted directory first and keep the descriptor under the launcher’s control. For example, an O_PATH | O_DIRECTORY | O_CLOEXEC descriptor for /usr/bin can be passed as dirfd to execveat(dirfd, "id", argv, envp, 0).
This fixes the starting directory object, but ordinary pathname resolution still applies beneath it. The syscall does not provide openat2()’s RESOLVE_BENEATH, RESOLVE_NO_SYMLINKS, or mount-crossing policy. If a less-trusted caller supplies the path and the lookup must obey a strict resolution policy, first obtain an executable descriptor through a lookup operation that enforces the policy, then execute that descriptor. Review the openat2(2) contract and the application’s later use of the opened object as separate parts of the design.
With AT_EMPTY_PATH, path is the empty string and dirfd is instead the descriptor for the executable file. This is useful after opening a known executable because the final call no longer resolves its name. The following small Linux program opens the system id binary and replaces itself with that executable:
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int
main(void)
{
int fd = open("/usr/bin/id", O_PATH | O_CLOEXEC);
if (fd == -1) {
perror("open /usr/bin/id");
return 1;
}
char *const argv[] = { "id", "-u", NULL };
char *const envp[] = { "PATH=/usr/bin:/bin", NULL };
execveat(fd, "", argv, envp, AT_EMPTY_PATH);
perror("execveat");
return 1;
}
Compile and run it on Linux with a compiler and the expected /usr/bin/id path:
cc -Wall -Wextra -Wpedantic -O2 -o exec-by-fd exec-by-fd.c
./exec-by-fd
The number printed is the effective user ID of the new program. The example uses a trusted system executable; it does not download, elevate, or execute caller-supplied bytes. In a real launcher, validate the intended executable and its provenance before opening it. Executing a descriptor prevents a second name lookup; it does not prove that the file came from a trusted publisher or satisfy an application’s content-integrity policy.
Keep flags and descriptor lifetime deliberate
AT_SYMLINK_NOFOLLOW causes the call to fail with ELOOP if the object named by a nonempty path is a symbolic link. It is not a general no-symlink rule for every component and does not replace a resolution policy. For an empty path, the descriptor already identifies the object; an earlier open determines how a symbolic link was handled.
O_CLOEXEC is normally desirable for an executable descriptor: on a successful exec it closes the descriptor in the replacement program. There is a documented exception for interpreter scripts. If a script is executed using execveat(fd, "", ..., AT_EMPTY_PATH) and the descriptor has close-on-exec set, the call can fail with ENOENT, because the interpreter cannot access the script through the descriptor. Clearing close-on-exec allows that script descriptor to leak into the interpreter. The manual warns that repeated script execution can accumulate leaked descriptors. Prefer this descriptor-execution pattern for a known ELF executable unless the script case has been designed and tested explicitly.
The argument and environment arrays must each end with a null pointer. On success, none of the code after execveat() runs. On failure, inspect errno; do not treat all ENOENT errors as proof that the initial file was absent. A missing shebang interpreter or ELF dynamic loader can also produce a missing-file error. EACCES can indicate execution permissions or filesystem policy; ENOEXEC can mean an unrecognized executable format; and ELOOP may reflect the requested no-follow behavior.
Test the process contract, not just the call
For a production launcher, test both successful execution and deliberate failures in a disposable environment. Cover a missing pathname, a non-executable file, the wrong architecture, a final symlink with AT_SYMLINK_NOFOLLOW, a script with and without close-on-exec, and the supported kernel/libc/header combinations. Verify the resulting argv, environment, process identity, and exit behavior using a child test program you control. Avoid testing against a live service binary or changing permissions on a system executable.
If only directory-relative execution is needed, do not adopt AT_EMPTY_PATH just because it is available. Conversely, if the purpose is to execute the exact object already opened under a strict path policy, reopening a saved path string gives up the descriptor boundary. A trustworthy design documents who selected the directory, who chose the executable, which resolution rules apply, and which file descriptor may survive into the new image.
execveat() is a narrow execution interface, not a substitute for process supervision, signing, privilege separation, or secure path resolution. Pin the object when that is the requirement, constrain lookup separately when path components are untrusted, and treat script execution as its own descriptor-lifetime case.
Related:
- Linux openat2: Constraining Path Resolution Against Symlink and Mount Escapes
- Linux close_range: File-Descriptor Hygiene Before exec
Sources: