
Most server compromises start as a service you left reachable, not as a novel zero-day.
sshd on the public internet, a forgotten admin port, or an unpatched race in a long-running daemon is the usual path. The attacker scans. The box answers. The rest is a known CVE and a default config.
The usual mistake is ‘we have a firewall’ while port 22 is still open to the world and the OpenSSH package is months behind. Exposure and patch age are the two numbers that matter first.
This page is the attacks that still land on internet-facing Linux, the versions that close the current holes, and the checks you run before you call the host quiet.
Qualys published CVE-2024-6387, the sshd signal-handler race, on 1 July 2024. Ubuntu shipped a fix in the security pocket. A 24.04 image that still compiles PermitRootLogin as prohibit-password will accept a root key. A Node listener that never set requestTimeout will still hash a stranger’s password on the same box.
The control here is who can open a shell, who can read the bytes on 443, and how many times a stranger may spend your CPU before 429. Pair the request surface with the secure coding checklist once sshd, TLS, and the quota all answer.
A locked host still serves an open login
Two failure modes share a VPS and get sold as one “server hardening” checklist.
- The host belt fails. Password sshd on public 22. Root still allowed with a key. 8080 bound on
0.0.0.0. Cleartext 80 as the real app. Tuesday’s OpenSSH fix sitting in the pocket because nobody enabledunattended-upgrades. - The app belt fails.
bcrypt.compareon every POST to/loginwith no quota.express.json()at the 100 kb default, or worse a raised 50 mb. NoheadersTimeout. A CDN 429 that never sees the origin because you pointed DNS at the VPS.
Closing one does not close the other. A key-only sshd with a public bcrypt route is a quiet shell and a loud CPU. A strict quota behind password sshd is a 429 on HTTP and a root prompt on 22. Those are other tickets. The fix is three files you can grep and one middleware you can curl.
sshd: keys only, root never
Ubuntu 24.04.4 LTS shipped on 12 February 2026. Canonical’s list of releases still puts standard security maintenance for 24.04 through May 2029. The OpenSSH man page default for PermitRootLogin is prohibit-password. That is not no. A key as root still lands.
Write a drop-in. Do not edit the 200-line stock file. On 24.04, sshd reads *.conf under the drop-in directory in lexical order and keeps the first value for each keyword. Cloud images often ship 50-cloud-init.conf with PasswordAuthentication yes. 00-hardening.conf sorts first. 99-hardening.conf loses.
# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
AuthenticationMethods publickey
Install the ed25519 public key in /home/deploy/.ssh/authorized_keys first. Mode 600 on that file, 700 on .ssh, owner deploy. Keep a second console open. Then:
sudo sshd -t
sudo systemctl restart ssh
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|kbdinteractiveauthentication'
You want permitrootlogin no, passwordauthentication no, and kbdinteractiveauthentication no. If any line still says yes, another drop-in won. Believe sshd -T, not the file you just saved. Socket activation on 24.04 means ssh.socket owns the listen port. Auth keywords take effect after systemctl restart ssh.
ufw default deny incoming, 443 from the world, 22 only from the admin net or a tailnet. The Ubuntu page is the filter recipe and the webapp user. Here you only need 22 off the public internet and 8080 on loopback.
sshd hardening is a snapshot. CVE-2024-6387 was a reminder that the daemon you locked last quarter still needs Tuesday’s fix. Install unattended-upgrades if it is missing, keep the -security origin on, and leave automatic reboot off on a single VPS. Confirm the timer:
sudo apt-get install -y unattended-upgrades
systemctl is-enabled unattended-upgrades
journalctl -u unattended-upgrades -n 20 --no-pager
A pin on openssh-server is how a 2024 race lives into 2026. Do not hold that pin unless you are mid-incident. The Ubuntu page has the 50unattended-upgrades file. The proof is a recent journal run, or a next timer, after the drop-in already prints no.
TLS you terminate, not a vendor costume
RFC 8996 deprecated TLS 1.0 and 1.1 in March 2021. The CA/Browser Forum’s TLS Baseline Requirements section 6.3.2 already caps a new public Subscriber certificate at 200 days as of 15 March 2026. Ballot SC-081v3 passed on 11 April 2025. A 398-day buy in a drawer is leftover inventory.
Terminate TLS on Caddy or nginx you admin, or inside the app if that process is the only listener. Do not send cleartext to a box you do not admin and call the tab HTTPS. TLSRef Server-Side TLS. Version 6.0, released 3 May 2026, left two profiles: Modern is TLS 1.3 only. Intermediate still allows 1.2. Use Intermediate unless you control every client.
# /etc/nginx/conf.d/tls.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# BAD: any of these still on a public 443
# ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
Caddy 2 as the proxy you own, sitting on 443, forwarding to the local worker:
# /etc/caddy/Caddyfile
example.com {
reverse_proxy 127.0.0.1:8080
}
Let’s Encrypt from Caddy or certbot is fine. HSTS at two years with includeSubDomains only after every name on the zone answers on 443. Once 443 answers, set HSTS, a real CSP, and X-Content-Type-Options on that same proxy. If the app issues a session cookie, that cookie needs Secure, HttpOnly, SameSite=Lax, and a Host prefix. The TLS page is ciphers, 47-day 2029, and mixed content. Here you need a listener you can restart and a certificate you can replace in hours.
Quota the hasher, not the whole site
Node 18.0.0, shipped 19 April 2022, set server.requestTimeout to 300000 ms. current Node.js http.Server page. headersTimeout defaults to 60000 ms. If either expires, the server sends 408 and closes the socket without calling your listener. Set them yourself. Do not inherit five minutes on /login.
const http = require("http");
const express = require("express");
const app = express();
const server = http.createServer(app);
server.headersTimeout = 10_000;
server.requestTimeout = 15_000;
server.keepAliveTimeout = 5_000;
server.listen(8080, "127.0.0.1");
express-rate-limit 8.6.2 published on 4 August 2026. Put loginLimiter on the hasher. A site-wide 100-request window is how a scanner burns the budget and a customer cannot sign in. The DoS page is body caps, requireSession before reportJob, and the CDN as a belt. This page is the one quota that belongs next to sshd and TLS.
const { rateLimit } = require("express-rate-limit");
const bcrypt = require("bcrypt");
const formLimit = { limit: "8kb", extended: false };
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: "draft-8",
legacyHeaders: false,
skip: (req) => req.method === "GET",
});
app.post("/login", loginLimiter, express.urlencoded(formLimit), async (req, res) => {
const email = req.body.email;
if (typeof email !== "string" || typeof req.body.password !== "string") {
res.status(400).send("bad login");
return;
}
const user = await users.findByEmail(email);
if (!user) {
res.status(401).send("no");
return;
}
const ok = await bcrypt.compare(req.body.password, user.passwordHash);
if (!ok) {
res.status(401).send("no");
return;
}
req.session.userId = user.id;
res.status(204).end();
});
The in-memory store is per process. Two Node workers each have their own counter. That is fine for a single box. Share a store when you have more than one process, or accept that the quota is per worker. Do not skip loginLimiter because fail2ban already watches sshd. Those are different sockets.
Put the three locks on one packet path
Order matters. nft or ufw decides who may speak. sshd decides who may have a shell. The proxy decides the protocol and the certificate. The app decides how many hashes run. A jail that inserts a drop after a log line is last, and optional.
| Lock | Proof | Sibling page |
|---|---|---|
| sshd drop-in | sshd -T prints no / no | Ubuntu host |
| ufw / nft | deny in, 443 open, 22 limited | Ubuntu host |
| TLS 1.2+ | nmap or sslscan, no 1.0 / 1.1 | TLS guide |
| loginLimiter | 11th POST is 429 | App-layer DoS |
INTERNET | +-- tcp/22 nft allow from admin | sshd --> deploy key | PasswordAuthentication no | +-- tcp/443 nft allow | caddy/nginx TLS 1.2 + 1.3 | | | +-- 127.0.0.1:8080 | loginLimiter then bcrypt | DROP everything else
Do not move sshd off 22 and call that the host belt. A new port cuts bot noise. It does not replace the drop-in. Do not buy a “DDoS appliance” and skip loginLimiter. Volume is an edge problem. The hasher is an origin problem. Do not terminate TLS in a dashboard you cannot read and then skip HSTS on the process you do own.
Prove sshd, 443, and 429
You are not scanning other people. You are reading the effective policy on a box you admin, then proving your own /login returned 429.
- From a second session,
ssh deploy@your-hostwith the key. A password prompt is a fail. - Rerun the
sshd -Tgrep from the sshd section. Both auth lines must printno. ss -lntupmust show 443 and 22 on the addresses you expect, and the worker only on loopback port 8080.- Against your own origin only, POST
/logineleven times. Expect 429 on the eleventh.
sshd -T | grep -E 'permitrootlogin|passwordauthentication'
ss -lntup | grep -E ':22|:443|:8080'
# Against your own origin. Eleven times. Eleventh is 429.
for i in 1 2 3 4 5 6 7 8 9 10 11; do
curl -sS -o /dev/null -w "%{http_code}\n" -X POST "https://your-app.example/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "email=you@your-app.example&password=not-the-real-one"
done
If sshd -T and the drop-in disagree, the drop-in lost. Fix the sort order. If port 8080 is on *, bind the app to loopback. If the eleventh POST is 200, loginLimiter is not on that route. If TLS 1.0 still appears in a handshake you started against your own name, the proxy file lost to another ssl_protocols line. The TLS page is the cipher proof. Those greps belong on the same host you just locked.
Questions we keep getting
Is fail2ban enough if sshd already refuses passwords?
No. It is a tripwire on leftover attempts and on a jail you forgot to lock. zsoltkacsandi’s August 2025 note is the same call. Keep the drop-in. Keep loginLimiter on HTTP. Add fail2ban only if you want a drop after a log line you already trust.
Can the app listen on 443 directly?
Yes, if that process is the one you admin and it loads a certificate you control. Then ufw still publishes 443 and still hides every other port. You skip the proxy hop. You do not skip the webapp user, TLS 1.2 as the floor, or loginLimiter.
Does a CDN replace the origin quota?
No. A CDN 429 is a belt on volume. It does not cap a body your parser already accepted, and it does not sit on a request that already reached bcrypt.compare. Set loginLimiter on the process you own. Then put the edge in front if you want.



