cheat sheet
Security Fundamentals
OS-level security primitives every operator should know: users and groups, file permissions, Linux capabilities, SUID/SGID, mandatory access control (SELinux, AppArmor), sandboxing concepts, least privilege, and encryption at rest vs in transit.
Security Fundamentals — Users, Permissions, Capabilities, MAC, Sandboxing
What it is
Operating system security is the set of OS-enforced primitives that decide what a process can do — which files it can read, which syscalls it can invoke, which network it can touch, whose identity it acts under. The classic Unix model (users + groups + read/write/execute bits) is the foundation; every modern OS layers more on top: Linux capabilities slice up root's power, mandatory access control (SELinux, AppArmor) overlays a separate policy, namespaces and seccomp drive containers, and secure boot + full-disk encryption extend the trust boundary down to the firmware. Reach for this article when you need the canonical taxonomy of these mechanisms — what each one defends against, where they overlap, and how they compose in a real production deployment.
The threat model
Every security decision has a threat model — the attacker's assumed capabilities — even if it's unstated. Useful security work always starts with naming the threats; controls without threats become cargo-culted ritual.
| Threat | Examples |
|---|---|
| Curious local user | Snooping at other users' files, reading process environment |
| Privilege escalation | Local user wants root; container wants host |
| Remote unauthenticated attacker | Internet-facing service exploit |
| Remote authenticated attacker | Compromised account; insider |
| Physical access | Stolen laptop, datacentre theft |
| Supply-chain | Malicious dependency, compromised build |
| Side channels | Spectre, timing, EM, power analysis |
For each, ask: what does the OS provide? A stolen-laptop threat is met by full-disk encryption (FDE), not file permissions; a remote exploit threat by sandboxing + capabilities, not just by chmod 600; a side-channel threat by microcode + kernel mitigations, not by anything you can chmod.
Principle of least privilege
The single most important security maxim: every component runs with the minimum privileges it needs. A web server doesn't need to write /etc; a backup script doesn't need root to read your home directory if it owns the files; a process that accepts internet traffic shouldn't be able to load kernel modules. Least privilege is implemented at every layer below — that's what the rest of this article is about.
# Quick audit: services running as root
ps -eo user,pid,comm --sort=user | awk '$1=="root"' | head
Output:
root 1 systemd
root 18 ksoftirqd/0
root 423 systemd-journal
root 611 rsyslogd
root 800 sshd
root 1234 nginx: master process
The nginx: master shouldn't surprise you (it forks workers as www-data), but rsyslogd and other daemons running as root are worth a hard look — switching them to a service-specific user is usually a one-liner in the systemd unit.
Users and groups
Every file, process, and socket carries a user ID (UID) and at least one group ID (GID). The kernel uses them as the basis for almost every access decision. A user can belong to many groups simultaneously (the supplementary groups); their primary group is the one new files inherit.
# Show your IDs
id
Output:
uid=1000(alice) gid=1000(alice) groups=1000(alice),27(sudo),998(docker)
| File | What it stores |
|---|---|
/etc/passwd | Username → UID, GID, home, login shell |
/etc/shadow | Username → password hash (root-only) |
/etc/group | Group name → GID, member list |
/etc/gshadow | Group password (rarely used) |
# Create a service account with no login and no home
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
getent passwd myapp
Output:
myapp:x:998:998::/nonexistent:/usr/sbin/nologin
Reserve UIDs below 1000 (or below 500 on RHEL) for system accounts; humans get 1000+. Use
--systemwithuseraddfor service accounts so they're allocated from the low range and don't appear ingetent passwd | awk -F: '$3>=1000'.
sudo and its cousins
sudo lets a user run specific commands as another user (usually root) after authenticating. The policy is in /etc/sudoers and /etc/sudoers.d/* — never edit directly, always with visudo so syntax errors don't lock you out.
# Allow alice to restart nginx without a password — narrow, explicit
sudo visudo -f /etc/sudoers.d/nginx-alice
Output: (none — opens editor, validates syntax on save)
alice ALL=(root) NOPASSWD: /bin/systemctl restart nginx.service, /bin/systemctl reload nginx.service
# Audit who can sudo to what
sudo -l -U alice
Output:
User alice may run the following commands on this host:
(root) NOPASSWD: /bin/systemctl restart nginx.service, /bin/systemctl reload nginx.service
(ALL : ALL) ALL
[!WARN]
ALL=(ALL:ALL) ALLis the equivalent of "give this user a permanent root key". For day-to-day operators, restrict to specific commands. For root-required automation, usedoas(OpenBSD-style, simpler) or short-lived credentials (HashiCorp Vault, AWS STS) — not a wildcard sudo entry.
su, doas, polkit
su switches identities outright; doas (from OpenBSD, packaged on most distros) is a smaller, simpler sudo alternative; polkit (PolicyKit) is the desktop-style framework for fine-grained "may this caller perform this action?" decisions used by systemd, NetworkManager, and udisks.
# Reload a service via polkit
busctl call org.freedesktop.systemd1 /org/freedesktop/systemd1 \
org.freedesktop.systemd1.Manager ReloadUnit ss "nginx.service" "replace"
Output:
o "/org/freedesktop/systemd1/job/1842"
File permissions
Linux file permissions are a 12-bit mode: 9 permission bits (read/write/execute for owner/group/other) plus 3 special bits (setuid, setgid, sticky). The basics are covered in depth in the permissions cheatsheet; here we focus on the security-relevant patterns.
ls -l ~/.ssh
Output:
drwx------ 2 alice alice 4096 May 25 09:14 .
-rw------- 1 alice alice 3389 May 24 10:00 id_ed25519
-rw-r--r-- 1 alice alice 747 May 24 10:00 id_ed25519.pub
-rw-r--r-- 1 alice alice 2204 May 25 09:14 known_hosts
The SSH client refuses to use a private key with looser permissions than 0600; the directory must be 0700. These are not arbitrary numbers — sshd's hard refusal is a deliberate guard.
SUID and SGID
The setuid bit on a binary makes it execute as the file's owner, not the invoker. The setgid bit does the same for the group. Setgid on a directory causes new files to inherit the directory's group — useful for shared workspaces.
# Common setuid binaries on a Linux system
sudo find / -xdev -perm -4000 -type f -printf "%M %u %g %p\n" 2>/dev/null | head
Output:
-rwsr-xr-x root root /usr/bin/passwd
-rwsr-xr-x root root /usr/bin/sudo
-rwsr-xr-x root root /usr/bin/chsh
-rwsr-xr-x root root /usr/bin/su
-rwsr-xr-x root root /usr/bin/mount
-rwsr-xr-x root root /usr/bin/newgrp
Every setuid-root binary is a privilege-escalation surface. The kernel does not honour setuid on shell scripts (security choice from the 1980s); only on compiled binaries.
[!WARN] Audit setuid binaries quarterly. A new one appearing in your inventory is a red flag — it might be a deliberately installed tool, or it might be malware. Watch
/etc/cron.dailyand/etc/cron.weeklyfor scripts that re-create them.
Linux capabilities
Capabilities split root's monolithic power into ~40 discrete units. Each one (CAP_NET_BIND_SERVICE, CAP_DAC_OVERRIDE, CAP_SYS_ADMIN, etc.) grants a single ability; processes can be granted some without being granted all. Capabilities replace setuid-root for almost every modern daemon — give the binary the one thing it needs and nothing else.
# Inspect a process's capabilities
cat /proc/self/status | grep ^Cap
Output:
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
| Set | Meaning |
|---|---|
CapInh | Inheritable across exec |
CapPrm | Permitted — capabilities you have available |
CapEff | Effective — capabilities currently in force |
CapBnd | Bounding — upper bound for the process tree |
CapAmb | Ambient — inherited by a child after exec |
# Decode the bitmap
capsh --decode=000001ffffffffff | head
Output:
0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,
cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,
cap_net_bind_service,cap_net_broadcast,cap_net_admin,...
Granting a capability to a binary
The classic case: let nginx bind to port 80 without running as root.
# Drop the setuid bit; grant exactly one capability
sudo setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx
getcap /usr/sbin/nginx
Output:
/usr/sbin/nginx cap_net_bind_service=ep
=ep means "permitted + effective" — the capability is available and active when the binary executes.
# Drop a capability
sudo setcap -r /usr/sbin/nginx
Output: (none — exits 0 on success)
Capability-aware syscall reference
The most-used capabilities and what they unlock:
| Capability | Lets you |
|---|---|
CAP_NET_BIND_SERVICE | Bind to ports < 1024 |
CAP_NET_ADMIN | Modify routing tables, interfaces, firewall |
CAP_NET_RAW | Open raw sockets (ping, tcpdump) |
CAP_SYS_ADMIN | Mount filesystems, set hostname — extremely broad ("the new root") |
CAP_SYS_PTRACE | Attach to other processes' memory |
CAP_SYS_NICE | Change scheduling priority |
CAP_DAC_OVERRIDE | Bypass file read/write permission checks |
CAP_DAC_READ_SEARCH | Bypass file read permission checks |
CAP_CHOWN | Change file ownership |
CAP_KILL | Send signals to any process |
CAP_SETUID / CAP_SETGID | setuid() / setgid() to any ID |
CAP_AUDIT_WRITE | Write to kernel audit log |
CAP_SYS_ADMINis the new root. Granting it to a container or daemon effectively undoes the rest of the sandbox. If a container needs it, the container is probably miscofigured — break the workload into a privileged helper and a least-privilege main.
Mandatory access control (MAC)
The classic Unix model is discretionary — the file's owner controls its permissions, and a process running as that owner can do whatever it wants. Mandatory access control overlays a policy that even the owner cannot override: a process is labelled, files and sockets are labelled, and an external policy decides which labels may interact. SELinux (RHEL, Fedora, Android) and AppArmor (Debian, Ubuntu, SUSE) are the two major Linux implementations.
SELinux
SELinux uses type enforcement: every process has a type (e.g. httpd_t), every file has a type (httpd_sys_content_t), and the policy says which types may access which other types in which ways. Policy is compiled and ships in the distro; you tune it with booleans and file contexts.
# Show your current SELinux mode (enforcing / permissive / disabled)
getenforce
sestatus
Output:
Enforcing
SELinux status: enabled
SELinuxfs mount: /sys/fs/selinux
SELinux root directory: /etc/selinux
Loaded policy name: targeted
Current mode: enforcing
# Inspect labels on files and processes
ls -Z /var/www/html
ps -eZ | head
Output:
unconfined_u:object_r:httpd_sys_content_t:s0 index.html
unconfined_u:object_r:httpd_sys_content_t:s0 about.html
LABEL PID TTY TIME CMD
system_u:system_r:init_t:s0 1 ? 00:00:01 systemd
system_u:system_r:syslogd_t:s0 611 ? 00:00:00 rsyslogd
system_u:system_r:httpd_t:s0 1234 ? 00:00:01 nginx
# Allow a non-default content directory for nginx
sudo semanage fcontext -a -t httpd_sys_content_t '/opt/myapp/static(/.*)?'
sudo restorecon -Rv /opt/myapp/static
# Toggle a boolean (read user home dirs)
sudo setsebool -P httpd_read_user_content on
# Investigate denials
sudo ausearch -m AVC -ts recent | head
sudo journalctl --grep='AVC'
Output:
Relabeled /opt/myapp/static from unconfined_u:object_r:default_t:s0 to system_u:object_r:httpd_sys_content_t:s0
Relabeled /opt/myapp/static/index.html from unconfined_u:object_r:default_t:s0 to system_u:object_r:httpd_sys_content_t:s0
type=AVC msg=audit(1716638400.234:1521): avc: denied { read } for pid=1234 comm="nginx" name="report.txt" dev="dm-0" ino=98231 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0
[!WARN] When SELinux blocks a service, don't run
setenforce 0and walk away — that disables SELinux globally until reboot. Useaudit2why < /var/log/audit/audit.log | headto understand the denial, then eithersemanage fcontextto fix the label oraudit2allowto generate a targeted policy module. Disabling SELinux permanently is rarely the right answer on RHEL.
AppArmor
AppArmor uses path-based profiles instead of type labels. A profile lists which paths a process may read, write, or execute, plus which capabilities and network operations it may perform.
# Show loaded profiles
sudo aa-status
Output:
apparmor module is loaded.
22 profiles are loaded.
22 profiles are in enforce mode.
/usr/sbin/cups-browsed
/usr/sbin/cupsd
/usr/sbin/dhclient
/usr/sbin/nginx
...
3 processes are unconfined but have a profile defined.
# Inspect a profile
sudo cat /etc/apparmor.d/usr.sbin.nginx | head -20
Output:
#include <tunables/global>
profile nginx /usr/sbin/nginx {
#include <abstractions/base>
#include <abstractions/nis>
capability net_bind_service,
capability setgid,
capability setuid,
/etc/nginx/** r,
/var/log/nginx/* w,
/var/www/** r,
...
}
# Put a profile in complain mode to log denials without blocking
sudo aa-complain /usr/sbin/nginx
# Re-enable enforcement
sudo aa-enforce /usr/sbin/nginx
Output:
Setting /usr/sbin/nginx to complain mode.
Setting /usr/sbin/nginx to enforce mode.
| Property | SELinux | AppArmor |
|---|---|---|
| Style | Type enforcement (labels) | Path-based |
| Policy ships in distro | Yes (RHEL, Fedora) | Yes (Debian, Ubuntu, SUSE) |
| Tooling | semanage, audit2allow, setsebool | aa-status, aa-complain, aa-enforce |
| Learning curve | Steep | Gentler |
| Granularity | Very fine | Practical |
Sandboxing
Sandboxing is the general technique of running code in a constrained environment that can't reach what it shouldn't. Linux has a stack of sandboxing primitives that compose:
| Mechanism | Constrains |
|---|---|
chroot | Filesystem root |
Namespaces (mount, pid, net, ipc, uts, user, cgroup, time) | Per-process views of kernel resources |
| cgroups v2 | CPU, memory, I/O, PID counts |
| seccomp-bpf | Allowed syscalls |
| Capabilities | Discrete root powers |
| MAC (SELinux/AppArmor) | Mandatory policy on label/path |
pivot_root | Atomic switch to a new filesystem root |
| User namespaces | Map UIDs so root inside is not root outside |
Containers (Docker, Podman, Kubernetes) combine all of these; systemd uses many of them too via service hardening directives.
chroot
The oldest sandbox: change the apparent root of the filesystem for a process. Modern code uses namespaces instead, but chroot is still useful for distro-installer chroots and emergency recovery.
# Enter a debian-rootfs directory
sudo chroot /mnt/debian /bin/bash
Output:
root@myhost:/#
[!WARN] Plain
chrootis not a security boundary — a process withCAP_SYS_CHROOTcan escape. Use it for environment isolation (build chroots, debootstrap), not for adversarial sandboxing. For real isolation, use a container runtime (Podman, Docker, systemd-nspawn) which combines namespaces + capabilities drop + seccomp.
Namespaces
Each namespace gives the process its own view of one kernel resource. A container is typically all of these at once.
| Namespace | Isolates |
|---|---|
mount | Mount points (filesystem tree) |
pid | Process IDs (PID 1 inside, real PID outside) |
net | Interfaces, routing tables, firewall, sockets |
ipc | SysV IPC, POSIX message queues |
uts | Hostname, domain name |
user | UID/GID mappings — root inside ≠ root outside |
cgroup | Cgroup root view |
time | System clock offsets (since Linux 5.6) |
# Inspect this shell's namespaces
ls -l /proc/self/ns/
Output:
lrwxrwxrwx 1 alice alice 0 May 25 09:14 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 net -> 'net:[4026531992]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 user -> 'user:[4026531837]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 uts -> 'uts:[4026531838]'
# Quick container-style sandbox using unshare
sudo unshare --pid --fork --mount-proc --uts --ipc --net /bin/bash
hostname container-test # only changes inside
ip addr # only loopback exists
exit
Output:
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
seccomp
Seccomp (Secure Computing) filters which syscalls a process may invoke. Modern seccomp-bpf accepts a small BPF program that inspects the syscall number and arguments and returns ALLOW, KILL, or RETURN-ERRNO. Docker and Kubernetes ship default seccomp profiles that block ~40 of the ~330 Linux syscalls — including the obscure ones used in container escapes.
# Run a command with a seccomp filter via firejail
firejail --seccomp curl https://example.com
Output:
Reading profile /etc/firejail/curl.profile
Parent pid 8312, child pid 8313
Child process initialized in 28.43 ms
Parent is shutting down, bye...
In systemd unit files:
[Service]
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
@system-service is a systemd-defined set: everything a normal service needs, nothing dangerous (no mount, pivot_root, kexec_load, …). See systemd unit files for the full hardening stack.
systemd sandboxing directives
A modern Linux service should layer most of these on top of running as a non-root user:
[Service]
User=myapp
Group=myapp
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
CapabilityBoundingSet=
ReadWritePaths=/var/lib/myapp /var/log/myapp
# Score the hardening (0=worst, 10=best)
sudo systemd-analyze security myapp.service
Output:
Overall exposure level for myapp.service: 1.4 OK
Anything above 5 is "needs work"; well-hardened services score below 2.
Authentication primitives
The OS sits underneath the application's authentication, but it provides several primitives applications rely on.
| Primitive | Used for |
|---|---|
| PAM (Pluggable Authentication Modules) | Pluggable auth stack — pam_unix, pam_ldap, pam_sss, pam_u2f, pam_oath |
| NSS (Name Service Switch) | Where to look up users/groups (files, LDAP, SSSD) |
| Kerberos | Single-sign-on tickets, strong cryptographic auth |
| PKI / TLS | Server (and optionally client) certificates |
| SSH keys | Asymmetric key pairs used by sshd |
| TPM / Secure Enclave | Hardware-backed key storage |
| FIDO2 / WebAuthn / U2F | Hardware authenticators (YubiKey) |
| PASETO / JWT | Application-layer tokens |
# Inspect the PAM stack for ssh logins
sudo cat /etc/pam.d/sshd | head
Output:
#%PAM-1.0
auth required pam_sepermit.so
auth substack password-auth
auth include postlogin
account required pam_nologin.so
account include password-auth
password include password-auth
session required pam_selinux.so close
session required pam_loginuid.so
Encryption at rest vs in transit
Two parallel concerns; the protections, threats, and tooling are different.
At rest
Encryption at rest protects data on disks against physical theft and decommissioned-disk recovery. The blast radius is the disk; if an attacker has root on a running system, encryption at rest doesn't help.
| Layer | Linux | macOS | Windows |
|---|---|---|---|
| Full-disk | LUKS / dm-crypt | FileVault (APFS) | BitLocker |
| Filesystem | fscrypt (ext4, F2FS) | APFS native | EFS |
| Per-file | gocryptfs, age, gpg | — | — |
| Cloud volume | EBS encryption, GCE persistent disk | — | Azure Disk Encryption |
| Application | client-side at the app layer |
# Inspect a LUKS volume
sudo cryptsetup luksDump /dev/sda3 | head
Output:
LUKS header information
Version: 2
Epoch: 5
Metadata area: 16384 [bytes]
UUID: 1a2b3c4d-...
Label: (no label)
Subsystem: (no subsystem)
Flags: (no flags)
Keyslots:
0: luks2
Key: 512 bits
Priority: normal
Cipher: aes-xts-plain64
Full-disk encryption is necessary but not sufficient. A stolen, powered-off laptop is protected; a stolen, suspended-to-RAM laptop with a guessable account password is not. Configure suspend → hibernate (zero memory) for high-value devices, or use TPM + PIN with anti-hammering.
In transit
Encryption in transit protects data while it crosses a network. The big mechanisms:
| Mechanism | Protects |
|---|---|
| TLS 1.2 / 1.3 | HTTPS, SMTP, IMAP, LDAP, gRPC, almost everything over TCP |
| SSH | Interactive shells, file transfer, port forwarding |
| WireGuard / IPsec | VPN tunnels (layer 3) |
| DNS over TLS / HTTPS | DNS queries |
| mTLS | Mutual auth — both ends present a certificate |
| Noise protocol | Custom protocols (Wireguard, Lightning) |
# What TLS version did this connection negotiate?
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 | grep -E 'Protocol|Cipher'
Output:
Protocol : TLSv1.3
Cipher : TLS_AES_256_GCM_SHA384
[!WARN] Don't roll your own crypto. Use libraries (OpenSSL, BoringSSL, libsodium, age) and battle-tested protocols (TLS, Noise, SSH). The chance of getting it right from scratch is microscopic; the chance of getting it wrong is essentially 100%.
Audit and logging
Logging is your forensic record. The Linux audit subsystem records syscall-level events; the systemd journal records what services say; last/lastlog records logins. Centralise to a log host you do not also expose — an attacker with root locally will rewrite logs.
# Audit a specific binary for any execution
sudo auditctl -w /usr/bin/sudo -p x -k sudo-exec
# Then read events
sudo ausearch -k sudo-exec | head
# systemd journal
journalctl --since "1 hour ago" -p err
journalctl _COMM=sshd --since today
# Login history
last -20
lastb -10 # failed logins
Output:
time->Sun May 25 10:42:18 2026
type=SYSCALL msg=audit(1716638538.211:842): arch=c000003e syscall=59 success=yes exit=0 a0=55c4d2 a1=55c4e8 a2=55c500 items=2 ppid=8420 pid=8421 auid=1000 uid=0 gid=0 euid=0 comm="sudo" exe="/usr/bin/sudo" key="sudo-exec"
May 25 10:41:02 myhost sshd[8401]: Accepted publickey for alice from 10.0.0.5 port 51420 ssh2
alice pts/0 10.0.0.5 Sun May 25 10:41 still logged in
alice pts/0 10.0.0.5 Sun May 25 09:02 - 09:48 (00:46)
Common pitfalls
chmod 777"fix" — opens the door for thirty seconds and a security incident an hour later. Usenamei -lto walk the path and find the actual permission gap.- Setuid script — silently ignored by the Linux kernel. Use
sudowith an explicit rule or a small C wrapper. sudo ALL=(ALL) NOPASSWD: ALL— turns sudo into a transparent door. Always restrict to specific commands.- Disabling SELinux to "fix" a service — usually the wrong answer.
audit2whyandsemanage fcontextsolve it for real. - Container running as root — defeats most of the container's security. Use
USERin the Dockerfile,--userat run time, or rootless containers (Podman). - Adding
CAP_SYS_ADMINto a container — equivalent to root. Identify the specific need (mount? namespaces? bpf?) and add that capability instead. - Storing secrets in environment variables that systemd logs —
EnvironmentFile=is OK;Environment=plusjournalctlexposure is not. UseLoadCredential=(systemd 250+) or systemd-creds for proper handling. - TLS terminated at a load balancer with plain HTTP inside the VPC — fine until an attacker is inside the VPC. Mutual TLS or service-mesh sidecars solve it.
- SSH password auth still on for root — disable both:
PermitRootLogin noandPasswordAuthentication no. Use keys, ideally hardware-backed. - Full-disk encryption with no recovery key escrow — locks you out as effectively as an attacker. Escrow to a secure location (sealed envelope in a safe, secret manager).
- Auditing locally only — the first thing an attacker with root does is edit
/var/log/. Ship logs off-box in real time. - Assuming "behind a firewall" is enough — defence in depth. Every internal service should still authenticate, encrypt, and run least-privilege.
Real-world recipes
Lock down a new VPS in five minutes
A bare-minimum hardening pass for an internet-facing host.
# 1. Update everything
sudo apt update && sudo apt upgrade -y
# 2. Make a user; disable root SSH
sudo useradd -m -s /bin/bash -G sudo alice
sudo passwd alice # strong password
sudo mkdir -p /home/alice/.ssh
sudo chown alice:alice /home/alice/.ssh
sudo chmod 700 /home/alice/.ssh
# Paste your public key
echo "ssh-ed25519 AAAA... alice@laptop" | sudo tee /home/alice/.ssh/authorized_keys
sudo chmod 600 /home/alice/.ssh/authorized_keys
sudo chown alice:alice /home/alice/.ssh/authorized_keys
# 3. Tighten sshd
sudo sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart ssh
# 4. Firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
# 5. Unattended-upgrades for security patches
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
Output: (none — exits 0 on success)
Audit setuid binaries
Quarterly inventory check.
sudo find / -xdev -perm -4000 -type f -printf "%M %u %g %p\n" 2>/dev/null | tee /tmp/suid-$(date +%Y%m%d).txt
diff /tmp/suid-baseline.txt /tmp/suid-$(date +%Y%m%d).txt
Output (if nothing changed): (none — exits 0 on success)
If diff produces output, a new SUID binary has appeared — investigate before doing anything else.
Strip capabilities from a binary
For "this service needs root only because of one syscall" cases.
# Old way: setuid root
sudo chown root:root /usr/local/bin/myserver
sudo chmod 4755 /usr/local/bin/myserver
# New way: capability + non-root user
sudo chown root:root /usr/local/bin/myserver
sudo chmod 755 /usr/local/bin/myserver
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myserver
getcap /usr/local/bin/myserver
Output:
/usr/local/bin/myserver cap_net_bind_service=ep
Sandbox a script that scrapes the web
Drop network restrictions on a tool you don't fully trust.
sudo systemd-run --user --scope \
-p ProtectSystem=strict \
-p ProtectHome=yes \
-p PrivateTmp=yes \
-p NoNewPrivileges=yes \
-p RestrictAddressFamilies=AF_INET:AF_INET6 \
-p SystemCallFilter=@system-service \
-p CapabilityBoundingSet= \
./scrape.py
Output: (none — exits 0 on success)
Investigate a denied AVC
When a service fails on a SELinux system and you suspect policy.
sudo ausearch -m AVC -ts recent | head
sudo ausearch -m AVC -ts recent | audit2why | head
Output (audit2why):
type=AVC msg=audit(1716618842.012:42): avc: denied { read } for pid=1234 comm="nginx" name="config.toml" dev="nvme0n1p2"
Was caused by:
The boolean httpd_read_user_content was set incorrectly.
Allow access by executing:
# setsebool -P httpd_read_user_content 1
Generate and verify a checksum chain for distribution
Defends against tampering for files downloaded out-of-band.
# Generator side
sha256sum *.tar.gz > SHA256SUMS
gpg --detach-sign --armor SHA256SUMS
# Verifier side
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS
Output:
gpg: Good signature from "Alice Dev <alice@example.com>"
release-1.0.tar.gz: OK
Tips
Run
systemd-analyze securityagainst every service on a box, weekly. The scores reveal which units are still running as root with no sandboxing — those are exactly where you'll spend the next afternoon if anything goes wrong.
lynis audit systemis a one-shot, full-system hardening audit. It's not the answer to every question, but the first run on a fresh host will surface dozens of low-hanging fruit (default passwords, missing AIDE, unsigned packages).
[!WARN] Security advice has an expiry date. The mitigations that worked in 2020 are not all the right ones today. Re-read your distro's security wiki and CVE feed every year, and update playbooks accordingly.