Why exec() in Privileged Programs is Dangerous
When a privileged program needs to run another program using exec(), system(), or popen(), it must be extremely careful. If the process’s privilege state isn’t fully reset before the exec, the new program inherits those privileges โ and may not be designed to handle them safely.
Even if the execed program is a trusted system utility, running it with unexpected root privileges or with open file descriptors to sensitive files creates dangerous, hard-to-predict behavior.
Before calling exec(), a privileged program must ensure that all user and group IDs are reset to the real (unprivileged) values. The new program should start completely unprivileged and have no ability to regain privilege.
How exec() Interacts with Saved Set-UID
There is one useful behavior: a successful exec() copies the effective user ID to the saved set-user-ID. This means even if you only called seteuid(getuid()) before exec (which normally doesn’t change the saved set-UID), the subsequent exec will copy the now-unprivileged EUID into the saved set-UID of the new program.
| Stage | Real UID | Effective UID | Saved Set-UID |
|---|---|---|---|
| SUID-root program started (owner=200, caller=1000) | 1000 | 200 | 200 |
After seteuid(getuid()) |
1000 | 1000 | 200 |
After successful exec() |
1000 | 1000 | 1000 |
The exec copies EUID (now 1000) into saved set-UID, so the new program is fully unprivileged. However, this only works if the exec succeeds. If exec fails, the saved set-UID is unchanged โ the process still has privilege=200. The program can then continue and do privileged work, which may be intentional (fallback behavior).
This is one of the most important rules in secure privileged programming: a privileged program must never exec a shell (bash, sh, zsh, etc.) while it still holds privilege.
Shells are general-purpose command interpreters with enormous power. Even if you don’t intend to provide interactive access, the shell can be manipulated through environment variables, startup scripts, argument parsing, and various other mechanisms to run arbitrary commands with your program’s effective UID.
All of these are dangerous in privileged programs. system("cmd") literally calls /bin/sh -c cmd. A malicious user controlling PATH or IFS can redirect what gets executed, running arbitrary code as root.
Safe Alternative: Use execve() with Absolute Paths
If you must exec another program, use execve() directly with an absolute path. This bypasses PATH entirely:
/* DANGEROUS: relies on PATH โ attacker could put a fake 'ls' in PATH */
execvp("ls", argv);
/* SAFE: absolute path, no PATH lookup, no shell involved */
execve("/bin/ls", argv, envp);
Set-UID Scripts Are Also Unsafe
On systems that honor set-UID bits on scripts, running them is just as dangerous as execing a shell directly. Linux wisely ignores the set-UID/set-GID bits on scripts (shell scripts, Python scripts, etc.) for this reason. Even on systems that do support them, their use should be avoided.
By default, open file descriptors survive across an exec(). If a privileged program opens a file that unprivileged users cannot read (say, /etc/shadow), the resulting file descriptor is a privileged resource. If the program then execs another program without closing that FD, the new program inherits it โ and can read the sensitive file.
There are two ways to prevent this:
Call
close(fd) for each sensitive file descriptor before exec. Simple and clear but you must track all FDs.Set the close-on-exec flag with
fcntl(fd, F_SETFD, FD_CLOEXEC). The kernel automatically closes this FD on any exec. Best for FDs that should never be inherited.#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
/*
* safe_exec(): Safely execute another program from a privileged context.
*
* Steps:
* 1. Drop all privileges permanently
* 2. Close sensitive file descriptors
* 3. Sanitize environment
* 4. exec with absolute path
*/
void safe_exec(const char *prog_path, char *const argv[])
{
uid_t uid = getuid();
gid_t gid = getgid();
/* Step 1: Permanently drop ALL privileges */
/* Drop supplementary groups first (still need EUID=0 for this) */
if (setgroups(0, NULL) == -1) {
perror("setgroups");
exit(EXIT_FAILURE);
}
/* Drop group ID */
if (setresgid(gid, gid, gid) == -1) {
perror("setresgid");
exit(EXIT_FAILURE);
}
/* Drop user ID last */
if (setresuid(uid, uid, uid) == -1) {
perror("setresuid");
exit(EXIT_FAILURE);
}
/* Verify the drop succeeded */
if (geteuid() != uid || getuid() != uid) {
fprintf(stderr, "FATAL: privilege drop failed before exec\n");
exit(EXIT_FAILURE);
}
/* Step 2: Close any sensitive FDs above stdin/stdout/stderr */
/* Simple approach: close all FDs from 3 upward */
int max_fd = (int)sysconf(_SC_OPEN_MAX);
for (int fd = 3; fd < max_fd; fd++) {
close(fd); /* Ignore errors โ FD may not be open */
}
/* Step 3: Sanitize critical environment variables */
/* Set PATH to known-safe value */
if (setenv("PATH", "/usr/local/bin:/usr/bin:/bin", 1) == -1) {
perror("setenv PATH");
exit(EXIT_FAILURE);
}
/* Clear IFS โ prevents shell word-splitting exploits */
if (setenv("IFS", "", 1) == -1) {
perror("setenv IFS");
exit(EXIT_FAILURE);
}
/* Step 4: exec with absolute path (never use execlp/execvp in privileged code) */
execv(prog_path, argv);
/* If we get here, exec failed */
perror("execv");
exit(EXIT_FAILURE);
}
int main(int argc, char *argv[])
{
printf("Running as EUID: %d\n", (int)geteuid());
/* Build the argument list */
char *args[] = { "/bin/ls", "-la", "/tmp", NULL };
/* This will drop privilege before executing ls */
safe_exec("/bin/ls", args);
/* Never reached */
return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
/*
* Demonstrates two ways to prevent FD inheritance across exec():
* 1. Open with O_CLOEXEC flag (atomic โ preferred)
* 2. Set FD_CLOEXEC on existing FD using fcntl()
*/
int open_private_file(const char *path)
{
int fd;
/* Method 1: Open with O_CLOEXEC โ atomic, preferred */
fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd == -1) {
perror("open");
return -1;
}
printf("Opened fd=%d for '%s' with O_CLOEXEC\n", fd, path);
return fd;
}
void set_cloexec_on_existing(int fd)
{
int flags;
/* Method 2: Set FD_CLOEXEC on an already-open FD */
flags = fcntl(fd, F_GETFD);
if (flags == -1) {
perror("fcntl F_GETFD");
return;
}
flags |= FD_CLOEXEC;
if (fcntl(fd, F_SETFD, flags) == -1) {
perror("fcntl F_SETFD");
return;
}
printf("Set FD_CLOEXEC on fd=%d\n", fd);
}
int main(void)
{
/* Open a file that should not be inherited by child processes */
int sensitive_fd = open_private_file("/etc/hostname");
if (sensitive_fd == -1)
return 1;
/* Or set it after opening */
int another_fd = open("/etc/os-release", O_RDONLY);
if (another_fd != -1) {
set_cloexec_on_existing(another_fd);
}
printf("\nBoth FDs will be automatically closed on exec()\n");
printf("No privileged file access leaks to child programs.\n");
/* These FDs would be closed if we called exec() here */
close(sensitive_fd);
if (another_fd != -1) close(another_fd);
return 0;
}
๐ Key Terms
/bin/sh -c command. If the process still holds elevated privilege, the shell inherits it. A malicious user can manipulate PATH, IFS, or other shell variables to make the shell execute arbitrary commands with the program’s elevated privileges.
execvp() searches PATH to find the program. An attacker can modify PATH to point to a malicious program with the same name. execve() with an absolute path bypasses PATH entirely โ the kernel is told exactly what to run.
FD_CLOEXEC is a flag that marks a file descriptor to be automatically closed when exec() is called. It should be set on any FD that the execed program should not inherit, especially FDs opened with elevated privilege. Can be set at open time with O_CLOEXEC or later with fcntl(fd, F_SETFD, FD_CLOEXEC).
seteuid(getuid()) before exec(), the new program’s saved set-UID will also be the unprivileged UID โ it cannot regain privilege. But this only works if exec() succeeds.
