The Paranoid Programmer’s Mindset
A secure privileged program treats everything from outside the program as potentially hostile. This includes command-line arguments, environment variables, user input, files read from disk, data from pipes, data from network connections, and even the initial state of the process (open file descriptors, resource limits, etc.).
The attacker controls everything you receive from the outside world. Your job is to validate, sanitize, and never make assumptions.
When a user runs a set-UID program, they set the environment before execution. Environment variables can contain anything. Two variables are especially dangerous:
Determines where the shell and functions like execlp(), execvp(), and system() search for executables. An attacker sets PATH to point to a directory with a fake ls or sh binary. When your privileged program calls system("ls /tmp"), it runs the attacker’s binary as root.
IFS (Internal Field Separator) tells the shell which characters split words in a command line. In older shells, a malicious IFS value could cause a command like system("/bin/ls") to be interpreted as calling a different program by splitting the path. Always set IFS="" in scripts and clear it in programs that call the shell.
Defense: Sanitize or Clear the Environment
The safest approach is to either clear the entire environment and rebuild it with known-safe values, or at minimum set critical variables to safe values before calling any function that uses them.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
/* Known-safe environment for a privileged program */
static char *safe_env[] = {
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"IFS= \t\n", /* safe IFS: only space, tab, newline */
"HOME=/", /* safe home โ not user's $HOME */
"TERM=dumb", /* safe terminal setting */
NULL
};
/*
* sanitize_environment(): Replace entire environment with known-safe values.
*
* clearenv() removes all environment variables.
* We then rebuild with only the variables we need.
*/
void sanitize_environment(void)
{
/* Method 1: clearenv() then set individual vars (glibc extension) */
if (clearenv() != 0) {
perror("clearenv");
exit(EXIT_FAILURE);
}
if (setenv("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", 1) == -1 ||
setenv("IFS", " \t\n", 1) == -1) {
perror("setenv");
exit(EXIT_FAILURE);
}
printf("[Security] Environment sanitized.\n");
}
/*
* Method 2: Use execve() with an explicit environment array.
* This is the most reliable approach โ no surprises.
*/
void exec_with_safe_env(const char *prog_path, char *const argv[])
{
/* execve takes environment as third argument โ we control it completely */
execve(prog_path, argv, safe_env);
perror("execve"); /* only reached on failure */
exit(EXIT_FAILURE);
}
int main(void)
{
printf("Before sanitization:\n");
printf(" PATH = %s\n", getenv("PATH") ? getenv("PATH") : "(null)");
sanitize_environment();
printf("After sanitization:\n");
printf(" PATH = %s\n", getenv("PATH") ? getenv("PATH") : "(null)");
printf(" IFS = '%s'\n", getenv("IFS") ? getenv("IFS") : "(null)");
return 0;
}
Every input from outside the program โ command-line arguments, interactive input, file contents, CGI inputs, IPC data, network packets โ must be validated before use.
| Input Source | What to Validate |
|---|---|
| argv[] (command-line) | Length, character set, numeric range, path traversal (..) |
| stdin / fgets() | Maximum length, null bytes, special characters |
| Files read from disk | Line length, format, expected values, no shell metacharacters |
| Environment variables | Never trust โ sanitize or clear |
| Network packets / CGI | All of the above, plus injection attacks |
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#define MAX_USERNAME_LEN 32
#define MAX_FILENAME_LEN 256
/*
* validate_username(): Ensure a username contains only safe characters.
* Valid: alphanumeric, underscore, hyphen. Max 32 chars.
* Invalid: spaces, shell metacharacters, path separators.
*/
int validate_username(const char *name)
{
size_t len;
if (name == NULL) return 0;
len = strlen(name);
if (len == 0 || len > MAX_USERNAME_LEN) {
fprintf(stderr, "Username too short or too long: %zu chars\n", len);
return 0;
}
for (size_t i = 0; i < len; i++) {
if (!isalnum((unsigned char)name[i]) &&
name[i] != '_' && name[i] != '-') {
fprintf(stderr, "Invalid character in username at pos %zu: '%c'\n",
i, name[i]);
return 0;
}
}
return 1; /* Valid */
}
/*
* validate_filename(): Reject path traversal and dangerous characters.
*/
int validate_filename(const char *filename)
{
if (filename == NULL) return 0;
if (strlen(filename) == 0 || strlen(filename) > MAX_FILENAME_LEN) return 0;
/* Reject absolute paths */
if (filename[0] == '/') {
fprintf(stderr, "Absolute paths not allowed\n");
return 0;
}
/* Reject path traversal */
if (strstr(filename, "..") != NULL) {
fprintf(stderr, "Path traversal detected: %s\n", filename);
return 0;
}
/* Reject null bytes (can confuse C string functions) */
if (memchr(filename, '\0', strlen(filename)) != NULL) {
fprintf(stderr, "Null byte in filename\n");
return 0;
}
return 1; /* Valid */
}
int main(int argc, char *argv[])
{
if (argc != 3) {
fprintf(stderr, "Usage: %s <username> <filename>\n", argv[0]);
return 1;
}
printf("Validating inputs...\n");
if (!validate_username(argv[1])) {
fprintf(stderr, "Invalid username\n");
return 1;
}
printf("[OK] Username: %s\n", argv[1]);
if (!validate_filename(argv[2])) {
fprintf(stderr, "Invalid filename\n");
return 1;
}
printf("[OK] Filename: %s\n", argv[2]);
return 0;
}
A set-UID program should never assume its initial runtime environment is normal. The program that exec’d it might have deliberately set up a hostile environment.
Standard FD Assumption โ A Classic Trap
Programs assume that stdin (FD 0), stdout (FD 1), and stderr (FD 2) are open. But an attacker can close some or all of these before exec-ing the set-UID program. When the program then opens a sensitive file (e.g., a log file), it gets FD 1 โ and thinking it’s writing to stdout, it’s actually writing to that sensitive file.
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
/*
* ensure_std_fds_open():
*
* At program startup, ensure fd 0, 1, 2 (stdin/stdout/stderr) are open.
* If any is closed, open /dev/null on it so subsequent opens
* don't accidentally get assigned one of these reserved FDs.
*/
void ensure_std_fds_open(void)
{
int fd;
struct stat sb;
/* Check each of the three standard FDs */
for (int stdfd = 0; stdfd <= 2; stdfd++) {
if (fstat(stdfd, &sb) == -1) {
/* This FD is closed โ open /dev/null on it */
fd = open("/dev/null", O_RDWR);
if (fd == -1) {
/* Can't open /dev/null โ fatal */
_exit(EXIT_FAILURE);
}
if (fd != stdfd) {
/* Should not happen since we opened the lowest available FD,
* but handle it just in case */
dup2(fd, stdfd);
close(fd);
}
fprintf(stderr, "[Security] fd %d was closed โ replaced with /dev/null\n", stdfd);
}
}
}
int main(void)
{
/* First thing: guarantee standard FDs are sane */
ensure_std_fds_open();
/* Now safe to open files โ we know fd 3+ will not be stdin/stdout/stderr */
FILE *log = fopen("/var/log/myprog.log", "a");
if (log) {
fprintf(log, "Program started safely\n");
fclose(log);
}
printf("Program running normally.\n");
return 0;
}
Other Dangerous Assumptions
| Wrong Assumption | What Could Go Wrong | Fix |
|---|---|---|
| stdin/stdout/stderr are open | Opened file gets FD 1, writes to sensitive file | Check with fstat() at startup |
| Resource limits are reasonable | fork() fails, file operations fail, unexpected signals | Check and set resource limits explicitly |
| CWD is a safe directory | Relative paths resolve unexpectedly | chdir(“/”) or a known-safe directory at startup |
| Environment variables are safe | PATH attack, LD_PRELOAD injection | clearenv() and rebuild |
