Ata Kuyumcu's Blog

Revisiting server hardening

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.