A security audit that ends with a PDF of findings is worthless if nobody patches anything. The useful audit is one where every finding maps to a command you can run and verify. This walkthrough runs against a Debian 12 server and produces machine-checkable output at every step, so you finish with a host whose state you can re-verify on demand rather than a list of regrets.
Run this on a freshly provisioned VPS before it serves traffic, and re-run the verification block after any major change. The same discipline applies to the hardening workflow described on the main site.
Step 1: Establish the baseline
Before changing anything, capture what is actually running. Unmanaged boxes accumulate surprises — a leftover service, an old package with a known CVE, a world-writable file in /etc.
apt-get update && apt list --upgradable 2>/dev/null | tail -n +2
systemctl list-units --type=service --state=running --no-pager
ss -tulpn
find /etc -xdev -type f -perm -o+w 2>/dev/null
lastb | head -20
The upgradable list is your CVE exposure, the running-services list is your attack surface, the listening-sockets list is what is reachable, and the world-writable find is your local privilege-escalation surface.
Step 2: Verify SSH is not the weak link
sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|pubkeyauth|x11forward|maxauthtries|allowtcpforwarding'
Expected hardened values:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
AllowTcpForwarding no
sshd -T reports the effective config after all includes are merged, which is what actually matters — editing sshd_config.d/*.conf and reading the file back proves nothing. If PasswordAuthentication yes appears, fix it in a drop-in and reload, keeping your existing session open so you do not lock yourself out.
Step 3: Close the firewall to a default-deny posture
cat <<'EOF' > /etc/nftables.conf
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
ip protocol icmp accept
tcp dport { 22, 80, 443 } accept
tcp flags syn limit rate 20/second accept
counter drop
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}
EOF
nft -f /etc/nftables.conf && systemctl enable --now nftables
Then prove it:
nft list ruleset
nmap -Pn -p- <your-public-ip> # run from an external host
A default-drop policy with an explicit allow list is the only firewall posture worth having. The rate limit on new SYNs blunts SYN floods without a separate tool. The nmap run from outside is the ground truth — a local firewall listing means nothing until a remote scan confirms ports are unfiltered or closed.
Step 4: Attack a brute-force problem with data, not kneejerk blocks
Before adding fail2ban, quantify the threat. If lastb shows thousands of failed attempts, the fix is key-only auth (already done above) plus a jail for defence in depth, not endless IP blocking.
[sshd]
enabled = true
port = 22
maxretry = 4
findtime = 600
bantime = 3600
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
The bantime.increment settings make repeat offenders get progressively longer bans — a single global 1-hour ban is trivially waited out by a botnet. Verify with fail2ban-client status sshd and check the jail is actually reading logs, since a wrong logpath produces a silent no-op jail that gives false comfort.
Step 5: Bound what every service can touch
A compromise that escapes the service process is far worse than one contained inside it. systemd gives you sandboxing for free. A hardened web-app unit looks like this:
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
ReadWritePaths=/var/lib/myapp /var/log/myapp
ProtectSystem=strict makes the entire filesystem read-only except explicitly whitelisted paths — this one directive defeats a large class of web-shell persistence attempts. SystemCallFilter=~@privileged blocks calls a web app never legitimately needs. Test with systemd-analyze security myapp.service, which scores the unit and itemises every remaining exposure.
Step 6: Kernel-level hardening
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 2
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.suid_dumpable = 0
kernel.randomize_va_space = 2
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
Each of these removes an information leak or a local escalation primitive with negligible performance cost. kernel.dmesg_restrict=1 stops an unprivileged user reading kernel addresses from the ring buffer; kptr_restrict=2 hides them from everyone but root. Reboot-dependent settings should be applied through /etc/sysctl.d/ so they survive.
Step 7: Automate the audit itself
A one-time audit decays. Wrap the verification commands in a script that exits non-zero when a check regresses, then run it from a systemd timer or CI. Keep it dependency-light so it never fails because a package is missing:
#!/bin/bash
set -u
fail=0
chk(){ if eval "$2"; then echo "PASS $1"; else echo "FAIL $1"; fail=1; fi; }
chk "root-ssh-off" "[ \"\$(sshd -T | grep -c 'permitrootlogin no')\" -eq 1 ]"
chk "password-auth-off" "[ \"\$(sshd -T | grep -c 'passwordauthentication no')\" -eq 1 ]"
chk "firewall-default-drop" "nft list ruleset | grep -q 'policy drop'"
chk "no-world-writable-etc" "[ -z \"\$(find /etc -xdev -type f -perm -o+w 2>/dev/null)\" ]"
chk "autoupdates-on" "systemctl is-enabled --quiet unattended-upgrades"
exit $fail
Hook it into a timer that mails or webhooks on failure. Drift detection turns the audit from a project into a guarantee.
Prioritising what you found
Not every finding deserves equal urgency. Rank by reachability first: an exposed service with a known CVE outranks a local hardening gap every time. Fix in this order — patch exposed software, disable unused services, enforce key-only auth, default-deny the firewall, then layer kernel and systemd hardening. Re-run the verification script after each change so you always know your current posture instead of assuming it.




Leave a Reply
You must be logged in to post a comment.