Traditionally, a Linux process was either root (allowed to do everything) or not. Capabilities split root’s power into about 40 separate privileges. A program can get just the one it needs, for example permission to bind to port 80, without becoming all-powerful. That’s a simple, effective way to reduce the damage a compromised service can do.
Common capabilities
| Capability | Allows |
|---|---|
| CAP_NET_BIND_SERVICE | Binding to ports below 1024 (80, 443, 53…) |
| CAP_NET_ADMIN | Configuring interfaces, routes, firewall rules |
| CAP_NET_RAW | Raw sockets (ping, packet capture) |
| CAP_SYS_ADMIN | A huge grab bag (mounts and much more), nearly root. Avoid |
| CAP_SYS_PTRACE | Tracing/debugging other processes |
| CAP_CHOWN, CAP_DAC_OVERRIDE | Change ownership; bypass file permission checks |
| CAP_SYS_TIME | Set the system clock |
| CAP_SETUID / CAP_SETGID | Change user/group IDs |
Full list: man 7 capabilities.
Example: a Node.js or Go app on port 443 without root
Option 1: file capabilities on the binary:
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myserver
getcap /usr/local/bin/myserver
# /usr/local/bin/myserver cap_net_bind_service=ep
Now any user running myserver can bind to ports below 1024. Notes:
- Capabilities are lost when the file is replaced (for example on upgrade). Re-apply them.
- Setting it on a shared interpreter (
/usr/bin/node,/usr/bin/python3) gives the capability to every script using that interpreter. Prefer option 2 for interpreters.
Option 2 (recommended): grant it in the systemd unit:
[Service]
User=myapp
Group=myapp
ExecStart=/usr/bin/node /opt/myapp/server.js
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
The service runs as an unprivileged user, with exactly one extra privilege. See systemd unit files.
Alternative: keep the app on a high port (3000), and put Nginx in front on 80/443. That’s the most common setup anyway. See Nginx reverse proxy.
Inspect capabilities
getcap -r /usr/bin /usr/sbin /usr/local/bin 2>/dev/null # files with capabilities
grep Cap /proc/$(pgrep -o nginx)/status # a process's capability sets (hex)
capsh --decode=00000000a80425fb # decode a hex mask
pscap # from libcap-ng-utils: capabilities of running processes
Unexpected file capabilities (for example cap_setuid on a random binary) can be a privilege-escalation backdoor. Review them as part of security checks. See detect a compromised server.
Harden services by dropping capabilities
Even services that start as root can drop what they don’t need:
[Service]
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
systemd-analyze security myapp.service scores a unit’s hardening, and suggests options.
Containers: drop by default
Docker gives containers a reduced default set. Tighten it further:
services:
web:
image: myapp
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
Avoid privileged: true, which grants everything, including CAP_SYS_ADMIN. See Linux namespaces and Kubernetes security basics.
Why it’s worth the effort
Most service compromises start with a bug in the application: a vulnerable plugin, an injection flaw, or a deserialisation issue. What the attacker can do next depends on the privileges of the process they took over. A web app running as root hands over the whole server. The same app running as an unprivileged user, with only CAP_NET_BIND_SERVICE, can bind ports, and little else. Combined with NoNewPrivileges, it can’t gain more, even through setuid programs.
Quick reference
| Task | Command |
|---|---|
| Give a binary a capability | setcap 'cap_net_bind_service=+ep' /path/bin |
| Show file capabilities | getcap /path/bin |
| Remove | setcap -r /path/bin |
| systemd service | AmbientCapabilities= + CapabilityBoundingSet= |
| Docker | cap_drop: [ALL] + cap_add: […] |
| Audit a unit | systemd-analyze security <unit> |
Related: Linux cgroups and Linux server hardening checklist.