How-To

Linux Privilege Escalation Hardening: A Step-by-Step Guide to SUID, Capabilities, Sudoers, and Containers

19 min read

Almost every real-world Linux compromise follows the same script. An attacker lands a low-privilege shell through a vulnerable app, a leaked credential, or a phished session. Then they spend ten minutes hunting for a way to become root. That second act is privilege escalation. It’s rarely a fancy zero-day. It’s usually something dumb: a leftover SUID bit, an over-granted file capability, a sloppy sudoers rule, or a container running with more power than it needs. This guide turns Linux privilege escalation hardening into a repeatable audit-and-fix checklist. It covers SUID/SGID binaries, file capabilities, sudoers, and container privileges, in the order you should tackle them on a live server.

This is written for the person who has to fix the server, not break into it. Every command here is defensive. You’ll run the same enumeration tricks an attacker’s tooling would use. The point is to find and close the gaps first.

What Is Linux Privilege Escalation Hardening?

Privilege escalation turns limited access into root, or root-equivalent control. That limited access might be a regular user account, a web app’s service account, or a container’s process. On Linux, four escalation paths show up most often:

  • SUID/SGID binaries: programs that run with the file owner’s (or group’s) privileges, not the caller’s. This happens no matter who launches them.
  • File capabilities: a finer-grained alternative to SUID. It grants a binary specific root-level powers, like binding to port 443 or changing its own UID, without making it fully root.
  • Sudoers misconfigurations: /etc/sudoers rules that grant more than needed. A permitted command can then be abused to spawn a root shell.
  • Over-privileged containers: containers launched with --privileged or a generous default capability set. A compromised app inside can then reach the host.

Two tools come up again and again. It’s worth knowing them by name. GTFOBins is a community-maintained catalog of standard Unix binaries, find, vim, python3, awk, and dozens more. It shows exactly how each one can be abused if it’s SUID, sudo-permitted, or capability-granted. LinPEAS is an automated enumeration script. It checks a system for SUID files, capabilities, sudo rules, writable cron jobs, and dozens of other misconfigurations in one pass. Both are built as offensive tools. Here, they’re strictly verification aids: run the audit, find what GTFOBins or LinPEAS flags, and close it.

Prerequisites

Make sure you have the following before starting:

  • A Linux server you own or are authorized to administer: Debian/Ubuntu, RHEL/Rocky/CentOS, or similar (examples below use Ubuntu 24.04 LTS)
  • Root or sudo access on that server
  • SSH access from your workstation. This guide assumes macOS with the built-in Terminal app. Windows Server admins can follow along using PowerShell’s built-in OpenSSH client; the commands run on the Linux side are the same.
  • libcap2-bin (Debian/Ubuntu) or equivalent installed for getcap/setcap
  • auditd installed if you’re doing the ongoing-verification section
  • Docker Engine or Podman installed if you’re doing the container-hardening section
  • A non-production server or VM to test on first, since some of these changes (especially SUID removal) can break things if applied blindly

Warning: Test SUID, capability, and sudoers changes on a staging box or VM snapshot before touching a production server. Removing the wrong bit can lock out legitimate workflows like password changes or mount operations.

Step-by-Step Guide

Step 1: Understand the Order of Operations

Do these in sequence, not randomly. SUID/SGID comes first. It’s the oldest and most commonly abused mechanism. Capabilities come next. They often hide in plain sight because admins forget they exist. Then sudoers, the most common source of “convenient” over-privilege. Then containers, where a single mistake has the biggest blast radius. Finally, ongoing monitoring so none of it quietly regresses. Each step builds context for the next one. You can’t correctly harden sudoers without knowing which binaries on the box are SUID. A sudo rule that allows one of them is a much bigger problem than it looks.

Step 2: Connect to Your Server

From a Mac, open Terminal (or iTerm2). No extra software needed since macOS ships with an SSH client.

ssh your-username@your-server-ip

You should land at a normal shell prompt on the target server. Everything from here runs on the Linux server, not on your Mac.

macOS desktop with Terminal.app open and an active SSH session connected to the Linux server, prompt showing the remote hostname

Confirm you have the packages this guide relies on:

sudo apt update && sudo apt install -y libcap2-bin auditd

Reading package lists… Done
libcap2-bin is already the newest version (1:2.66-5ubuntu2.2).
auditd is already the newest version (1:3.1.2-2.1build1).

On RHEL/Rocky, the equivalent is sudo dnf install libcap audit.

Step 3: Audit SUID and SGID Binaries

SUID (4000) and SGID (2000) bits let a binary run as its owning user or group, not the caller. That’s normal for a handful of system tools (passwd, mount) but a liability everywhere else. Find every SUID binary:

find / -xdev -perm -4000 -type f 

/usr/bin/passwd
/usr/bin/mount
/usr/bin/umount
/usr/bin/su
/usr/bin/sudo
/usr/bin/gpasswd
/usr/bin/newgrp
/usr/lib/openssh/ssh-keysign

And SGID:

find / -xdev -perm -2000 -type f 

/usr/bin/wall
/usr/bin/write
/usr/bin/ssh-agent
/sbin/unix_chkpwd

-xdev keeps the search on the current filesystem. It won’t wander into /proc or mounted network shares and take forever. Drop temporarily if you want to see permission-denied noise. For a clean list, keep it.

Terminal output of `find / -xdev -perm -4000 -type f ` listing SUID binaries on an Ubuntu 24.04 server

Tip: Save this output to a file (find / -xdev -perm -4000 -type f > /tmp/suid-baseline.txt) so you have a baseline to diff against later.

Step 4: Cross-Check Findings Against GTFOBins

A SUID bit on /usr/bin/passwd is expected and safe. It’s how unprivileged users change their own password. A SUID bit on /usr/bin/find, /usr/bin/python3, or /usr/bin/vim is a red flag. For every binary in your list that isn’t a well-known system tool, look it up on GTFOBins.

GTFOBins homepage at gtfobins.github.io showing the searchable list of Unix binaries with function tags including SUID, Sudo, and Capabilities

Search for the binary name and open its entry page. Each page has a SUID section showing the exact command that would exploit that bit if present.

GTFOBins entry page for the find binary at gtfobins.github.io/gtfobins/find showing the SUID and Sudo exploitation sections

For example, GTFOBins shows that a SUID bit on find lets any local user spawn a root shell with a single -exec call. Say find shows up in your find / -perm -4000 output, and nothing on the server depends on that bit. It almost never does. That’s an immediate fix candidate.

Step 5: Strip Unnecessary SUID/SGID Bits

For anything flagged as dangerous and not required for the server’s role, remove the bit:

sudo chmod u-s /usr/bin/find      # removes SUID
sudo chmod g-s /usr/bin/wall      # removes SGID, if genuinely unneeded

Verify it’s gone:

ls -l /usr/bin/find

-rwxr-xr-x 1 root root 375224 Mar 3 2026 /usr/bin/find

No more s in the owner execute position; compare against the earlier -rwsr-xr-x.

Warning: Don’t strip SUID from passwd, mount, umount, su, sudo, gpasswd, newgrp, or ssh-keysign. Normal, non-root users need that bit to do things they’re legitimately allowed to do. If in doubt, check what the binary does before removing anything. Test the change on a non-production box first.

Step 6: Audit Linux Capabilities

Capabilities split root’s power into distinct units (see capabilities(7)). A binary can get exactly the piece of root it needs, cap_net_bind_service to bind low ports, for instance, without full SUID root. That’s the good version. The bad version is a capability like cap_setuid handed to something generic. That’s functionally equivalent to a root shell. List every file with capabilities set:

getcap -r / 

/usr/bin/ping cap_net_raw=ep
/usr/bin/mtr-packet cap_net_raw=ep
/usr/bin/python3.12 cap_setuid,cap_setgid=eip
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin=ep

Terminal output of `getcap -r / ` showing multiple file capability entries including a cap_setuid grant on python3.12

That python3.12 line is the one that should stop you cold. cap_net_raw on ping is normal and expected. It’s why ping doesn’t need to run as root anymore on modern distros. But cap_setuid,cap_setgid on a full scripting interpreter is a problem. Anyone who can run that interpreter can call os.setuid(0) and become root, no sudo or SUID required. GTFOBins documents this exact pattern under the Capabilities section for python, perl, and similar interpreters.

Step 7: Remove Dangerous Capability Grants

Strip the capability from the binary:

sudo setcap -r /usr/bin/python3.12

Confirm it’s gone:

getcap /usr/bin/python3.12

(no output — no capabilities set)

If a specific service legitimately needs a capability, like a web server binding port 443, grant only that one capability. Don’t leave a broad or unexplained set:

sudo setcap cap_net_bind_service=+ep /usr/bin/my-app

Tip: If you’re not sure why a capability is there, check which package owns the binary first (dpkg -S /usr/bin/python3.12 on Debian/Ubuntu, rpm -qf /usr/bin/python3.12 on RHEL). Some capability grants come from a package’s post-install script. They get silently reapplied on the next upgrade unless you fix the root cause, often a setup script or container base image.

Step 8: Harden /etc/sudoers with visudo

Sudoers is the most common source of accidental over-privilege. It’s easy to write a rule that “works” without noticing it also grants a shell escape. Always edit it with visudo. Never use vi, nano, or any other editor directly against the file.

sudo visudo

visudo opens /etc/sudoers in your default editor, but it validates the syntax before saving. A typo can’t leave the file broken and lock out every admin on the box, including you.

Terminal showing visudo open in the default editor with the contents of /etc/sudoers visible, including a NOPASSWD entry

Look for two patterns and fix both:

Unneeded NOPASSWD. This lets a user run a command via sudo without re-entering a password. It’s justified for specific, non-interactive automation, like a deploy script calling systemctl restart, say, but not as a blanket grant.

# Too broad — no password ever required for anything
alice ALL=(ALL) NOPASSWD: ALL

# Scoped — password-free only for a specific restart command
alice ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx

Blanket ALL command grants. Replace them with an explicit list of the exact commands the user actually needs, including arguments where it matters.

# Too broad
bob ALL=(ALL) ALL

# Explicit — bob can restart and check status on the app service, nothing else
bob ALL=(root) /usr/bin/systemctl restart myapp, /usr/bin/systemctl status myapp

Prefer drop-in files under /etc/sudoers.d/ for each team or purpose, instead of piling everything into the main file. It keeps changes isolated and easy to revert:

sudo visudo -f /etc/sudoers.d/deploy-team

Before finalizing any sudo rule, check the exact binary and arguments against GTFOBinsSudo section. Say a rule allows a binary like less, vim, find, or awk with unrestricted arguments. GTFOBins will show you the shell-escape sequence that turns “read this one log file as root” into “get a root shell.”

Step 9: Replace Editor/Pager Sudo Grants with sudoedit

A common but risky pattern is granting sudo access to an editor so a user can modify a specific config file:

# Risky — vim can spawn a shell with :!sh, which then runs as root
alice ALL=(root) /usr/bin/vim /etc/nginx/nginx.conf

Almost every full-screen editor and pager, like vim, nano, less, more, has a shell-escape sequence. GTFOBins documents them for exactly this reason. The fix is sudoedit, also invocable as sudo -e. It lets a user edit a specific file as root through a safe wrapper. It copies the file to a temp location, opens the user’s own unprivileged editor of choice, then writes the result back as root. No root-privileged editor process ever exists.

# /etc/sudoers.d/nginx-admins
alice ALL=(root) sudoedit /etc/nginx/nginx.conf

The user runs:

sudoedit /etc/nginx/nginx.conf

(opens the file in $EDITOR, writes changes back as root on save/exit)

There’s no way to escape to a root shell from inside sudoedit. The editor itself never runs as root.

Step 10: Harden Docker/Podman Containers

Docker’s default capability set already trims a lot from full root. But it’s still broader than most containers need. Start every container from zero capabilities, and add back only what the app requires. Don’t just accept the defaults or reach for --privileged.

docker run -d 
  --name web 
  --cap-drop=ALL 
  --cap-add=NET_BIND_SERVICE 
  -p 443:443 
  nginx:1.27

--cap-drop=ALL removes every Linux capability from the container. --cap-add=NET_BIND_SERVICE adds back only the one capability this image needs, to bind to port 443 as a non-root process.

Terminal showing a docker run command with --cap-drop=ALL and --cap-add=NET_BIND_SERVICE flags, followed by `docker ps` confirming the container started successfully

Check what a running container actually has:

docker inspect web --format '{{.HostConfig.CapAdd}} {{.HostConfig.CapDrop}}'

[NET_BIND_SERVICE] [ALL]

Warning: Never use --privileged for normal workloads. It grants essentially every capability plus direct access to host devices. A compromised process inside the container can then reach the host kernel almost as easily as if it were running outside a container at all. The only defensible use cases are things like nested Docker-in-Docker for CI runners, or specific hardware-access tooling. Even then, try scoped --device and --cap-add flags before reaching for --privileged.

If dropping all capabilities breaks the container, check the logs for a permission denied or operation not permitted error. Identify the specific capability it’s missing, and add back only that one:

docker logs web --tail 20

nginx: [emerg] bind() to 0.0.0.0:443 failed (13: Permission denied)

That specific error is what --cap-add=NET_BIND_SERVICE fixes.

Step 11: Apply Seccomp and AppArmor Profiles

Capabilities control what a process is allowed to do at the privilege level. Seccomp controls which syscalls it can make at all. AppArmor adds mandatory access control on top of that. Docker applies a default seccomp profile automatically, and on Ubuntu/Debian hosts, a default AppArmor profile too. Confirm they’re active; don’t just assume:

docker inspect web --format '{{.AppArmorProfile}}'

docker-default

For services that need tighter restriction than the default, apply a custom seccomp profile:

docker run --rm -d 
  --name restricted-app 
  --cap-drop=ALL 
  --security-opt seccomp=/etc/docker/profiles/custom-seccomp.json 
  --security-opt apparmor=docker-default 
  myapp:1.0

Building a fully custom seccomp profile from scratch is tedious. Docker’s own security documentation includes a default profile. Start from that and trim it further, instead of writing one from nothing.

Tip: Test custom profiles in staging first. An overly strict profile usually doesn’t throw a clear security error. It just makes the app crash or hang, and the cause looks unrelated until you check dmesg or the audit log for denied syscalls.

Step 12: Set Up Ongoing Verification with auditd

A one-time audit only tells you the state of the server today. Add kernel-level audit rules so future privilege changes get logged, not just caught on your next manual pass.

sudo tee /etc/audit/rules.d/privesc.rules > /dev/null <<'EOF'
-w /etc/sudoers -p wa -k sudoers_changes
-w /etc/sudoers.d/ -p wa -k sudoers_changes
-a always,exit -F arch=b64 -S setuid,setgid,setresuid,setresgid -k privesc_syscalls
-a always,exit -F arch=b32 -S setuid,setgid,setresuid,setresgid -k privesc_syscalls
EOF

Load the new rules and confirm they’re active:

sudo augenrules --load
sudo auditctl -l

-w /etc/sudoers -p wa -k sudoers_changes
-w /etc/sudoers.d -p wa -k sudoers_changes
-a always,exit -F arch=b64 -S setuid,setgid,setresuid,setresgid -k privesc_syscalls
-a always,exit -F arch=b32 -S setuid,setgid,setresuid,setresgid -k privesc_syscalls

Terminal output of `sudo auditctl -l` listing active audit rules watching /etc/sudoers and the setuid/setgid syscalls

Query the logs later with:

sudo ausearch -k sudoers_changes
sudo ausearch -k privesc_syscalls

Warning: Scope audit rules narrowly. Watching every execve syscall on a busy server generates enormous log volume fast. It can fill disk space or bury real signal in noise. Stick to specific syscalls and specific paths, like the ones above. Forward logs to a central log server or SIEM if you’re running this across more than a handful of hosts.

Step 13: Schedule Recurring Re-Audits

Configuration drift happens. A package update reapplies a capability. A new hire gets a “temporary” broad sudo rule that never gets narrowed. Re-run the SUID and capability audits on a schedule, and diff against your baseline:

sudo crontab -e

# Weekly SUID/capability drift check, Monday 03:00
0 3 * * 1 find / -xdev -perm -4000 -type f > /tmp/suid-current.txt && diff /tmp/suid-baseline.txt /tmp/suid-current.txt

For a broader check, download LinPEAS and run it against your own server as a periodic validation pass. It covers SUID, capabilities, sudo rules, cron jobs, and writable paths in one script.

curl -L https://github.com/carlospolop/PEASS-ng/releases/latest/download/linpeas.sh -o /tmp/linpeas.sh
chmod +x /tmp/linpeas.sh
PEASS-ng GitHub repository README page at github.com/carlospolop/PEASS-ng showing the project description and linpeas.sh file listing

Run it, review the output, then clean up:

/tmp/linpeas.sh > /tmp/linpeas-output.txt

====================================( Basic information)====================================
OS: Linux server01 6.8.0-generic
[+] Reading /etc/passwd — Interesting groups found

rm -f /tmp/linpeas.sh /tmp/linpeas-output.txt

Warning: Only run LinPEAS (or similar enumeration scripts) against systems you own or are explicitly authorized to test. Delete the script and its output after review. An unexplained linpeas.sh sitting in /tmp looks identical to attacker tooling to anyone doing incident response later, including a future version of you.

Configuration

SettingWhereRecommended valueWhy
NOPASSWD tag/etc/sudoers or /etc/sudoers.d/*Only on specific, non-interactive commandsBlanket NOPASSWD: ALL turns any compromise of that account into instant root
Command scope/etc/sudoersExplicit binary + argument list, not ALLLimits what a compromised script running under that sudo grant can do
Editor/file access/etc/sudoerssudoedit instead of raw editor pathRemoves the shell-escape path that GTFOBins documents for vim, nano, less, etc.
SUID bitFilesystem (chmod u-s)Removed unless the binary requires it for normal usersFewer SUID binaries = fewer local root paths
File capabilitiesFilesystem (setcap)Only the specific capability the app needscap_setuid/cap_setgid on a general-purpose interpreter is root-equivalent
Container capabilitiesdocker run / Compose cap_drop/cap_add--cap-drop=ALL then add back individuallyMatches least privilege instead of accepting Docker’s broader default set
--privilegeddocker runNever for standard workloadsGrants nearly full capabilities plus host device access, defeating container isolation
Seccomp/AppArmor profiledocker run --security-optDefault profile active at minimum, custom profile for sensitive servicesRestricts syscalls and adds MAC confinement beyond capabilities alone
auditd rules/etc/audit/rules.d/*.rulesWatch /etc/sudoers, setuid/setgid syscallsDetects privilege-escalation attempts and config drift going forward

Tips and Troubleshooting

Sudo stops working entirely after editing /etc/sudoers. Why it happens: Someone edited the file directly with vi or nano instead of visudo. A syntax error left the file broken, and now sudo refuses to run for anyone. Fix: If you still have an open root session or console access, run sudo visudo (or visudo -c to check syntax without saving) to find and fix the error. If you’re fully locked out, boot into single-user/recovery mode, or use your hosting provider’s console access to fix /etc/sudoers directly as root, bypassing sudo entirely. Going forward, only edit sudoers files through visudo.

A legitimate app breaks after removing a capability (e.g., a web server can’t bind to port 443). Why it happens: Capabilities like cap_net_bind_service are often the only reason a service doesn’t need to run as root. Stripping them without checking what the app relies on breaks it silently. Fix: Check the service’s error log for a permission-denied message, identify the missing capability, and re-add only that one with setcap: sudo setcap cap_net_bind_service=+ep /usr/bin/my-app.

Container fails to start or crashes after --cap-drop=ALL. Why it happens: Many images, like network tools, anything using ping/traceroute internally, or services binding low ports, depend on one or more of the capabilities Docker grants by default. Fix: Check docker logs <container> for the specific permission denied error, then add back only that capability with --cap-add=<CAPABILITY> rather than reverting to defaults or --privileged.

auditd log volume balloons after adding privilege-escalation rules. Why it happens: Rules scoped too broadly, watching every execve, for example, generate enormous event volume on an active server. Fix: Narrow rules to specific syscalls (setuid, setgid, setresuid, setresgid) and specific paths (/etc/sudoers, known SUID binaries) as shown in Step 12, and forward logs off-box if you’re monitoring more than one server.

A removed SUID bit breaks a normal user workflow (password changes, mounting drives). Why it happens: Some SUID bits (passwd, mount, umount) are there specifically so non-root users can perform actions that genuinely require elevated rights. Fix: Restore the bit (sudo chmod u+s /usr/bin/passwd). Then cross-reference the binary against GTFOBins and its actual system role before removing SUID from anything you didn’t personally verify as unnecessary.

Closing Checklist

Every command from this guide, in order, for a quick re-run or a bookmark-and-repeat audit:

# Connect
ssh your-username@your-server-ip

# Confirm tooling
sudo apt update && sudo apt install -y libcap2-bin auditd

# SUID / SGID audit
find / -xdev -perm -4000 -type f 
find / -xdev -perm -2000 -type f 

# Strip unneeded SUID/SGID bits
sudo chmod u-s /path/to/binary
sudo chmod g-s /path/to/binary

# Capabilities audit
getcap -r / 

# Remove dangerous capabilities
sudo setcap -r /path/to/binary

# Grant only a specific needed capability
sudo setcap cap_net_bind_service=+ep /path/to/binary

# Sudoers hardening (always via visudo)
sudo visudo
sudo visudo -f /etc/sudoers.d/team-name

# sudoedit usage (as the granted user)
sudoedit /etc/nginx/nginx.conf

# Container hardening
docker run -d --name app --cap-drop=ALL --cap-add=NET_BIND_SERVICE -p 443:443 image:tag
docker inspect app --format '{{.HostConfig.CapAdd}} {{.HostConfig.CapDrop}}'
docker inspect app --format '{{.AppArmorProfile}}'
docker logs app --tail 20

# auditd setup
sudo augenrules --load
sudo auditctl -l
sudo ausearch -k sudoers_changes
sudo ausearch -k privesc_syscalls

# Scheduled re-audit (crontab entry)
0 3 * * 1 find / -xdev -perm -4000 -type f  > /tmp/suid-current.txt && diff /tmp/suid-baseline.txt /tmp/suid-current.txt

# LinPEAS one-off validation (delete after review)
curl -L https://github.com/carlospolop/PEASS-ng/releases/latest/download/linpeas.sh -o /tmp/linpeas.sh
chmod +x /tmp/linpeas.sh
/tmp/linpeas.sh > /tmp/linpeas-output.txt
rm -f /tmp/linpeas.sh /tmp/linpeas-output.txt

Wrapping Up

None of this is glamorous work. Nobody throws a party because your sudoers file is clean. But it’s the difference between a contained annoyance and a full root takeover on your watch. If you only have time for one pass this week, do the SUID and sudoers audits first. They’re cheap to check and the most commonly abused in practice.

StepActionApplies To
1Audit SUID/SGID with find, cross-check GTFOBinsAll Linux servers
2Audit capabilities with getcap -r /, strip with setcap -rAll Linux servers
3Harden sudoers via visudo, use sudoeditAny server with named sudo users
4--cap-drop=ALL, add back only what’s needed, never --privilegedDocker/Podman hosts
5Apply seccomp/AppArmor profilesDocker/Podman hosts
6Add auditd rules, schedule re-audits, verify with LinPEASAll Linux servers, ongoing

Once this runs as a recurring check, pair it with a broader CIS Benchmark pass for the rest of your hardening baseline. SUID, capabilities, sudoers, and containers cover the privilege-escalation angle specifically. They’re one piece of a full server-hardening checklist.