Privilege Escalation · intermediate · ~12 min
**What you will learn** - Enumerate a Linux host for privilege-escalation footholds using `sudo -l` and `find / -perm -4000`, and read the output correctly. - Explain *why* an over-broad `sudo` rule or a risky SUID binary lets a normal user become root, using a clear trust-boundary model. - Recognise the recurring GTFOBins pattern: a "normal" tool that can also run commands, read any file, or write any file. - Write least-privilege `sudoers` rules and audit/remove unnecessary SUID bits so each escalation path is closed. - Verify a fix actually works by re-testing that the escalation no longer succeeds. - Configure logging and detection so that sudo abuse and SUID misuse leave evidence a defender can find.
Security objective. The asset you are protecting is root on a Linux host — total control of the machine, its data, and every account on it. The threat is a local attacker who already has an unprivileged shell (through a web bug, stolen SSH key, or a low-privilege service account) and wants to escalate to root. In this lesson you will learn to detect the two misconfigurations that most often make that possible, and to prevent them.
This builds directly on your prerequisite, "Privilege escalation: the enumeration-first mindset". There you learned that escalation is mostly a search problem: you enumerate the system methodically and let the machine tell you what is misconfigured, rather than firing exploits blindly. This lesson applies that mindset to two of the highest-yield targets on Linux:
sudo rules. sudo lets a user run a program as another user (usually root). If the rule grants a program that can also spawn a shell — vim, less, find, awk, python — then "run this one tool as root" quietly becomes "become root".Both problems share one root cause: a program runs with root authority on behalf of a user, and that program can do more than the administrator intended. The GTFOBins project (gtfobins.github.io) catalogs exactly which everyday tools can be abused this way.
Everything here is practised on a machine you own or are explicitly authorised to test — a local VM, a container, or an intentionally-vulnerable lab. The offensive steps exist so you can understand and close the hole. Every vulnerable example in this lesson is paired with a secure fix and a verification step.
In authorised penetration tests and red-team engagements, sudo and SUID misconfigurations are among the most frequently reported Linux privilege-escalation findings. The reason is simple: they are easy to introduce by accident. An admin adds NOPASSWD: /usr/bin/find so a backup script runs unattended; a developer sets the SUID bit on a helper binary to "make it work"; a package ships a SUID tool that later turns out to be abusable. None of these look dangerous in isolation.
For a defender / blue teamer, understanding this class of bug is what lets you write a tight sudoers policy, run a SUID audit as part of hardening, and set up detection so that abuse is not silent. For a penetration tester, being able to demonstrate the escalation — and then hand over a precise, actionable remediation — is the difference between a scary-sounding finding and a report the client can actually fix.
The recurring lesson: the trick is always the same "normal tool, extra power" shape. Once you can see that shape, you can find it in enumeration output and design it out of your systems. And a critical mindset point — passing an automated privilege-escalation scanner does not prove a host is secure. Scanners miss custom SUID binaries, novel sudo rules, and logic-specific abuse. Manual review still matters.
sudo and the sudoers policyDefinition. sudo runs a command as another user (root by default), governed by rules in /etc/sudoers (and /etc/sudoers.d/). sudo -l prints the rules that apply to you.
How it works. A rule like alice ALL=(ALL) NOPASSWD: /usr/bin/find means "alice may run /usr/bin/find as any user, no password required." The danger is not find itself — it is that find has a -exec flag that runs arbitrary commands. Run as root, find can launch a root shell.
When it is fine / when it is not. Granting a specific, non-shell-spawning command (e.g. systemctl restart nginx) to a trusted user is reasonable. Granting a tool from the GTFOBins list, a wildcard path, or a script the user can edit is not.
Pitfall. Wildcards. sudo: /usr/bin/git * looks narrow but git has features (pager, hooks, -c core.pager=) that run commands. "One command" is not the same as "one capability".
Definition. GTFOBins is a curated list of Unix binaries that can be abused to break out of a restricted context — spawn a shell, read/write arbitrary files, or run commands — when they are available via sudo, SUID, or capabilities.
Plain explanation. Many tools embed a mini programming language or a shell escape. less and man can run !command; vim can run :!command; awk, python, perl, find can all execute code. If root runs any of these on your behalf, you inherit root's power.
Pitfall. Assuming a tool is safe because its purpose is innocent. tar compresses files — but tar --checkpoint-action=exec=... runs commands. Purpose does not equal capability.
Definition. The SUID bit (chmod u+s, shown as rws in ls -l) makes an executable run with the effective UID of its owner. SGID (g+s) does the same for the group. find / -perm -4000 lists SUID files.
How it works. The kernel sets the process's effective UID to the file owner's UID at exec time. passwd legitimately uses this to edit /etc/shadow. The problem is a SUID-root binary that also lets you run other commands or read arbitrary files.
When it is fine / when it is not. A small, audited, single-purpose SUID binary (like passwd or sudo itself) is expected. A SUID copy of bash, find, nmap, or a homemade helper that calls system("...") is a serious hole.
Pitfall. Custom SUID binaries that call another program by a relative name or via the shell — the attacker controls $PATH or the argument and redirects execution.
Definition. By default sudo sanitises the environment, but a misconfigured env_keep (or a SUID binary that trusts the environment) can preserve dangerous variables like LD_PRELOAD or LD_LIBRARY_PATH.
How it works. LD_PRELOAD forces the dynamic loader to load your library before others, so your code runs inside the privileged process. This is why sudo's env_reset default matters, and why the loader ignores LD_PRELOAD for SUID binaries unless something re-enables it.
Pitfall. Adding Defaults env_keep += "LD_PRELOAD" to "fix" a broken script re-opens this hole for every sudo command.
THREAT MODEL: local privilege escalation via sudo / SUID
Trust boundary: UID separation (unprivileged user -> root)
+-----------------------------+ +---------------------------+
| Attacker context | | Protected asset |
| - unprivileged shell (bob) | | - root / UID 0 |
| - can read sudo -l | | - /etc/shadow |
| - can read world-readable | | - all users' data |
| files, run own binaries | | - kernel & services |
+--------------+--------------+ +-------------+-------------+
| ^
| ENTRY POINTS (things that cross the |
| boundary while running AS ROOT): |
| |
| [1] sudo rule granting a GTFOBins |
| tool (find/vim/less/awk/...) ----+
| [2] SUID-root binary that runs ----+
| commands or reads any file |
| [3] env leak (LD_PRELOAD) into ----+
| a privileged process |
v |
boundary CROSSED ==> attacker code runs as UID 0
Knowledge check.
sudo NOPASSWD: /usr/bin/less for a low-privileged user. What insecure assumption did the admin make, and which log would show the sudo call?The two enumeration commands and the two hardening building blocks:
# ENUMERATE: what may I run as another user via sudo?
sudo -l
# Lists rules for the current user. Look for:
# NOPASSWD -> no password needed
# (ALL) or (root) -> runs as root
# wildcards (*) or GTFOBins tools -> abusable
# ENUMERATE: which files run as their owner (SUID)?
find / -perm -4000 -type f 2>/dev/null
# -perm -4000 matches the SUID bit (SGID would be -2000)
# 2>/dev/null hides permission-denied noise
# ls -l on a hit shows 'rws' where an 'x' would normally be
# HARDEN: a least-privilege sudoers rule (edit ONLY with visudo)
# Grant the exact command + arguments, run as a specific target user.
deploy ALL=(www-data) /usr/bin/systemctl reload nginx
# - no wildcard, no shell-capable tool, explicit target user
# - omit NOPASSWD unless truly required for automation
# HARDEN: remove an unnecessary SUID bit and confirm
sudo chmod u-s /usr/local/bin/oldhelper
ls -l /usr/local/bin/oldhelper # 'rws' should become 'rwx'
Always edit sudoers with visudo (or visudo -f /etc/sudoers.d/myfile): it syntax-checks before saving, so a typo cannot lock you out of sudo.
Both of these escalation routes share one root cause: programs that run as root on behalf of a normal user.
sudo -l shows what you are allowed to run as another user. Watch for these dangerous cases:
(ALL) NOPASSWD: /usr/bin/vim. Inside vim you can run :!/bin/sh to get a root shell. Many "harmless" tools (less, awk, find, tar, python) can also run commands or read any file. The GTFOBins project lists these techniques.NOPASSWD on an editable target. If sudo runs a script you can edit, or a script that calls a binary by a relative path, you can change what runs as root.LD_PRELOAD or LD_LIBRARY_PATH, you can make the program load a malicious shared library.A SUID binary runs as its owner (often root), no matter who launches it. List them with:
find / -perm -4000 2>/dev/null
The risks:
secure_path, keep the default environment reset, and never use NOPASSWD on scripts a user can edit.system() calls and relative paths. These are exactly the issues covered in the platform's secure-coding C exercises.This example uses a small custom SUID-root helper to show the full insecure → secure → verify cycle. Build and run it only in an isolated lab (a throwaway VM or container you own).
/* vuln_helper.c -- INTENTIONALLY VULNERABLE demo of a SUID pitfall.
Idea: a "log rotator" meant only to gzip /var/log/app.log,
but it builds a shell command from user input and runs it as root. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "usage: %s <logname>\n", argv[0]);
return 1;
}
char cmd[256];
/* PITFALL 1: user input is pasted straight into a shell command. */
/* PITFALL 2: 'gzip' is looked up via $PATH, not an absolute path. */
int n = snprintf(cmd, sizeof cmd, "gzip -f /var/log/%s", argv[1]);
if (n < 0 || (size_t)n >= sizeof cmd) {
fprintf(stderr, "argument too long\n");
return 1;
}
/* system() runs /bin/sh -c cmd with the process's (root) privileges. */
return system(cmd);
}
In the lab, an admin makes it SUID-root:
cc -O2 -o vuln_helper vuln_helper.c
sudo chown root:root vuln_helper
sudo chmod u+s vuln_helper # now it runs as root for everyone
Because the input is concatenated into a shell command, an unprivileged user can inject: ./vuln_helper 'app.log; id' — the ; id runs as root. (We show the shape of the abuse to explain the fix; we do not build a shell payload.) Because gzip is found via $PATH, a user can also prepend a directory holding a fake gzip.
/* safe_helper.c -- hardened version.
- No shell: exec the program directly with an argument vector.
- Absolute program path (no $PATH search).
- Strict input validation: a log name may only be [A-Za-z0-9._-]
and must not contain '/' or '..'.
- Drop privileges we do not need before doing the work. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <ctype.h>
#include <sys/types.h>
static int is_safe_name(const char *s) {
if (s[0] == '\0' || strlen(s) > 64) return 0;
if (strstr(s, "..")) return 0;
for (const char *p = s; *p; ++p) {
if (!(isalnum((unsigned char)*p) || *p=='.' || *p=='_' || *p=='-'))
return 0;
}
return 1;
}
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "usage: %s <logname>\n", argv[0]);
return 1;
}
if (!is_safe_name(argv[1])) {
fprintf(stderr, "rejected: invalid log name\n");
return 1;
}
char path[128];
int n = snprintf(path, sizeof path, "/var/log/%s", argv[1]);
if (n < 0 || (size_t)n >= sizeof path) {
fprintf(stderr, "rejected: name too long\n");
return 1;
}
/* No shell, absolute path, fixed argument vector: nothing to inject. */
execl("/bin/gzip", "gzip", "-f", path, (char *)NULL);
perror("execl"); /* only reached if exec fails */
return 1;
}
(If this helper truly does not need root at all, the best fix is to not make it SUID in the first place — least privilege beats careful coding.)
cc -O2 -o safe_helper safe_helper.c
# GOOD input is accepted (compresses the real log):
printf 'hello\n' | sudo tee /var/log/app.log >/dev/null
./safe_helper app.log && echo "ACCEPTED good input" # -> ACCEPTED good input
# BAD input is rejected, not executed:
./safe_helper 'app.log; id' # -> rejected: invalid log name
./safe_helper '../../etc/shadow' # -> rejected: invalid log name
./safe_helper "$(python3 -c 'print("a"*100)')" # -> rejected: name too long
Expected output. The good call prints ACCEPTED good input; each malicious call prints a rejected: message and exits non-zero without running any injected command. The injection (; id) and the path traversal (../../etc/shadow) never reach a shell or an unexpected file, because there is no shell and the name is validated. That is the mitigation proof.
Walkthrough of the secure helper, since the fix is the point.
| Step | Code | What happens |
|---|---|---|
| 1 | if (argc != 2) |
Reject anything but exactly one argument — no ambiguous or missing input. |
| 2 | is_safe_name(argv[1]) |
Allowlist validation runs before the value is used anywhere. |
| 3 | `if (s[0]=='\0' | |
| 4 | if (strstr(s, "..")) |
Blocks path traversal like ../../etc/shadow. |
| 5 | for (...) isalnum ... |
Every character must be in the allowlist [A-Za-z0-9._-]; /, ;, spaces, $ are all rejected. |
| 6 | snprintf(path, sizeof path, "/var/log/%s", ...) |
Builds the full path with a bounded write; the >= sizeof path check catches truncation. |
| 7 | execl("/bin/gzip", "gzip", "-f", path, NULL) |
Runs gzip directly — no /bin/sh, so shell metacharacters have no meaning; absolute path defeats $PATH hijacking. |
| 8 | perror("execl"); return 1; |
execl only returns on failure; we report and exit non-zero. |
Why the vulnerable version fails. In vuln_helper.c, argv[1] flows into snprintf and then into system(), which runs /bin/sh -c "...". The shell interprets ;, |, $(), and backticks, so app.log; id becomes two commands run as root. Separately, gzip (no path) is resolved via $PATH, so a user who controls $PATH can substitute their own gzip. The secure version removes both the shell and the $PATH lookup, and validates input, so there is no place for attacker data to become code.
Trace of ./safe_helper 'app.log; id': argc==2 (ok) → is_safe_name scans app.log; id, hits the space/; → returns 0 → program prints rejected: invalid log name and returns 1. gzip is never launched; id never runs.
| WRONG approach | WHY it is wrong | CORRECTED | How to recognise / prevent |
|---|---|---|---|
user ALL=(ALL) NOPASSWD: /usr/bin/vim |
vim (and less, find, awk, nano -s, git) can spawn a shell — this is effectively root for user. |
Grant only non-shell commands, e.g. a specific systemctl unit action. |
Cross-check every sudo-granted binary against GTFOBins during review. |
Using a wildcard: sudo: /usr/bin/systemctl * |
The * lets the user pass edit, or a unit that runs their command. |
Pin the exact sub-command: /usr/bin/systemctl reload nginx. |
Treat any * in a sudoers line as a red flag. |
Setting SUID to "just make it work": chmod u+s myscript |
Shell scripts and shell-calling binaries inherit root and trust $PATH/env. |
Redesign so root is not needed; if it is, use a small audited C binary with execl + validation, or a narrow sudo rule. |
Run a periodic find / -perm -4000 audit and diff against a known-good baseline. |
Building a command with system("cmd " + userinput) |
Command injection: shell metacharacters in input run as root. | execl/execv with a fixed argv and validated arguments — no shell. |
Grep source for system(, popen(, sh -c; treat each as suspect. |
Calling a helper by name (system("gzip ...")) |
$PATH hijack: attacker's gzip runs first. |
Absolute path /bin/gzip; reset PATH for privileged processes. |
Look for bare program names in privileged code. |
Defaults env_keep += "LD_PRELOAD" to fix a script |
Re-enables loading an attacker library into every sudo process. | Keep env_reset (the default); fix the script's real dependency instead. |
Audit env_keep entries; LD_* should never be kept. |
Enumeration gives nothing / errors.
sudo -l prompts for a password you do not have: you can still list rules only if a NOPASSWD entry applies; otherwise note it as "unknown" rather than assuming there is nothing.find / -perm -4000 floods with Permission denied: append 2>/dev/null. Missing hits usually means you are on a hardened host — that is a good sign, not a bug.The secure helper misbehaves.
execl: No such file or directory → gzip is not at /bin/gzip on this distro. Confirm with command -v gzip and use the real absolute path; do not fall back to a bare name.system(). Grep the source for system( / popen(.Questions to ask when an escalation "shouldn't" work but does (or vice-versa):
id -u / add a debug print)? SUID sets euid, not necessarily ruid.$PATH or an LD_* variable influencing which code runs?visudo accept the rule, or is a syntax error making sudo ignore your intended (tighter) line and fall through to a broader one?Security & safety — detection and logging for sudo / SUID abuse.
Escalation is only "silent" if you let it be. Configure logging so that both legitimate and abusive privileged actions leave a trail.
What to log (per privileged action):
Where it comes from: sudo already logs to the auth log (/var/log/auth.log or journalctl _COMM=sudo). For stronger coverage, add auditd rules: watch execve, and add a watch on SUID files so any exec of them is recorded. sudo's I/O logging (log_input/log_output) can capture full sessions for high-risk rules.
What to NEVER log: passwords or the contents of /etc/shadow, private keys, session tokens or cookies, API keys, full payment card numbers, or unrelated PII. If your helper handles a secret, log that a secret was used, never its value. Redact before writing.
Events that signal abuse:
sudo call to a GTFOBins-listed tool (find, vim, less, awk, python, tar) — especially with -exec, !, or -c.find / -perm -4000 diff).sudo invocations with LD_PRELOAD / LD_LIBRARY_PATH set.command not allowed / auth failures from one user — possible enumeration.False positives are real: backup and deploy automation legitimately uses sudo and sometimes find/tar. Baseline normal activity first, alert on deviations, and tune. An alert is a prompt to investigate, not proof of an attack.
Authorised use case. During a scoped internal penetration test, a tester lands an unprivileged shell on a Linux app server. Following the enumeration-first mindset, they run sudo -l and find / -perm -4000, discover NOPASSWD: /usr/bin/find, and (in scope, on the client's system) demonstrate a root shell via find's -exec. The value to the client is the report: the exact rule, a safe reproduction, the impact (full host compromise), and a precise fix — replace the rule with a narrow, non-shell command or move the backup job to a dedicated service account.
Best-practice habits (defenders):
env_reset, use absolute paths, edit sudoers only via visudo.| Beginner | Advanced | |
|---|---|---|
| sudo | Read sudo -l; spot wildcards/NOPASSWD |
Design tight sudoers.d policy; per-command env_keep review; I/O logging on risky rules |
| SUID | List with find / -perm -4000; remove obvious extras |
Baseline + automated drift detection; audit custom binaries' system/exec/path handling |
| Detection | Know where sudo logs | auditd execve/SUID watches, SIEM alerts, false-positive tuning |
All tasks are lab-only — run them on a VM or container you own, or an intentionally-vulnerable image (e.g. a local practice box). Each ends by remediating and verifying, not just exploiting.
Authorization checklist (before any task): (1) I own or am explicitly authorised to test this host. (2) It is isolated (local VM/container, no production data, snapshot taken). (3) I have a reset/cleanup plan. If any is "no", stop.
Enumerate and classify. Objective: practise reading enumeration output. Requirements: on your lab VM, run sudo -l and find / -perm -4000 -type f 2>/dev/null. For each result, write one line: is it dangerous, and why (cross-check GTFOBins)? Output: a short table (binary → verdict → reason). Hint: look for wildcards, NOPASSWD, and shell-capable tools. Concepts: enumeration, GTFOBins, trust boundary.
Read a sudoers rule. Objective: tell safe from unsafe rules. Requirements: given deploy ALL=(ALL) NOPASSWD: /usr/bin/less and deploy ALL=(www-data) /usr/bin/systemctl reload nginx, explain which is abusable and rewrite the unsafe one to be least-privilege. Constraints: no wildcards; name the target user explicitly. Concepts: least privilege, GTFOBins.
Harden a SUID finding. Objective: close a SUID hole and prove it. Requirements: in the lab, set the SUID bit on a copy of a harmless tool, confirm it shows rws, then remove it with chmod u-s and confirm it now shows rwx. Write a find / -perm -4000 before/after diff. Constraints: do not modify system-critical SUID binaries (sudo, passwd). Hint: work on a copy in /tmp. Concepts: SUID audit, verification. Defensive conclusion: record the removal and re-run the audit to confirm.
Fix a command-injection SUID helper. Objective: apply the insecure→secure→verify cycle. Requirements: start from the lesson's vuln_helper.c, rewrite it to use execl with an absolute path and allowlist validation, then show that app.log; id and ../../etc/shadow are rejected while app.log is accepted. Input/Output: malicious input → rejected:; valid input → success. Concepts: input validation, no-shell exec, mitigation verification.
Cleanup / reset (all tasks): remove any test SUID bits (chmod u-s), delete /tmp test binaries and /var/log/app.log, restore /etc/sudoers.d test files, and revert the VM to your pre-lab snapshot.
Main concepts. Two misconfigurations dominate Linux privilege escalation, and both share one cause — a program runs with root authority on a user's behalf and can do more than intended: (1) over-broad sudo rules, and (2) risky SUID binaries. The recurring shape is a "normal" tool with extra power (shell escape, arbitrary file read, command execution), cataloged by GTFOBins.
Key commands. Enumerate with sudo -l and find / -perm -4000 -type f 2>/dev/null. Harden sudo with narrow, wildcard-free rules edited via visudo, keeping env_reset. Remove needless SUID bits with chmod u-s.
Common mistakes. Granting shell-capable tools via sudo; wildcards in sudoers; setting SUID to "make it work"; building shell commands from user input (system(...)); bare program names that allow $PATH hijack; keeping LD_PRELOAD in the environment.
What to remember. Least privilege beats clever code — prefer no SUID and the narrowest sudo grant. When a privileged binary must exist, validate input with an allowlist, use execl with an absolute path (no shell), and verify the fix rejects bad input and accepts good input. Log the security decision, never the secret. Passing a scanner does not prove security, and nothing is ever "completely secure" — test only what you own or are authorised to test, in an isolated lab, and reset afterward.