Bash and Linux

Failed SSH Logins per IP

mediumLogs and troubleshooting Must-do

Problem statement

Read an SSH auth log and count failed password attempts per source IP, list which user names were tried, and flag the IPs worth blocking. Any server with SSH open to the internet gets these attempts all day; knowing how to read them is basic security work and a very common interview task.

auth.log (on RHEL-style systems the file is /var/log/secure)

TEXT
Oct 1 09:00:01 web1 sshd[1001]: Failed password for root from 203.0.113.7 port 51000 ssh2
Oct 1 09:00:03 web1 sshd[1002]: Failed password for invalid user admin from 203.0.113.7 port 51002 ssh2
Oct 1 09:00:05 web1 sshd[1003]: Accepted publickey for asha from 198.51.100.2 port 40000 ssh2
Oct 1 09:01:10 web1 sshd[1004]: Failed password for invalid user test from 198.51.100.9 port 33000 ssh2
Oct 1 09:01:12 web1 sshd[1005]: Invalid user oracle from 198.51.100.9 port 33002
Oct 1 09:02:00 web1 sshd[1006]: Failed password for root from 203.0.113.7 port 51010 ssh2
Oct 1 09:02:30 web1 sshd[1007]: Failed password for asha from 192.0.2.44 port 22022 ssh2
  1. Count failed password attempts per IP, most first.
  2. Print the user names that were tried, with counts.
  3. Print the IPs with 3 or more failures as block candidates.
  4. Print the successful logins, so you can check none came from an attacking IP.

Expected output:

TEXT
== failed passwords per IP ==
3 203.0.113.7
1 192.0.2.44
1 198.51.100.9
== user names tried ==
2 root
1 admin
1 asha
1 test
== block candidates (3 or more failures) ==
203.0.113.7 (3 failures)
== successful logins ==
asha from 198.51.100.2

Hints

Hint 1: The position of the IP changes: invalid user admin adds two extra words. Instead of a field number, pull out from and the address with grep -oE 'from [0-9.]+'.

Approach

Optimal: grep -o, sed groups, uniq -c

Covers: sshd log lines, Failed password vs Invalid user vs Accepted, why field numbers break, grep -oE, sed -E with an optional group, counting with uniq -c, thresholds in awk, lastb, journalctl -u ssh.

What sshd writes. Every login attempt leaves a line. The ones that matter:

Message Means
Failed password for root from IP wrong password for a real user
Failed password for invalid user admin from IP the user does not even exist, a typical scan
Invalid user oracle from IP a name that does not exist was tried
Accepted publickey for asha from IP a successful login

Field numbers break here. The IP is field 11 in Failed password for root from 203.0.113.7, but field 13 in Failed password for invalid user admin from 203.0.113.7. awk '{print $11}' gets it wrong for half the lines. The fix is to match the shape of the text instead of counting fields:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB A["... for root from 203.0.113.7 port ..."]:::gray --> G(["grep -oE 'from [0-9.]+'"]):::purple B["... for invalid user admin from 203.0.113.7 ..."]:::gray --> G G --> IP["203.0.113.7
same result for both"]:::green classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
  • grep -oE 'from [0-9.]+' prints only the part from 203.0.113.7, wherever it is on the line, and awk '{print $2}' keeps the address.
  • For the user, (invalid user )? in sed means "these words may or may not be there", so one pattern handles both kinds of line. \2 is the user name that follows.

From counts to action. Counting attempts per IP shows the noisy sources. A threshold turns that into a list worth acting on. Here 203.0.113.7 tried 3 times in two minutes, including the user root, which is a typical brute-force pattern.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB L(["failed lines"]):::blue --> C(["count per IP"]):::purple C --> T{{"3 or more?"}}:::yellow T --> B["block candidate
203.0.113.7"]:::red T --> W["watch
198.51.100.9, 192.0.2.44"]:::gray classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px

Check the successes too. The most important line is often an Accepted one from an IP that was failing a minute earlier. That would mean a guess worked. Here the only success is asha with a key, from an address that never failed.

Walking through the code. The # Setup: lines only create the sample log, so skip past them.

  1. grep 'Failed password' keeps failures, grep -oE pulls out from IP, awk keeps the IP, and sort | uniq -c | sort -k1,1nr -k2,2 counts and ranks them, with ties in IP order.
  2. sed -E keeps the tried user name, then the same counting.
  3. The counts go into awk, which prints IPs with 3 or more failures.
  4. grep 'Accepted' and an awk print the user and IP of each success.

Edge cases. IPv6 addresses contain letters and colons; use from [0-9a-f.:]+ to catch them. Newer systemd systems may not write auth.log at all; use journalctl -u ssh (Debian, Ubuntu) or -u sshd (RHEL) and the same pipeline.

# Setup: create the sample auth log in a fresh temporary folder
cd "$(mktemp -d)"
cat > auth.log << 'LOG'
Oct  1 09:00:01 web1 sshd[1001]: Failed password for root from 203.0.113.7 port 51000 ssh2
Oct  1 09:00:03 web1 sshd[1002]: Failed password for invalid user admin from 203.0.113.7 port 51002 ssh2
Oct  1 09:00:05 web1 sshd[1003]: Accepted publickey for asha from 198.51.100.2 port 40000 ssh2
Oct  1 09:01:10 web1 sshd[1004]: Failed password for invalid user test from 198.51.100.9 port 33000 ssh2
Oct  1 09:01:12 web1 sshd[1005]: Invalid user oracle from 198.51.100.9 port 33002
Oct  1 09:02:00 web1 sshd[1006]: Failed password for root from 203.0.113.7 port 51010 ssh2
Oct  1 09:02:30 web1 sshd[1007]: Failed password for asha from 192.0.2.44 port 22022 ssh2
LOG

echo "== failed passwords per IP =="
grep 'Failed password' auth.log | grep -oE 'from [0-9.]+' | awk '{ print $2 }' |
  sort | uniq -c | sort -k1,1nr -k2,2

echo "== user names tried =="
grep 'Failed password' auth.log |
  sed -E 's/.*Failed password for (invalid user )?([^ ]+) from.*/\2/' |
  sort | uniq -c | sort -k1,1nr -k2,2

echo "== block candidates (3 or more failures) =="
grep 'Failed password' auth.log | grep -oE 'from [0-9.]+' | awk '{ print $2 }' |
  sort | uniq -c | awk '$1 >= 3 { print $2, "(" $1 " failures)" }'

echo "== successful logins =="
grep 'Accepted' auth.log | awk '{ for (i = 1; i < NF; i++) if ($i == "for") u = $(i + 1); else if ($i == "from") ip = $(i + 1); print u, "from", ip }'
RecapThe whole problem in a few lines, for the night before
  • Spot it: "who is trying to brute-force SSH"
  • Idea: grep 'Failed password' | grep -oE 'from [0-9.]+' | sort | uniq -c | sort -rn
  • Cost: one pass over the log, then a sort of the IPs
  • Trap: using a fixed field number, which shifts on invalid user lines

Interview follow-ups

  • Count failures per IP per hour, to tell a slow scan from a burst.

    Keep the time with the IP. The hour is the first part of field 3 in this log format, so awk '/Failed password/ { for (i = 1; i < NF; i++) if ($i == "from") print substr($3, 1, 2) ":00", $(i + 1) }' auth.log | sort | uniq -c. One IP with 200 attempts in one hour is a burst; the same IP with 5 attempts every hour all day is a slow scan, which a short-window threshold would never catch.

Frequently asked questions

The strongest fix is to turn off password logins and use keys only: PasswordAuthentication no in sshd_config (see Audit SSH Config). Then failed passwords cannot succeed at all. fail2ban reads these same log lines and blocks IPs with too many failures for a while, which cuts the noise. Restricting port 22 to known IPs in a firewall or cloud security group, or reaching servers through a bastion or VPN, removes most of the traffic entirely.

sudo lastb lists failed login attempts from the binary /var/log/btmp file, with user, source and time. last shows successful logins from /var/log/wtmp, which is useful to check who really got in. On systemd systems, journalctl -u ssh --since today | grep Failed reads the same messages from the journal. Cloud providers also keep their own logs of console and API logins, which are separate from sshd.

No. Real users mistype passwords, and an office or mobile network can put many people behind one address. Blocking on the first failure can lock out your own team. A threshold over a time window, like 5 failures in 10 minutes, catches automated guessing while leaving room for mistakes, and temporary bans are safer than permanent ones. Keep an allow list for your own office and VPN ranges.