Know your attacker · Part 6
Brute force and credential stuffing: SSH and login forms
Password guessing against SSH and credential stuffing against login forms: what each looks like in auth logs and access logs, the sshd and fail2ban settings that end the first, and what it takes to resist the second.
Some attackers do not look for a flaw at all. They try to log in. Against SSH, they guess passwords for common account names; against your web application’s login form, they replay username and password pairs leaked from other sites. The first is noisy and almost entirely preventable. The second is quiet, distributed, and a problem for the application as much as for the server.
The names
- Brute force — trying many passwords against one account. Dictionary attack when the passwords come from a list of common ones.
- Password spraying — trying one or two very common passwords against many accounts, to stay under per-account lockouts.
- Credential stuffing — replaying real email/password pairs leaked from another service’s breach, betting that people reuse passwords.
What the attacker wants
On SSH: a shell. A server with a guessable password for root, admin, ubuntu or a forgotten test account becomes a cryptominer, a spam relay, a node in the botnet that will attack the next server, or a foothold into your network.
On a login form: accounts. Customer accounts with stored payment methods, loyalty points, gift cards, personal data; admin accounts that control the whole site.
How SSH brute force works
Bots connect to port 22 across the internet, try a handful of user names (root, admin, test, oracle, postgres, ubuntu, git) with common passwords, disconnect, and come back. sshd logs every failure:
journalctl -u ssh --since "1 hour ago" | grep -E "Failed password|Invalid user" | tail
sshd[41233]: Invalid user oracle from 203.0.113.77 port 51022
sshd[41233]: Failed password for invalid user oracle from 203.0.113.77 port 51022 ssh2
sshd[41240]: Failed password for root from 203.0.113.77 port 51544 ssh2
(The unit is ssh on Debian and Ubuntu, sshd on most other distributions; on systems that still log to files, look in /var/log/auth.log or /var/log/secure.) On any server with port 22 open to the internet, this is constant background noise.
How to protect SSH
The decisive measure is that there is no password to guess:
# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
With keys only, a bot can try passwords for ever and never succeed. PermitRootLogin prohibit-password still allows root with a key; use no if you always log in as a regular user. Before reloading, keep a session open and check what sshd will actually apply — a file in sshd_config.d/, such as cloud-init’s 50-cloud-init.conf, can override the main file:
sudo sshd -T | grep -Ei 'passwordauthentication|kbdinteractive|permitrootlogin'
sudo systemctl reload ssh
Then cut the noise, which also cuts the CPU sshd spends on handshakes:
- Restrict who can reach port 22: your office or VPN addresses in the firewall, or a bastion host.
- fail2ban’s
sshdjail. Its defaults ban an address for 10 minutes after 5 failures within 10 minutes; longer bans for repeat offenders are worth enabling:
# /etc/fail2ban/jail.local
[sshd]
enabled = true
maxretry = 5
findtime = 10m
bantime = 1h
bantime.increment = true
- OpenSSH’s own penalties. Since OpenSSH 9.8,
PerSourcePenaltiesis on by default: sshd itself refuses connections for a while from addresses that repeatedly fail to authenticate.
Moving SSH to another port reduces log noise from the dumbest bots. It is not a security measure on its own.
How credential stuffing works
Credential stuffing targets your application, not your server. The attacker has a list of millions of email/password pairs from past breaches. A tool replays them against your login form — usually through a pool of proxies so that each address sends only a few attempts, and with realistic browser headers.
It is hard to see in an access log because a failed login usually looks like a successful one:
198.51.100.31 - - [14/Oct/2026:10:02:11 +0000] "POST /login HTTP/1.1" 200 5120 ...
192.0.2.140 - - [14/Oct/2026:10:02:11 +0000] "POST /login HTTP/1.1" 200 5120 ...
203.0.113.212 - - [14/Oct/2026:10:02:12 +0000] "POST /login HTTP/1.1" 200 5120 ...
Status 200 and the same size: the form was shown again with an error. The signals are elsewhere: a rise in POST /login relative to GET /login, a login success rate that drops, many different accounts tried from the same address, logins from addresses that never loaded the page’s images and scripts, or a sudden wave of password-reset requests from real customers.
On WordPress, xmlrpc.php with system.multicall lets a single request try many passwords — block it if you do not use it.
How to protect a login form
No single setting solves this. Layer them:
- Rate-limit the login endpoint per address, and separately per account in the application:
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server {
location = /login {
limit_req zone=login burst=5 nodelay;
limit_req_status 429;
# ... pass to the application
}
}
- Offer strong second factors: passkeys or TOTP. A stolen password alone is then useless.
- Refuse known-breached passwords at sign-up and password change. The Have I Been Pwned “Pwned Passwords” range API checks a password without revealing it.
- Log login outcomes in the application, with the account and the address, so you can measure success rates and spot one address trying many accounts.
- Use a challenge (CAPTCHA, proof of work) as a last step, triggered by suspicious patterns rather than shown to everyone.
- Use a WAF or a CDN’s bot management if you face a large distributed campaign; per-address limits cannot catch a few attempts from each of ten thousand addresses.
How Demerzel helps
- It checks that SSH has no password to guess. The security checklist on each server reads sshd’s settings —
PasswordAuthentication,KbdInteractiveAuthentication,PermitRootLogin— and whether SSH guessers get blocked at all: SSH not reachable from the internet, a fail2ban SSH jail, or the warden enforcingssh-bruteforce. Each item still to fix comes with the command. When a root-only file insshd_config.d/makes the answer uncertain, the checklist says unknown rather than guessing. - It shows who is guessing. Once you let the agent read sshd’s messages, Demerzel counts failed SSH logins per address and shows the user names tried that do not exist on the server — a bot’s
adminororacle. It never sends the name of a real account, nor any log line. With fail2ban’s log granted too, an address’s dossier shows every ban fail2ban gave it. - It bans SSH guessers, without banning you. The
ssh-bruteforcerule fires on failed logins over 10 connections within 10 minutes. Enforced by the warden after a replay on your own logs, it bans the address — a first offence for an hour, repeat offences for a day, a week, four weeks. Any address logged in over SSH is protected, and stays protected for 24 hours after it logs out, and the agent never counts an address that logged in successfully.
What it does not do: tell a failed login on your web application from a successful one. Demerzel reads access logs, where both are a POST answered with 200. It will see an address hammering /login — the rate-dynamic rule throttles any address sending over 300 dynamic requests in two minutes, and S2-cms-probe catches WordPress login probes on sites that do not run WordPress — but a credential-stuffing campaign spread thin across many addresses needs the application-side measures above.