← Back to Wiki
Security / Cloud

Your New VPS Is Already Being Brute-Forced: Hardening SSH So cloud-init Cannot Undo It

A host went live on a Friday. Nobody had published its address, and no DNS record pointed at it. Eight hours later its auth log held 1,118 failed authentications from 37 unique source IPs, still arriving while I read them, including direct root password guesses. Nothing was compromised. That is not the point. The point is that this is the default state of a fresh cloud image, not bad luck, and the obvious hardening has a trap in it that silently puts the password login back.

Share on X

Read the log before you decide anything

Do this first, on any host you did not personally harden:

# how many are failing, and from how many places
grep -c "Failed password" /var/log/auth.log
grep "Failed password" /var/log/auth.log | grep -oE "from [0-9.]+" | sort -u | wc -l

# what the server will actually accept, not what the file says
sudo sshd -T | grep -E "permitrootlogin|passwordauthentication"

# does root have a usable password at all
sudo passwd -S root

The last two matter more than the count. A brute-force against key-only SSH is noise. The same traffic against PasswordAuthentication yes and a real password on root is a live exposure. On this host passwd -S root returned P, meaning a usable password, and sshd -T confirmed both password auth and root login were on.

BE WARNED: read sshd -T, not sshd_config. The main config file is not the whole config. It has an Include /etc/ssh/sshd_config.d/*.conf line near the top, and on a cloud image the interesting values live in those drop-ins rather than the file you just opened. sshd -T prints what the daemon actually resolved.

The trap: your hardening file has to sort before cloud-init's

Every instinct says to name a hardening drop-in 99-hardening.conf. Last file wins, surely.

It does not. sshd uses the first value it reads for any keyword. Files in sshd_config.d are read in lexical order, so 50-cloud-init.conf is read before your 99- file and its PasswordAuthentication yes wins. Your file is present, correct, and completely inert.

# name it to sort FIRST, not last
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF

sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -E "permitrootlogin|passwordauthentication"

Sorting first buys you two things. It beats cloud-init's file, and it beats the PermitRootLogin yes sitting further down the main config. It also survives cloud-init rewriting its own drop-in later, which it does on its own schedule and without telling you.

Reload, do not restart, and keep your current session open. Open a second SSH session and confirm it works before you close the first. sshd -t catches a syntax error, but it cannot catch you locking yourself out with a correct file.

Do not lock root's password. It is the wrong reflex

Locking root with passwd -l root looks like the obvious next step. Think about what it buys you.

Once PasswordAuthentication no is in force, that password is unreachable from the network. There is no path to it. Its only remaining use is typing it into your provider's web console when the box will not boot or you have broken sshd. That is your break-glass recovery, and locking the account removes it in exchange for no reduction in attack surface at all.

The argument for locking it is that cloud-init might flip password auth back on. That is a real risk, and the drop-in's sort order is what handles it. Fix the regression, keep the recovery path.

fail2ban is worth it, and it is not the fix

Install it. It trims log noise and slows the volume down:

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

But be clear about what it does. fail2ban rate-limits guessing. Key-only authentication makes guessing pointless. If you have to choose which one to do at 2am, turn off password authentication. The volume of attempts is not the problem. The fact that any of them could ever succeed is.

Prove it from outside

Two checks, from a machine that is not the one you are hardening:

# a password attempt must now be refused outright
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password root@<host>
# expect: Permission denied (publickey)

# and confirm every real login has been a key
sudo grep "Accepted" /var/log/auth.log | tail -20

That last line is the one worth reading properly. Every Accepted entry on this host was publickey, all of them from a single jump host. That fact turned out to matter enormously about ten minutes later, when the next hardening step was going to be a source-IP firewall rule. See why restricting SSH to your own IP is how you lock yourself out.

When this isn't your problem

The one-line version: a fresh cloud host is being guessed at within hours, the account it is guessing at may genuinely accept passwords, and a hardening drop-in named 99- loses to cloud-init's 50- because sshd takes the first value it reads. Name it 10-, verify with sshd -T, and leave root's password set as your console break-glass.