Loading content...
Every process in Unix carries a hidden bag of key-value pairs: the environment. Unlike command-line arguments that are visible and explicit, environment variables work silently in the background, influencing program behavior in countless ways.
When you run a program, it doesn't just receive argv—it also inherits your entire environment: your PATH, your HOME, your locale settings, your terminal configuration, and dozens more variables. Understanding how environment flows through exec() is essential for:
By the end of this page, you will understand how environment variables are structured, how they're inherited or explicitly passed through exec(), the key environment variables that affect program behavior, how to manipulate the environment programmatically, and the security considerations that must guide environment handling.
The environment is remarkably similar to argv in structure: a NULL-terminated array of string pointers. The key difference is that each string has the format NAME=value.
1234567891011121314151617181920212223242526272829303132
#include <stdio.h> // The global 'environ' variable holds the current environmentextern char **environ; // Alternative: main() can receive envp as third argumentint main(int argc, char *argv[], char *envp[]) { // Both 'environ' and 'envp' point to the same structure: // // envp[0] → "PATH=/usr/bin:/bin\0" // envp[1] → "HOME=/home/user\0" // envp[2] → "USER=user\0" // envp[3] → "SHELL=/bin/bash\0" // ... // envp[n] → NULL printf("Environment via envp:\n"); for (int i = 0; envp[i] != NULL; i++) { printf(" envp[%d] = %s\n", i, envp[i]); } printf("\nEnvironment via environ (same data):\n"); for (int i = 0; environ[i] != NULL; i++) { printf(" environ[%d] = %s\n", i, environ[i]); } // Verify they're the same printf("\nenvp == environ? %s\n", envp == environ ? "Yes" : "No"); // Yes initially return 0;}Key characteristics:
PATH, Path, and path are different variables (on Unix)= is the delimiter: FOO=bar=baz means name=FOO, value=bar=bazFOO= means FOO is set to an empty stringThe global 'environ' and main()'s 'envp' initially point to the same data. However, if you use setenv() or putenv(), 'environ' may be updated while the original 'envp' becomes stale. Always use 'environ' when you need the current environment.
When exec() is called, the environment flows to the new program in one of two ways, depending on whether you use an 'e' variant.
execl(), execv()execlp(), execvp()environsetenv() calls visible to childexecle(), execve()execvpe()123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
#include <unistd.h>#include <stdlib.h>#include <stdio.h> extern char **environ; // Non-'e' variant: child inherits parent's environmentvoid inherit_example() { // Set something in parent's environment setenv("PARENT_VAR", "I came from parent", 1); // Child will see PARENT_VAR and all of parent's environment execl("/usr/bin/env", "env", NULL); // 'env' prints all environment variables - will show PARENT_VAR} // 'e' variant: explicit environmentvoid explicit_example() { // Create a custom, minimal environment char *custom_env[] = { "PATH=/usr/bin:/bin", "HOME=/tmp", "CUSTOM_VAR=hello from parent", NULL }; // Child sees ONLY these 3 variables // Parent's other variables (LANG, SHELL, etc.) are NOT inherited execle("/usr/bin/env", "env", NULL, custom_env);} // Common pattern: inherit + addvoid inherit_plus_add() { // Problem: we want parent's env PLUS some additions // Solution: copy environ and extend it // Count existing variables int count = 0; for (char **e = environ; *e; e++) count++; // Create extended array char **new_env = malloc((count + 3) * sizeof(char *)); // Copy existing for (int i = 0; i < count; i++) { new_env[i] = environ[i]; } // Add new variables new_env[count] = "MY_EXTRA_VAR=value1"; new_env[count+1] = "ANOTHER_VAR=value2"; new_env[count+2] = NULL; execve("/usr/bin/env", (char*[]){"env", NULL}, new_env); free(new_env); // Only on failure} // Common pattern: inherit + modifyvoid inherit_and_modify() { // Sometimes you want to change a variable, not just add // Solution: use setenv() before non-'e' exec // Modify PATH for child setenv("PATH", "/secure/bin:/usr/bin", 1); // overwrites existing // Remove a dangerous variable unsetenv("LD_PRELOAD"); // Now exec - child sees modified environment execl("/usr/bin/env", "env", NULL);}Use non-'e' variants for normal program execution where you want standard inheritance. Use 'e' variants when you need security (sanitized environment), testing (controlled conditions), or isolation (container-like execution). If you just need to add/modify a few variables, use setenv()/unsetenv() before a non-'e' exec.
Hundreds of environment variables exist, but some are especially critical for system operation and program behavior. Understanding these helps you debug issues and configure systems correctly.
| Variable | Purpose | Example |
|---|---|---|
PATH | Directories to search for executables | /usr/local/bin:/usr/bin:/bin |
HOME | User's home directory | /home/username |
USER/LOGNAME | Current username | username |
SHELL | User's default shell | /bin/bash |
PWD | Current working directory | /home/username/project |
OLDPWD | Previous working directory | /home/username |
TERM | Terminal type for formatting | xterm-256color |
LANG/LC_* | Localization settings | en_US.UTF-8 |
TZ | Timezone | America/New_York |
EDITOR/VISUAL | Preferred text editor | vim or /usr/bin/code |
| Variable | Purpose | Security Note |
|---|---|---|
LD_LIBRARY_PATH | Additional library search paths | Ignored for setuid binaries |
LD_PRELOAD | Libraries to load before all others | Ignored for setuid binaries |
LD_DEBUG | Enable linker debugging output | Ignored for setuid binaries |
LD_BIND_NOW | Resolve all symbols at load time | Generally safe |
12345678910111213141516171819202122232425262728293031323334353637
#include <stdio.h>#include <stdlib.h> void show_important_env() { // Access environment variables with getenv() printf("User context:\n"); printf(" USER = %s\n", getenv("USER") ?: "(unset)"); printf(" HOME = %s\n", getenv("HOME") ?: "(unset)"); printf(" SHELL = %s\n", getenv("SHELL") ?: "(unset)"); printf("\nPath configuration:\n"); printf(" PATH = %s\n", getenv("PATH") ?: "(unset)"); printf(" PWD = %s\n", getenv("PWD") ?: "(unset)"); printf("\nLocalization:\n"); printf(" LANG = %s\n", getenv("LANG") ?: "(unset)"); printf(" TZ = %s\n", getenv("TZ") ?: "(unset)"); printf("\nTerminal:\n"); printf(" TERM = %s\n", getenv("TERM") ?: "(unset)"); // Check for potentially dangerous variables printf("\nSecurity-relevant:\n"); const char *dangerous[] = {"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_DEBUG", "IFS", NULL}; for (const char **d = dangerous; *d; d++) { char *val = getenv(*d); if (val) { printf(" WARNING: %s is set to '%s'\n", *d, val); } }} int main() { show_important_env(); return 0;}LD_PRELOAD allows loading arbitrary shared libraries into a process, enabling powerful debugging and testing—but also code injection attacks. The dynamic linker ignores these variables for setuid/setgid programs (AT_SECURE mode), but non-privileged programs are vulnerable. Be careful about running untrusted code that has access to your environment.
C provides several functions for reading and modifying the process environment. Understanding their semantics—especially regarding memory management—is crucial for correct usage.
| Function | Purpose | Memory Handling |
|---|---|---|
getenv(name) | Get value of a variable | Returns pointer to environ string; don't modify! |
setenv(name, value, overwrite) | Set/create a variable | Copies name and value; handles memory |
unsetenv(name) | Remove a variable | Modifies environ array |
putenv(string) | Set from NAME=value string | String becomes part of environ; you own memory! |
clearenv() | Remove all variables (non-standard) | Clears the entire environment |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
#include <stdio.h>#include <stdlib.h>#include <string.h> // READINGvoid reading_env() { // getenv returns NULL if variable doesn't exist char *path = getenv("PATH"); if (path != NULL) { printf("PATH = %s\n", path); } else { printf("PATH is not set\n"); } // DANGER: Don't modify the returned string! // path points into the actual environ array // path[0] = 'X'; // BAD: undefined behavior} // SETTING with setenv()void setting_with_setenv() { // setenv copies both name and value // Third argument: 0 = don't overwrite if exists // 1 = overwrite existing value setenv("MY_VAR", "hello", 1); // Always sets setenv("MY_VAR", "world", 0); // NO-OP: MY_VAR already exists printf("MY_VAR = %s\n", getenv("MY_VAR")); // "hello" setenv("MY_VAR", "world", 1); // Overwrites printf("MY_VAR = %s\n", getenv("MY_VAR")); // "world" // setenv handles special characters correctly setenv("COMPLEX", "has=equals and spaces", 1); // Works fine} // SETTING with putenv() - DANGEROUSvoid setting_with_putenv() { // putenv takes a "NAME=value" string and puts the pointer // directly into environ. You own the memory! // WRONG: string literal may be in read-only memory // putenv("BAD=idea"); // May crash when trying to modify // WRONG: stack variable goes out of scope // char buf[100]; // sprintf(buf, "BAD=%d", 42); // putenv(buf); // Dangling pointer after function returns! // CORRECT: dynamically allocated, never freed char *safe = malloc(100); sprintf(safe, "GOOD=%d", 42); putenv(safe); // OK, but don't free 'safe'! // CORRECT: static buffer static char static_buf[100]; sprintf(static_buf, "ALSO_GOOD=%d", 123); putenv(static_buf);} // REMOVINGvoid removing_env() { setenv("TO_REMOVE", "exists", 1); printf("Before: %s\n", getenv("TO_REMOVE") ?: "(null)"); unsetenv("TO_REMOVE"); printf("After: %s\n", getenv("TO_REMOVE") ?: "(null)"); // (null) // unsetenv("NONEXISTENT") is a no-op, not an error} // CLEARING (GNU extension)void clearing_env() { #ifdef __GLIBC__ clearenv(); // Remove ALL environment variables // Now environ is empty (just NULL terminator) // Only available with glibc #endif} int main() { reading_env(); setting_with_setenv(); removing_env(); return 0;}setenv() is generally safer because it copies the name and value, managing memory internally. putenv() makes the string part of the environment directly—dangerous if you ever free or modify that memory. Only use putenv() when you specifically need the memory-sharing behavior (rare).
When using 'e' variants of exec(), you need to construct a proper environment array. Here are the patterns for common scenarios.
123456789101112131415
// Minimal secure environment for sensitive operationschar *minimal_env[] = { "PATH=/usr/bin:/bin", "HOME=/tmp", "TERM=dumb", "LANG=C", NULL}; execve("/path/to/program", argv, minimal_env); // Use cases:// - Running untrusted code// - Reproducible builds// - Security-sensitive daemonsThe environment strings must remain valid until exec() replaces the process. If exec() fails, you're responsible for cleanup. The safest approach is to point to existing strings (from environ or static storage) and only dynamically build the array of pointers, not the strings themselves.
The environment is a significant attack surface. It can be used to:
LD_PRELOAD or LD_LIBRARY_PATHPATH, IFS, locale settingsSecure programs must treat the inherited environment with suspicion.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <string.h> extern char **environ; // Secure environment for setuid or privileged programsvoid sanitize_environment_for_privileged_exec() { // 1. Define allowed variables (allowlist approach) const char *allowed[] = { "TERM", "LANG", "LC_ALL", "TZ", "HOME", "LOGNAME", "USER", NULL }; // 2. Build a clean environment from scratch char *safe_env[20]; int idx = 0; // 3. Set PATH explicitly (never inherit!) safe_env[idx++] = "PATH=/usr/bin:/bin"; safe_env[idx++] = "IFS= \t\n"; // 4. Selectively copy allowed variables for (const char **a = allowed; *a; a++) { char *val = getenv(*a); if (val != NULL) { // Validate value sanity before including if (strlen(val) < 1000) { // Prevent buffer attacks static char buf[10][1100]; // Static to persist static int bufidx = 0; snprintf(buf[bufidx], sizeof(buf[bufidx]), "%s=%s", *a, val); safe_env[idx++] = buf[bufidx++]; } } } safe_env[idx] = NULL; // 5. Execute with the sanitized environment execve("/path/to/privileged_program", argv, safe_env); perror("exec failed"); _exit(127);} // Validate an environment variable valueint is_sane_value(const char *name, const char *value) { // Check length if (strlen(value) > 4096) return 0; // Check for null bytes (shouldn't happen, but verify) if (memchr(value, '\0', strlen(value))) return 0; // Path-like variables shouldn't have ".." or absolute paths // to untrusted directories if (strcmp(name, "PATH") == 0 || strcmp(name, "LD_LIBRARY_PATH") == 0) { if (strstr(value, "..")) return 0; if (strstr(value, "/tmp")) return 0; // Example policy } return 1;}LD_PRELOAD, LD_LIBRARY_PATH, etc. can inject code. The kernel removes these for setuid, but not for non-setuid privileged code (capabilities, etc.)./proc/*/environ, logs, core dumps, error messages. Use files or file descriptors instead.clearenv() and build from scratch.On Linux, /proc/<pid>/environ is readable by the same user. If you pass secrets like API_KEY=xyz via environment, any process running as the same user can read them. Secrets should be passed via files (with restricted permissions) or file descriptors, never environment variables.
When you invoke a shell command via system() or execlp("sh", "sh", "-c", ...), the environment takes on additional importance because the shell interprets environment-based variables.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
#include <stdio.h>#include <stdlib.h>#include <unistd.h> // Shell uses many environment variables for its own operationvoid shell_critical_variables() { // IFS - Internal Field Separator (extremely dangerous if modified!) // Default is " \t\n" (space, tab, newline) // If set maliciously: IFS="/" could make "/bin/ls" become "bin ls" // PATH - where to find commands (obvious attack vector) // SHELL - which shell to use (usually doesn't affect sh -c) // ENV, BASH_ENV - scripts to source on shell startup // Could execute arbitrary code before your command // CDPATH - affects cd command resolution // PS1, PS2, PS4 - prompts (theoretically could contain code)} // DANGEROUS: Using system() with untrusted environmentvoid dangerous_system() { // If PATH is compromised, this could run a malicious "ls" system("ls -la"); // If IFS is compromised, argument parsing breaks system("rm -rf temp/files"); // Could become "rm -rf temp files"!} // SAFER: Sanitize environment before shell invocationvoid safer_shell_exec() { // Reset critical variables setenv("PATH", "/usr/bin:/bin", 1); setenv("IFS", " \t\n", 1); // Standard IFS unsetenv("CDPATH"); unsetenv("ENV"); unsetenv("BASH_ENV"); unsetenv("LD_PRELOAD"); unsetenv("LD_LIBRARY_PATH"); system("ls -la"); // Still not perfect, but much safer} // SAFEST: Avoid shell entirelyvoid safest_no_shell() { // Invoke commands directly without shell interpretation pid_t pid = fork(); if (pid == 0) { // Child: exec directly execlp("ls", "ls", "-la", NULL); _exit(127); } // Parent: wait... // No shell means: // - No IFS issues // - No shell metacharacter expansion // - PATH is used by execlp, but still predictable}The system() function invokes the shell, which introduces complexity and attack surface. Prefer fork+exec for running external commands. If you must use system(), carefully sanitize the environment first. Never pass user input directly to system()—that's a classic command injection vulnerability.
Environment handling varies somewhat across platforms. Understanding these differences is important for portable code.
| Feature | Linux | macOS | Windows (via POSIX layer) |
|---|---|---|---|
| Case sensitivity | Case-sensitive | Case-sensitive | Case-insensitive |
environ global | Available | Available | Available (with POSIX layer) |
clearenv() | Available (glibc) | Not available | Varies |
| Max env size | ~2 MB typically | ~256 KB typically | ~32 KB typically |
| setuid protection | Kernel clears LD_* | Kernel clears DYLD_* | N/A |
| Library injection var | LD_PRELOAD | DYLD_INSERT_LIBRARIES | N/A |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
#include <stdio.h>#include <stdlib.h> // Portably clear the environmentvoid portable_clearenv() {#ifdef __GLIBC__ // GNU libc provides clearenv() clearenv();#elif defined(__APPLE__) // macOS: set environ to empty manually extern char **environ; static char *empty[] = { NULL }; environ = empty;#else // Fallback: unset variables one by one extern char **environ; while (environ[0] != NULL) { // Find the variable name char *eq = strchr(environ[0], '='); if (eq) { char name[256]; size_t len = eq - environ[0]; if (len < sizeof(name)) { strncpy(name, environ[0], len); name[len] = '\0'; unsetenv(name); } } else { break; // Malformed entry, stop } }#endif} // Portably sanitize dynamic linker variablesvoid sanitize_dynamic_linker_vars() {#if defined(__linux__) unsetenv("LD_PRELOAD"); unsetenv("LD_LIBRARY_PATH"); unsetenv("LD_AUDIT"); unsetenv("LD_DEBUG"); unsetenv("LD_DEBUG_OUTPUT");#elif defined(__APPLE__) unsetenv("DYLD_INSERT_LIBRARIES"); unsetenv("DYLD_LIBRARY_PATH"); unsetenv("DYLD_FRAMEWORK_PATH"); unsetenv("DYLD_PRINT_LIBRARIES");#elif defined(__FreeBSD__) unsetenv("LD_PRELOAD"); unsetenv("LD_LIBRARY_PATH"); unsetenv("LD_LIBMAP");#endif // Add more platforms as needed}Windows uses case-insensitive environment variable names (PATH, Path, and path are the same). When using POSIX layers like Cygwin or WSL, behavior may attempt to match the POSIX model, but native Windows APIs differ significantly. The CreateProcess() function handles environment differently than exec().
Environment-related bugs can be subtle: a program works in one context but fails in another due to environmental differences. Here are techniques for debugging.
12345678910111213141516171819202122232425262728
# View a process's environment (Linux)$ cat /proc/<pid>/environ | tr '\0' '\n' # Compare your environment with another user's$ sudo -u otheruser env | sort > /tmp/other_env$ env | sort > /tmp/my_env$ diff /tmp/my_env /tmp/other_env # Run a command with a minimal environment$ env -i PATH=/usr/bin:/bin HOME=/tmp ./my_program # Run with an additional environment variable$ MY_DEBUG=1 ./my_program # Run with a modified PATH$ PATH=/custom/path:$PATH ./my_program # Watch what environment a program receives$ strace -f -e trace=execve ./my_script 2>&1 | grep envp # Print environment inside a script for debugging$ cat > debug.sh << 'EOF'#!/bin/bashecho "=== Environment ===" >&2env | sort >&2echo "===================" >&2# ... rest of scriptEOF1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
#include <stdio.h>#include <stdlib.h>#include <string.h> extern char **environ; // Print full environment diagnosticsvoid diagnose_environment() { fprintf(stderr, "=== Environment Diagnostic ===\n"); // Critical variables const char *critical[] = { "PATH", "HOME", "USER", "SHELL", "LANG", "LD_LIBRARY_PATH", "LD_PRELOAD", "PWD", NULL }; fprintf(stderr, "\nCritical variables:\n"); for (const char **c = critical; *c; c++) { char *val = getenv(*c); fprintf(stderr, " %s = %s\n", *c, val ? val : "(unset)"); } // Count and total size int count = 0; size_t total_size = 0; for (char **e = environ; *e; e++) { count++; total_size += strlen(*e) + 1; } fprintf(stderr, "\nEnvironment statistics:\n"); fprintf(stderr, " Variable count: %d\n", count); fprintf(stderr, " Total size: %zu bytes\n", total_size); // Check for suspicious entries fprintf(stderr, "\nSuspicious entries:\n"); for (char **e = environ; *e; e++) { size_t len = strlen(*e); // Very long variables if (len > 1000) { char name[64]; const char *eq = strchr(*e, '='); if (eq) { size_t name_len = eq - *e; if (name_len > 63) name_len = 63; strncpy(name, *e, name_len); name[name_len] = '\0'; fprintf(stderr, " LONG: %s (%zu bytes)\n", name, len); } } // Dangerous variables that are set if (strncmp(*e, "LD_", 3) == 0 || strncmp(*e, "DYLD_", 5) == 0 || strncmp(*e, "IFS=", 4) == 0) { fprintf(stderr, " WARN: %s\n", *e); } } fprintf(stderr, "=== End Diagnostic ===\n\n");} // Call this at program start during debugging__attribute__((constructor))void auto_diagnose() { if (getenv("DIAGNOSE_ENV")) { diagnose_environment(); }}When a program works in one context but not another, capture the environment in both cases (env | sort > env_good.txt and env | sort > env_bad.txt), then diff them. The difference often reveals the problem immediately—a missing library path, different locale, or unexpected variable.
We've thoroughly explored environment variables and their role in process execution. Let's consolidate the key concepts:
NAME=value, accessed via environ or envp.execl(), execv(), etc. pass the current environ to the child.execve(), execle() give you complete control over child's environment.setenv() copies values; putenv() shares memory with all its pitfalls.PATH, HOME, LD_PRELOAD, locale settings, and many others./proc//<pid>/environ and can leak in many ways.system() and sh -c interpret environment variables, amplifying attack surface.What's next:
With all the pieces in place—exec() variants, process image replacement, argument passing, and environment variables—we're ready to assemble the complete picture: the fork-exec pattern. The next page explores this iconic Unix paradigm: how fork() and exec() work together to create the flexible, powerful process creation model that defines Unix systems.
You now understand how environment variables flow through exec(), how to build custom environments, critical security considerations, and debugging techniques for environment-related issues. Next, we'll see how fork() and exec() combine in the iconic fork-exec pattern—the foundation of Unix process creation.