Two months ago I wrote down my baseline for hardening a fresh Linux install. Non-root user, SSH lockdown, UFW, fail2ban, unattended-upgrades, the Docker firewall hole. It was fine as far as it went. But it didn’t go far enough.
Last week I spun up a new VPS (Debian 13 this time, not Ubuntu) and found six I’d missed the first time around. Post-quantum SSH is also ready and takes one config line.
Post-quantum SSH key exchange
The first hardening post disabled password auth and root login. That protects against brute force. It does nothing against someone recording your SSH traffic today and cracking the handshake later, once a sufficiently large quantum computer exists. The key exchange in SSH is elliptic-curve Diffie-Hellman, and ECDH falls to Shor’s algorithm.
ML-KEM-768 is NIST’s recently standardized post-quantum key encapsulation mechanism. It shipped in OpenSSH 9.9. Debian 13 ships OpenSSH 10.0, which includes it. Enabling it is one line in a drop-in:
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,curve25519-sha256
That’s a hybrid: ML-KEM-768 wrapped in X25519, then the older NTRU Prime hybrid as fallback, then plain X25519 for ancient clients. If a client doesn’t support PQ, it falls back to classic ECDH. You’re still vulnerable to future decryption for those sessions, but clients that can negotiate PQ get full protection.
Put it in /etc/ssh/sshd_config.d/99-hardening.conf and reload ssh. The service
is called ssh on both Debian and Ubuntu. sshd is the RHEL convention. Wrong
name, and the reload fails silently.
Kernel sysctl hardening
None of the six params below are on by default on Debian. Together they close holes that the firewall and SSH config don’t touch:
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.log_martians = 1
kernel.kptr_restrict = 1
kernel.yama.ptrace_scope = 1
rp_filter is the important one. Strict reverse path filtering drops packets
arriving on an interface that the kernel wouldn’t use to send the reply. On a
single-homed VPS with one network interface, this catches spoofed source
addresses that shouldn’t be there.
log_martians surfaces these in the kernel log instead of silently dropping
them. If you ever see martian log entries, something is misconfigured (either
your routing or someone else’s) and you want to know.
kptr_restrict and ptrace_scope limit what a compromised process can learn.
Without them, /proc/kallsyms exposes kernel addresses and any process can
attach a debugger to any other process owned by the same user. With them, the
attacker needs root for both. On a single-user VPS that may sound academic,
until a container or a poorly written cron job gets popped.
Write these to /etc/sysctl.d/99-hardening.conf and run sysctl --system to
apply immediately. Don’t assume a file on disk means the kernel agrees. Check
the runtime value with sysctl net.ipv4.conf.all.log_martians after. I’ve seen
config files that looked correct but weren’t loaded because a file earlier in
the numeric order overrode them.
While you’re in sysctl land, Debian 13 also sets fs.protected_hardlinks and
fs.protected_symlinks to 1 by default. Check them, don’t change them. They’re
already right.
/tmp noexec
Making /tmp non-executable closes one of the oldest tricks: write a script to
/tmp, run it. On older Debian and Ubuntu you’d add a tmpfs entry to
/etc/fstab. On Debian 13, /tmp is mounted via a systemd .mount unit. Editing
fstab does nothing.
Check which system you have:
grep /tmp /etc/fstab # returns nothing on Debian 13
systemctl cat tmp.mount # shows the unit that actually mounts /tmp
If it’s systemd-managed, you need a drop-in override:
mkdir -p /etc/systemd/system/tmp.mount.d
cat > /etc/systemd/system/tmp.mount.d/noexec.conf << EOF
[Mount]
Options=mode=1777,strictatime,nosuid,nodev,noexec,size=50%%
EOF
systemctl daemon-reload
systemctl restart tmp.mount
Verify with:
touch /tmp/test && chmod +x /tmp/test && /tmp/test
# Permission denied
The size=50% cap prevents /tmp from eating all RAM.
Lightweight threat detection
The original post mentioned AIDE briefly. AIDE is a file integrity checker. It
works by hashing every binary on the system and alerting on changes. It’s also a
pain to maintain: every apt upgrade invalidates the baseline and you have to
re-hash.
Two lighter tools cover most of the same ground:
rkhunter scans for rootkits, suspicious hidden files, and /dev anomalies.
It also hashes system binaries, but unlike AIDE it ships with known-good hashes
and only flags deviations. Install it, baseline it, then run it weekly:
apt install rkhunter
rkhunter --propupd --nocolors --skip-keypress
debsecan checks installed packages against the Debian CVE database. The default mode lists every known CVE whether patched or not, which is noisy. The useful mode:
debsecan --suite trixie --only-fixed
That shows only CVEs with patches available in the repos. If it returns nothing, you’re up to date.
Wire both into a weekly systemd timer:
# /etc/systemd/system/security-audit.service
[Unit]
Description=Weekly security audit
[Service]
Type=oneshot
ExecStart=/usr/bin/rkhunter --check --nocolors --skip-keypress
ExecStart=/usr/bin/debsecan --suite trixie --only-fixed
StandardOutput=append:/var/log/security-audit.log
StandardError=append:/var/log/security-audit.log
# /etc/systemd/system/security-audit.timer
[Unit]
Description=Weekly security audit timer
[Timer]
OnCalendar=Sun 04:00
Persistent=true
[Install]
WantedBy=timers.target
Enable it and check the log on Monday morning. If it’s empty, the box is clean (maybe, probably, maybe not).
The log grows forever without rotation. Add logrotate so it doesn’t surprise you six months later:
cat > /etc/logrotate.d/security-audit << 'EOF'
/var/log/security-audit.log {
weekly
rotate 12
compress
delaycompress
missingok
notifempty
create 640 root adm
}
EOF
12 rotations keeps three months of reports, negligible space.
needrestart
After apt upgrade installs new libraries, running processes keep using the old
ones. Your SSH daemon can run a vulnerable libc for weeks after the fix shipped,
until the next unattended-upgrades auto-reboot at 03:00.
apt install needrestart fixes this. It hooks into apt and after every upgrade
prompts which services to restart. If you’re running unattended-upgrades, it
intercepts those too. No config needed.
fail2ban recidive
The original post had sshd and that was it. The recidive jail watches fail2ban’s own log for IPs that get banned, get unbanned, and get banned again. Three cycles in one day and you get a week-long ban instead of another hour.
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
maxretry = 3
findtime = 1d
bantime = 7d
On Debian 13 the fail2ban log lives at /var/log/fail2ban.log. Older guides
point at /run/fail2ban/fail2ban.log, which holds the socket and doesn’t work
as a recidive source. Check with:
grep ^logtarget /etc/fail2ban/fail2ban.conf
On Debian 13, fail2ban defaults to nftables as its ban action, not UFW. Bans go
into nftables directly and are invisible to ufw status. If you set up UFW in
the first hardening pass, switch it: edit
/etc/fail2ban/jail.d/defaults-debian.conf and change banaction = nftables to
banaction = ufw (and banaction_allports similarly).
This file loads first alphabetically and sets [DEFAULT] values. Dropping a new
file with a [DEFAULT] section won’t override it. Later files can only add
jails, not redefine defaults already set by earlier files. Verify with
fail2ban-client -d 2>&1 | grep addaction: you should see ufw, not
nftables.
The baseline from the last post still works. This is the second coat. The
post-quantum stuff alone is worth the five minutes, the sysctl params another
five, and everything else can be done in an afternoon while you wait for
apt install to finish.