Privilege Escalation · intermediate · ~11 min

Linux privesc: capabilities, services, and the docker group

## 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.

Overview

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.

Why it matters

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:

  • They are exploit-free and reliable. No crash, no shellcode, no flaky timing. That makes them the first thing a competent tester checks and the last thing many defenders audit.
  • They hide in plain sight. A capability looks like a hardening feature. Docker-group membership looks like a developer convenience. Automated vulnerability scanners frequently miss them because nothing is "vulnerable" — the config is simply too permissive.
  • The fix is cheap but the miss is catastrophic. Removing a capability or fixing file ownership takes seconds; leaving it means any local compromise escalates to root, which usually means the whole host and often lateral movement to others.

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.

Core concepts

1. Linux capabilities

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.

2. Writable systemd service units

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.

3. The docker / lxd / lxc groups

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.

Threat model

                          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.

  1. What asset is protected in every path above, and where exactly is the trust boundary?
  2. What insecure assumption makes a cap_setuid on python3 fatal — what did the admin believe was safe?
  3. Which logs would let a defender notice that a docker group member mounted the host filesystem?
  4. Why must all of this only be reproduced in an authorized, isolated lab?

Syntax notes

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.

Lesson

Beyond sudo, SUID binaries, and cron jobs, a few powerful misconfigurations round out Linux privilege escalation.

Linux capabilities

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.

Writable service units

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.

The docker group

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 fixes

  • Audit file capabilities and remove any that are not needed.
  • Make service unit files and the binaries they reference root-owned and not user-writable.
  • Treat docker, lxd, and lxc group membership as granting root, and restrict it accordingly.

Code examples

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.

(1) WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

# 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.

(2) SECURE fix — remove the capability (and never grant it to interpreters)

# 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

(3) VERIFY the fix — prove bad input is now REJECTED and good input still works

# 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

Lab cleanup / reset

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.

Line by line

Walking through the capability example:

  1. 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.
  2. 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.
  3. getcap /tmp/pypriv/tmp/pypriv cap_setuid=ep — confirms the attachment. This is exactly what an auditor's getcap -r / would surface.
  4. /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.
  5. setcap -r /tmp/pypriv — strips all capabilities. The binary reverts to an ordinary, unprivileged copy of python.
  6. Verification getcap prints nothing: no capability remains.
  7. Re-running the escalation now raises PermissionError: Operation not permitted — the kernel denies setuid(0) because the process no longer holds cap_setuid. The attack path is closed.
  8. 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.

Common mistakes

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.

Debugging tips

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.
  • The lab escalation does not yield root. Check the capability is actually attached (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.
  • Unit-file edit "does nothing" on restart. You edited the file but did not run 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.
  • Docker command hangs or says permission denied. Confirm group membership took effect — group changes require a new login/session (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?

Memory safety

Security & safety: detection and logging

These escalations are quiet, so detection depends on logging the right events.

What to log

  • Capability and file changes: audit 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.
  • Privilege transitions: log setuid/execve of capability-bearing binaries; a non-root process becoming UID 0 is a high-signal event.
  • systemd changes: journald already records unit reloads/restarts; alert on unexpected daemon-reload or edits to files under /etc/systemd/system (pair with an auditd watch on that directory).
  • Docker activity: 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.
  • Group membership: log changes to /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.

Real-world uses

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.

Practice tasks

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.

Beginner 1 — Enumerate capabilities

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.

Beginner 2 — Spot your dangerous groups

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.

Intermediate 1 — Capability create → detect → remediate → verify

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.

Intermediate 2 — Writable unit chain audit

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.

Challenge — Build a hardening + detection checklist

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.

Summary

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."

Practice with these exercises