When Tilde Is Not HOME: Deconstructing POSIX Expansion, execvp Path Resolution, and Shell Vulnerabilities
Explore the subtle mechanics of Unix path resolution to uncover why literal tildes in PATH create critical security vulnerabilities. Learn how shell tilde expansion, glibc execvp traversal, and non-interactive environments diverge under the hood.
The Illusion of Portability: The Ambiguity of Tilde Expansion
To generations of engineers, the tilde (~) represents the quintessential shorthand for the current user's home directory. We type cd ~, configure dotfiles with ~/.config, and routinely prepend tools to our environment using scripts like export PATH="~/bin:$PATH". To a casual shell user, ~ and $HOME appear functionally identical, interchangeable syntactic sugar preserved for ergonomic convenience.
Under the hood of POSIX-compliant operating systems, this assumption is dangerously flawed. The tilde is not an environment variable, nor is it recognized as an alias by the Linux kernel. It is a shell-level macro that undergoes a specific, highly contextual syntactic substitution phase called Tilde Expansion.
When this expansion fails—or when an unexpanded literal ~ leaks into environmental contexts like $PATH—it bypasses expected directory boundaries and introduces critical vulnerabilities, including arbitrary binary execution via current working directory (CWD) hijacking. Understanding why this happens requires descending through POSIX shell parsing phases, tracing the execution path of the C standard library's execvp(3), and examining how non-interactive systems interpret path strings.
POSIX Tilde Expansion Rules vs. Shell Variable Assignment
According to POSIX.1-2017 (IEEE Std 1003.1-2017, Section 2.6.1), tilde expansion occurs only under precise structural conditions during word analysis:
- A word begins with an unquoted tilde character (
~). - All following characters up to the first unquoted slash (
/) are treated as a login name. - If the login name is null (i.e., just
~or~/), the tilde is substituted with the value of theHOMEenvironment variable. - If a valid login name follows, the shell queries the user database (typically via
getpwnam(3)) and replaces the tilde prefix with that user's designated initial working directory.
Crucially, POSIX also specifies path assignment contexts where tildes appearing immediately after an unquoted colon (:) should be expanded. This was introduced specifically to support colon-separated lists like PATH and CDPATH:
# Standard interactive shell assignment:
export PATH=~/bin:~/.local/bin:$PATH
In modern interactive shells (Bash, Zsh, Dash), the tokenizer recognizes the context of a variable assignment (NAME=value) and executes tilde expansion on every colon-delimited segment. In this scenario, PATH is populated with absolute paths:
$ echo "$PATH"
/home/alice/bin:/home/alice/.local/bin:/usr/local/bin:/usr/bin:/bin
Where Tokenization Breaks Down
The failure mode manifests when configuration vectors deviate from basic interactive shell assignments. Consider these common engineering mistakes:
# Mistake 1: Quoting the tilde inhibits token-level expansion
export PATH="~/bin:$PATH"
# Mistake 2: Programmatic updates via subshells or parameter substitution
APPEND_DIR='~/scripts'
export PATH="$PATH:$APPEND_DIR"
In Mistake 1, double quotes preserve the literal identity of the tilde character. POSIX specifies that quote characters ('", \) deactivate tilde expansion. While Bash will expand variables like $PATH inside double quotes, it ignores the quoted ~. As a result, the literal string ~/bin is written directly into the process environment table (environ).
In Mistake 2, parameter expansion occurs after tilde expansion in the shell execution pipeline. The shell does not perform a recursive scan on the resulting string, leaving PATH polluted with the literal character sequence 0x7E (~).
The Kernel Boundary: Inside Glibc's execvp and Path Traversal
The operating system kernel knows nothing of users' home directories during binary execution. The sys_execve syscall (and its POSIX wrapper execve(2)) expects an exact, canonical pathname:
int execve(const char *pathname, char *const argv[], char *const envp[]);
If you pass ~/bin/custom-tool directly to execve, the kernel will attempt to resolve a directory literally named ~ relative to the current working directory, unless the path begins with a leading forward slash /. Because ~ is not /, it is treated strictly as a relative path.
When a program invokes an executable by name without an absolute path (e.g., git, python3, node), it delegates resolution to the C library via execvp(3) or execlp(3). To understand how this impacts corrupted PATH values, examine the internal logic of GNU C Library (glibc) within posix/execvpe.c:
// Simplified representation of glibc's execvpe path iteration loop
size_t path_len = strlen(path);
const char *subp;
while ((subp = strchr(path, ':')) != NULL) {
size_t len = subp - path;
char *buf = malloc(len + 1 + file_len + 1);
// Copy segment
memcpy(buf, path, len);
buf[len] = '/';
memcpy(buf + len + 1, file, file_len + 1);
// Execute attempt directly using the raw segment buffer
__execve(buf, argv, envp);
if (errno != ENOENT && errno != ENOTDIR) {
// Handle permission or execution errors
}
path = subp + 1;
}
Notice what execvpe does not do: it does not call wordexp(3), it does not inspect $HOME, and it does not invoke getpwnam(3). It takes each segment of $PATH exactly as provided in the environment string, appends /filename, and executes __execve.
If your $PATH contains literal ~/bin, glibc constructs the resolution target:
~/bin/<command>
Because this does not begin with /, the underlying VFS treats ~ as an ordinary relative subdirectory of whatever current working directory the calling process currently occupies.
Exploitation Mechanics: Weaponizing the Literal ~/ Directory
When a developer or system administrator inadvertently places a literal ~ into their global or user environment, they unknowingly introduce a relative-path injection vulnerability across the system.
The Attack Scenario
Assume a systems developer has the following line in their shell configuration or an automation script:
export PATH="~/bin:/usr/local/bin:/usr/bin:/bin"
Now, trace what happens when an attacker induces the developer to navigate to an untrusted repository or workspace (e.g., cloning a public project, inspecting a tarball, or operating inside a shared /tmp scratchpad):
/tmp/malicious_repo/
├── ~
│ └── bin
│ └── git <-- Malicious executable script/binary
└── README.md
- The developer clones the repository and enters the root:
cd /tmp/malicious_repo. - The developer runs a routine command:
git status. - The shell or calling process attempts to locate
gitby querying the entries inPATHsequentially. - The first token in
PATHis~/bin. execvpconcatenates./+~/bin+/git, evaluating to/tmp/malicious_repo/~/bin/git.- The operating system verifies that this relative file exists, is regular, and has execute bits set.
- The untrusted payload executes under the developer's privileges before the genuine
/usr/bin/gitis ever consulted.
This behavior mirrors the classic and widely condemned vulnerability of having . (the current working directory) placed in PATH, but it is far more insidious because static linters searching for empty tokens (::) or literal dots frequently miss literal tildes.
Systemd, PAM, and Non-Interactive Runtimes: Where Defaults Collapse
Discrepancies amplify when crossing the boundary between interactive user sessions and headless execution environments like Docker containers, CI/CD runners, and systemd user services.
/etc/environment and pam_env
A frequent production mistake occurs when engineers attempt to establish standard paths within /etc/environment or /etc/security/pam_env.conf:
# In /etc/environment:
PATH="~/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
/etc/environment is not a shell script. It is parsed directly by PAM's pam_env module as key-value pairs of literal text. PAM does not spin up a POSIX shell interpreter to evaluate this file. Consequently, the literal characters ~, /, b, i, n are injected directly into every spawned session's environment table without transformation.
Every cron job, SSH login, and systemd service spawned under this profile inherits a corrupted search vector.
Docker and Container Entrypoints
In container configuration files (Dockerfile), engineers often write directives like:
ENV PATH="~/app/bin:${PATH}"
The OCI runtime specification and Docker daemon do not perform shell expansions on ENV strings at build or run boundaries. If an entrypoint uses the exec-form array syntax:
ENTRYPOINT ["npm", "start"]
The container runc runtime directly calls execve on the host, passing the raw string from the image configuration. If the developer later uses WORKDIR /workspace, any relative resolution against ~ targets /workspace/~, rendering application startup behavior completely nondeterministic and vulnerable to file tree tampering.
Hardening Strategies: Canonicalization and Hermetic PATH Construction
To eliminate the unpredictability of tilde-based resolution and prevent relative path hijacking, engineering workflows must enforce canonical, absolute paths throughout all environment definitions.
1. Explicitly Bind to $HOME in Shell Configurations
Never rely on the contextual heuristics of the shell's tilde expansion engine. When defining user paths in .bashrc, .zshrc, or initialization hooks, use the explicit environment variable:
# Anti-pattern
export PATH="~/bin:$PATH"
export PATH=~/bin:$PATH
# Secure, deterministic pattern
export PATH="${HOME}/bin:${PATH}"
Variable expansion within double quotes is fully deterministic according to POSIX specifications. By wrapping the assignment in double quotes and referencing ${HOME}, the string immediately resolves to /home/username/bin at tokenization time, ensuring no literal relative identifiers are committed to the environment table.
2. Runtime Validation and PATH Sanitization
In sensitive systems or CI execution environments, implement defensive sanitization within your baseline profile scripts to strip relative components and unexpanded macro characters:
# Sanitize PATH: Strip empty elements, relative elements (.), and literal tildes (~)
sanitize_path() {
local new_path=""
local IFS=':'
for entry in $PATH; do
case "$entry" in
"" | "." | "~"* | [!/]*)
# Log anomaly or drop dangerous path silently
continue
;;
*)
if [ -d "$entry" ]; then
new_path="${new_path:+${new_path}:}${entry}"
fi
;;
esac
done
echo "$new_path"
}
export PATH="$(sanitize_path)"
This validator rejects:
- Empty tokens (
::), which POSIX mandates must resolve to the current directory. - Direct relative indicators (
.,./). - Leading tildes (
~*), catching unexpanded paths. - Any non-absolute path segment (
[!/]*).
3. Hermetic Automation with Static Analysis
Integrate linter checks within automated pipelines. ShellCheck provides immediate detection of unexpanded tildes in shell scripts via rule SC2088 (Tilde does not expand in quotes) and warning SC2142.
For non-shell configuration sources (systemd, container definitions, PAM configuration files), configure unit tests to inspect the raw /proc/$PID/environ block of running processes:
# Verify no process contains a PATH with unexpanded tildes
python3 -c '
import sys, os
path = os.environ.get("PATH", "")
for segment in path.split(":"):
if not segment.startswith("/"):
sys.exit(f"CRITICAL: Non-absolute path detected in PATH: {segment}")
print("PATH environment validated.")
'
By treating the tilde as what it truly is—an interactive shell shorthand, rather than a universal filesystem abstraction—you prevent silent path resolution failures and close an attack vector that has persisted in Unix environments for decades.