Privilege Escalation · intermediate · ~11 min
## What you will learn - Explain what Linux **file capabilities** are, and identify which ones (like `cap_setuid` and `cap_dac_read_search`) are effectively root. - Enumerate capabilities, writable systemd units, and dangerous group memberships in an **authorized lab**, and reason about why each grants root. - Understand why membership in the **docker**, **lxd**, or **lxc** groups is equivalent to being root, with no exploit involved. - Apply concrete **remediations**: trim unneeded capabilities, lock down unit-file ownership, and treat container-management groups as privileged. - **Verify** each fix by re-running the enumeration and confirming the escalation path is closed. - Know what to **log and detect** so these silent misconfigurations show up in a real audit.
Security objective. The asset you are protecting is root on the Linux host: full control of every file, process, and account. The threat is a local, low-privilege attacker (a compromised web-app service account, a limited shell, a curious employee) who already has some access and wants to become root. You will learn to detect and prevent three misconfigurations that hand over root without any memory bug, payload, or exploit: over-broad file capabilities, user-writable systemd service files, and membership in the docker/lxd/lxc groups.
This lesson builds directly on your prerequisite, Linux privesc: sudo and SUID binaries. There you learned that a SUID-root binary runs with root's identity, and that sudo rules can be too generous. Capabilities are the modern, finer-grained cousin of SUID: instead of "all of root or none," the kernel splits root's power into discrete units. That is safer in principle, but a single wrong capability on the wrong binary is still game over. Writable service files are the Linux mirror of weak-permission problems you will see again in the next lesson, Windows privesc: service misconfigurations.
The theme is the same across all three: none of these is a "vulnerability" in the classic sense. They are configuration mistakes that look ordinary until you know what to look for. Everything here is practiced only on machines you own or are explicitly authorized to test.
In authorized penetration tests and red-team engagements, these three findings are among the most common ways a foothold becomes a full compromise. An attacker rarely needs a zero-day; they need one binary with cap_setuid, one world-writable .service file, or one service account that someone quietly added to the docker group for convenience.
They matter professionally because:
For a defender or blue-teamer, knowing these paths is what lets you write the right detection rules and harden the build image before an attacker or auditor finds them.
Definition. A capability is one discrete slice of the superuser's power. Traditional Unix was all-or-nothing: UID 0 could do everything, everyone else was checked. Linux capabilities break root's authority into ~40 named units (for example, CAP_NET_BIND_SERVICE to bind low ports, CAP_SETUID to change user IDs).
Plain explanation. The idea is least privilege: a program that only needs to bind port 80 should get CAP_NET_BIND_SERVICE and nothing else, instead of running as full root. Good in theory. The problem is that some capabilities are so powerful they are indistinguishable from root.
How it works. Capabilities can be attached to a file (a "file capability") so the binary gains them when executed. You read them with getcap. The two that matter most for escalation:
| Capability | What it grants | Why it is root-equivalent |
|---|---|---|
cap_setuid |
Change the process's user ID | The program can set its UID to 0 and become root directly |
cap_dac_read_search |
Bypass file read/execute permission checks | Can read any file, including /etc/shadow (password hashes), leading to offline cracking or direct compromise |
cap_dac_override |
Bypass all file permission checks (read and write) | Can overwrite any file, including /etc/passwd |
When / when-not. Capabilities are the right tool when a service needs one specific privilege. They are a misconfiguration when a general-purpose interpreter (python3, perl, ruby) or a copy of one carries cap_setuid — an interpreter that can change its UID can run arbitrary code as root.
Pitfall. People assume "it's a capability, not SUID, so it's safe." A dangerous capability on a scriptable binary is just as fatal as SUID-root.
Definition. systemd is the init system that starts and manages services; it runs as PID 1, as root. A unit file (something.service) tells it what command to run.
Plain explanation. Because systemd runs as root, whatever a unit file's ExecStart= points to also runs as root when the service starts. If a low-privilege user can edit the unit file, or replace the binary/script it launches, they choose the command that runs as root.
How it works. The attacker edits ExecStart= (or a writable target script) to run their own command, then triggers a restart (or waits for a reboot or timer). Next start, their command runs as root.
When / when-not. Unit files legitimately live in /etc/systemd/system and /usr/lib/systemd/system and should be root-owned, mode 644. It becomes a finding when a unit file, its directory, or the executable it references is writable by a non-root user or group.
Pitfall. The unit file itself may be locked down, but ExecStart=/opt/app/run.sh points at a script that is user-writable. You must check the whole chain.
Definition. The docker group grants access to the Docker daemon socket. Docker's daemon runs as root, so anyone who can talk to it can ask it to do root things.
Plain explanation. A docker group member can start a container that mounts the host's filesystem and then read or modify it as root from inside the container. There is no exploit — the group membership is the privilege.
How it works. Mounting the host root (/) into a container and chroot-ing into it gives a root shell over the real host filesystem: read /etc/shadow, add a user, drop a SUID binary, etc. The lxd and lxc groups give equivalent power through the LXD container manager.
When / when-not. These groups exist so developers can manage containers without typing sudo constantly. That convenience is exactly the risk on any multi-user or production host.
Pitfall. Treating docker as "just a normal group." Adding a service account to it to "let the app manage containers" silently makes that account root-equivalent.
LINUX HOST (asset: root / whole system)
+--------------------------------------------------------------------+
| TRUST BOUNDARY: unprivileged user --> root (UID 0) |
| |
| Entry point (attacker already here): low-priv shell / service |
| account (e.g. www-data) after initial foothold |
| |
| Path A: file capability |
| [ /usr/bin/python3 +cap_setuid ] --setuid(0)--> ROOT |
| |
| Path B: writable systemd unit |
| [ user-writable app.service / ExecStart script ] |
| --restart--> systemd (root) runs attacker cmd --> ROOT |
| |
| Path C: docker group |
| [ user in 'docker' ] --run -v /:/host--> container --> ROOT |
| (host FS mounted rw) |
+--------------------------------------------------------------------+
Detection surface: auth logs, journald, docker events, file audit
Knowledge check.
cap_setuid on python3 fatal — what did the admin believe was safe?docker group member mounted the host filesystem?The core enumeration commands. All are read-only and lab-safe; run them on a host you own.
# List file capabilities across the whole filesystem.
# -r = recurse; 2>/dev/null hides permission-denied noise.
getcap -r / 2>/dev/null
# example line -> /usr/bin/python3.11 cap_setuid=ep
# ^ binary ^ capability ^ e=effective p=permitted
# Show which groups the current user belongs to (look for docker/lxd/lxc).
id
groups
# Find systemd unit files writable by group or others (potential finding).
# -perm -o+w = world-writable; -perm -g+w = group-writable.
find /etc/systemd/system /usr/lib/systemd/system -type f \
\( -perm -o+w -o -perm -g+w \) -ls 2>/dev/null
# Inspect what a specific unit actually runs (check the ExecStart target too).
systemctl cat app.service
Annotated capability suffix: in cap_setuid=ep, the letters after = are the capability sets the file grants — e (effective, active immediately), p (permitted, allowed to raise), i (inheritable). ep on a scriptable binary is the dangerous case.
Beyond sudo, SUID binaries, and cron jobs, a few powerful misconfigurations round out Linux privilege escalation.
A capability is one slice of root's power. Splitting root into separate units lets a binary hold some root abilities without being fully SUID-root.
A few capabilities are escalation-equivalent on their own:
cap_setuid lets a program set its own user ID to 0 (root).cap_dac_read_search lets a program read any file, including /etc/shadow (the file that stores password hashes).Enumerate capabilities across the whole filesystem:
getcap -r / 2>/dev/null
A python3 or custom binary carrying cap_setuid is instant root.
systemd services run as root.
If a low-privilege user can write a unit file (or a binary or ExecStart script that a unit runs), they gain root the next time the service starts or restarts.
This is the Linux sibling of the Windows weak-service-permissions problem.
Membership in the docker group is effectively root. A member can launch a container that mounts the host filesystem and then read or write it as root:
docker run -v /:/host -it alpine chroot /host sh
There is no exploit here. The group is the privilege. The same applies to the lxd and lxc groups.
The example is a capabilities walkthrough because it is the most subtle of the three. Shape: insecure setup, secure fix, verification. Run only in a disposable VM or container you own.
# LAB SETUP (root, throwaway VM): create a dangerous file capability.
# We copy python3 so we never touch the system interpreter.
cp "$(command -v python3)" /tmp/pypriv
setcap cap_setuid+ep /tmp/pypriv # <-- the misconfiguration
# Confirm the capability is attached.
getcap /tmp/pypriv
# /tmp/pypriv cap_setuid=ep
# ATTACKER (as a normal, non-root user in the SAME lab):
# Because the binary can setuid(0), it can drop a root shell.
/tmp/pypriv -c 'import os; os.setuid(0); os.system("id")'
# expected: uid=0(root) gid=... groups=... <-- escalation achieved
Why it works: cap_setuid lets the process call setuid(0) and become root; because python3 runs arbitrary code, the attacker turns that one capability into a full root shell.
# Remove the capability from the binary.
setcap -r /tmp/pypriv # or: setcap '' /tmp/pypriv
# Better in real systems: delete the stray copy entirely and grant
# only the *minimal* capability to a purpose-built binary, e.g. a
# network daemon that only needs to bind low ports:
# setcap cap_net_bind_service+ep /usr/local/bin/mydaemon
# 3a. The capability must be gone.
getcap /tmp/pypriv
# (no output) -> PASS: no capability attached
# 3b. The escalation must now FAIL for a non-root user.
/tmp/pypriv -c 'import os; os.setuid(0); os.system("id")'
# PermissionError: [Errno 1] Operation not permitted -> PASS: rejected
# 3c. A legitimate, non-root use of python still works (accepts good input).
/tmp/pypriv -c 'print(2 + 2)'
# 4 -> PASS: normal functionality intact
# 3d. Whole-host re-enumeration shows no dangerous caps remain.
getcap -r / 2>/dev/null | grep -Ei 'cap_setuid|cap_dac' || echo 'clean'
# clean
rm -f /tmp/pypriv
# For docker-group experiments, remove test containers:
# docker ps -a --filter name=lab- -q | xargs -r docker rm -f
# Then discard the VM snapshot.
Expected outcome. Step 1 yields a root shell (the vulnerability). After step 2, step 3b fails with Operation not permitted while step 3c still prints 4. That contrast — bad path rejected, good path accepted — is the proof the remediation worked.
Walking through the capability example:
cp "$(command -v python3)" /tmp/pypriv — copies the interpreter so the lab never modifies the real system binary. Everything dangerous stays on the throwaway copy.setcap cap_setuid+ep /tmp/pypriv — attaches cap_setuid in the effective (e) and permitted (p) sets. This is the deliberate misconfiguration; from now on, running /tmp/pypriv grants the ability to call setuid.getcap /tmp/pypriv → /tmp/pypriv cap_setuid=ep — confirms the attachment. This is exactly what an auditor's getcap -r / would surface./tmp/pypriv -c 'import os; os.setuid(0); os.system("id")' — as a non-root user, os.setuid(0) succeeds because the capability permits it. id then reports uid=0(root): the process is now root.setcap -r /tmp/pypriv — strips all capabilities. The binary reverts to an ordinary, unprivileged copy of python.getcap prints nothing: no capability remains.PermissionError: Operation not permitted — the kernel denies setuid(0) because the process no longer holds cap_setuid. The attack path is closed.print(2 + 2) → 4 confirms we did not break legitimate use; we removed only the excess privilege.How the key value changes:
| Step | Binary capability | Non-root setuid(0) result |
|---|---|---|
After setcap ...+ep |
cap_setuid=ep |
succeeds → uid 0 (root) |
After setcap -r |
none | fails → Operation not permitted |
The single controlling variable is whether the file carries cap_setuid. Removing it flips the outcome from "root" to "denied," which is the whole lesson: the privilege lived in the configuration, not in any code flaw.
Mistake 1 — "Capabilities are safer than SUID, so any capability is fine."
Why wrong: safety comes from granting the minimum capability, not from the mechanism. cap_setuid, cap_dac_override, cap_dac_read_search, and cap_sys_admin are effectively root. Corrected: match each binary to the one narrow capability it needs (e.g. cap_net_bind_service) and never place a root-equivalent capability on a scriptable interpreter. Recognize/prevent: audit with getcap -r / and treat any cap_setuid/cap_dac_*/cap_sys_admin as a finding.
Mistake 2 — Locking the unit file but not its ExecStart target.
Why wrong: systemd runs the target as root; a locked-down .service that calls a user-writable /opt/app/run.sh is still fully exploitable. Corrected: audit the whole chain — unit file, its directory, and every path in ExecStart/ExecStartPre — for non-root write access. Recognize/prevent: systemctl cat unit.service, then check ownership and mode of each referenced file.
Mistake 3 — Adding a service account to the docker group "just so the app can build images."
Why wrong: that account is now root-equivalent; if the app is compromised, the attacker owns the host. Corrected: use rootless Docker, a socket-proxy with a restricted API, or a dedicated build host; never put production service accounts in docker/lxd/lxc. Recognize/prevent: getent group docker lxd lxc and review every member.
Mistake 4 — Believing a clean vulnerability-scanner report means these are absent.
Why wrong: passing an automated scan does NOT prove a system is secure; most scanners do not flag over-permissive capabilities or docker-group membership because nothing is technically "vulnerable." Corrected: run explicit configuration audits (getcap, group review, unit-file permission checks) as a separate step. Never claim a host is "completely secure" — claim only that these specific paths were checked and closed.
When enumeration or a fix does not behave as expected:
getcap -r / prints nothing at all. Confirm libcap tools are installed (command -v getcap) and that you are running as a user allowed to read the tree; add 2>/dev/null to hide permission-denied noise, but remember denied directories may hide capabilities — re-run as root during an authorized audit.getcap /tmp/pypriv must show cap_setuid=ep, not just +i). Inheritable-only (i) does not grant power on exec. Also confirm you are testing as a non-root user; running as root proves nothing.setuid(0) raises Operation not permitted when you expected success. The kernel may be enforcing NoNewPrivileges, or a security module (AppArmor/SELinux) is blocking it — check dmesg and journalctl -k for denials.systemctl daemon-reload, so systemd is still using the cached unit. In a real audit, note that an attacker who can also trigger reload/restart is required for the path to be live.id should list docker).Questions to ask when a path fails: Am I truly unprivileged right now? Is the capability in the effective/permitted set or only inheritable? Does the whole ExecStart chain include a writable file? Is a MAC policy (SELinux/AppArmor) silently blocking me, and is that logged?
These escalations are quiet, so detection depends on logging the right events.
What to log
setcap/chcon/chmod on binaries via auditd (a watch on /usr/bin, /usr/local/bin). Record timestamp, source user/UID, the resource (file path + capability), the result, the security decision (allowed/denied), and a correlation id tying it to the session.setuid/execve of capability-bearing binaries; a non-root process becoming UID 0 is a high-signal event.daemon-reload or edits to files under /etc/systemd/system (pair with an auditd watch on that directory).docker events and the daemon log capture container creation; alert on containers that bind-mount host paths (-v /:... or /etc, /root) — a strong abuse signal./etc/group; additions to docker, lxd, lxc, sudo should page someone.What to NEVER log
Passwords, password hashes from /etc/shadow, private keys, API tokens, session cookies, and unneeded PII. If a command line might contain a secret (a token passed as an argument), redact it rather than storing it verbatim.
Events that signal abuse
A non-root user running a capability binary right before a UID-0 process appears; a host filesystem bind-mounted into a fresh container; an unexpected write to a .service file followed by a restart; a new member of docker/lxd.
How false positives arise
Legitimate deployments also run daemon-reload, and CI systems legitimately bind-mount build directories (though rarely /). Baseline normal behavior (which service accounts deploy, which mounts CI uses) so alerts fire on deviation, not on routine operations. Tune thresholds and maintain an allowlist of known-good automation identities.
Authorized real-world use case. During a scoped internal penetration test, a tester gains a low-privilege www-data shell through a web-app flaw. Running getcap -r / 2>/dev/null reveals a backup helper with cap_dac_read_search, letting them read /etc/shadow; id shows the deploy account is in the docker group. Both are documented as findings, demonstrated safely once each, and reported to the client with remediation and retest steps. The engagement letter authorized exactly this host and this activity.
Best-practice habits
| Habit | Beginner | Advanced |
|---|---|---|
| Least privilege | Never grant cap_setuid/cap_dac_*; prefer no capability |
Build purpose-specific binaries with a single narrow capability; use NoNewPrivileges=yes and capability bounding sets in unit files |
| Secure defaults | Keep unit files root-owned, mode 644 | Deploy immutable/ProtectSystem=strict services and read-only container images |
| Validation / audit | Run getcap -r / and review docker/lxd group members regularly |
Automate config drift detection in CI/CD and image builds; fail the pipeline on new dangerous caps |
| Least access to Docker | Do not add users to docker casually |
Use rootless Docker or a socket proxy exposing a restricted API |
| Logging / error handling | Enable auditd watches on binary dirs and /etc/group |
Correlate capability use, container mounts, and privilege transitions into SIEM alerts |
The defensive posture is always: minimize privilege, set safe defaults, verify with enumeration, and log enough to detect abuse — then retest after every fix.
All tasks are lab-only: run them on a disposable VM, container, or an intentionally vulnerable image you own or are explicitly authorized to use (localhost/CTF). Each ends with remediation and verification.
Authorization checklist (before any task): (1) I own or have written authorization for this host. (2) It is isolated (no production data, no external network dependencies). (3) I have a snapshot to restore. (4) I will clean up test artifacts afterward.
Objective: list every file capability on a lab VM and flag the dangerous ones.
Requirements: run getcap -r / 2>/dev/null; classify each result as benign or root-equivalent.
Output: a short table of path → capability → risk.
Constraints: read-only; do not modify anything.
Hints: focus on cap_setuid, cap_dac_override, cap_dac_read_search, cap_sys_admin.
Concepts: capabilities, least privilege.
Defensive conclusion: for each risky entry, write the remediation (setcap -r) you would apply.
Objective: determine whether the current user is root-equivalent by group membership.
Requirements: run id and getent group docker lxd lxc; state whether membership grants root and why.
Output: a one-paragraph justification referencing the host-mount technique (describe, do not exploit here).
Constraints: enumeration only.
Hints: membership alone is the privilege.
Concepts: docker/lxd/lxc groups, trust boundaries.
Defensive conclusion: propose the group-removal command and who should approve it.
Objective: reproduce the cap_setuid misconfiguration on a copied binary, then close it.
Requirements: copy python, setcap cap_setuid+ep, confirm escalation as a non-root user, remove the capability, and re-test.
Input/Output: escalation returns uid=0 before the fix; Operation not permitted after.
Constraints: only the copy in /tmp; never the system interpreter.
Hints: use the VERIFY steps from the lesson; prove good use (print(2+2)) still works.
Concepts: effective/permitted sets, mitigation verification.
Defensive conclusion: add an auditd watch that would have detected the setcap.
Objective: find and fix a writable systemd ExecStart target.
Requirements: in a lab, create a unit whose ExecStart points to a group-writable script; detect it with a find permission scan and systemctl cat; then make the script root-owned, mode 755.
Input/Output: before — find reports a writable target; after — no writable files in the chain.
Constraints: lab service only; disable it afterward.
Hints: check both the .service file and the referenced script.
Concepts: systemd runs as root, whole-chain review.
Defensive conclusion: verify by re-running the scan and confirming the fix; note what journald/auditd would log.
Objective: produce a reusable audit script and detection plan for these three paths.
Requirements: a read-only shell script that runs the three enumerations (getcap, group check, unit-permission find) and prints a pass/fail summary; plus a short list of auditd/journald/docker events rules that would detect abuse.
Constraints: the script must not modify the system; no real hostnames or secrets (use placeholders).
Hints: exit non-zero if any dangerous cap or writable unit is found so CI can gate on it.
Concepts: config drift detection, secure defaults, logging.
Defensive conclusion: document how you would retest after remediation and how to avoid false positives from legitimate automation.
Main concepts. Three exploit-free Linux privilege escalations share one root cause — over-permissive configuration, not a code bug. (1) A file capability like cap_setuid or cap_dac_read_search on the wrong binary is root-equivalent. (2) A writable systemd unit (or its ExecStart target) runs the attacker's command as root on restart. (3) Membership in the docker/lxd/lxc groups lets a user mount the host filesystem and act as root.
Key commands. getcap -r / 2>/dev/null to enumerate capabilities; setcap -r <file> to remove them; id / getent group docker lxd lxc for group membership; a find ... -perm -g+w -o -perm -o+w scan plus systemctl cat for unit files.
Common mistakes. Trusting the capability mechanism instead of minimizing the specific capability; locking the unit file but not its ExecStart script; adding service accounts to docker for convenience; and believing a clean scanner report means these are absent — it does not.
What to remember. For every finding: identify the insecure assumption, apply the minimal fix, then verify that the bad path is now rejected while legitimate use still works — and log capability use, unit changes, group changes, and host-mount container starts so abuse is detectable. Only ever practice on systems you own or are authorized to test, and never claim a host is "completely secure."