Skip to main content

2.2 Bash: find the attacker in auth.log

Bash is the shell on effectively every Linux server on earth, which means it's the language of your Ubuntu box, your Kali box, and every mid-incident moment of your future career. Its superpower is the pipe: small tools chained together, each one transforming the text and passing it along, like an assembly line where every station does one job.

Today's task is the classic first job of a security analyst: a server's auth.log is full of failed logins, and you want to know who's knocking. During a tuning project where my SIEM was producing six figures of alerts a day, the triage that found the worst offenders was exactly the pipeline you're about to build. This one-liner family has never stopped earning its keep.

Windows users, you're in this lesson, in Git Bash, per the shell rule in lesson 1.3. This is the one where that choice starts paying off: grep and awk below are genuinely bash tools with no PowerShell equivalent you'd want to type.

Set the scene

You need a log to dig through. Real auth.log files come from SSH servers you don't have yet, so plant this sample. Paste the whole block into your terminal; it writes a file called auth.log in your current directory. (The cat > file << 'EOF' trick is called a here-document, and it means "everything until the line EOF goes into the file.")

cd ~/git/lab-journal/Resources/scripts

cat > auth.log << 'EOF'
Aug 1 03:11:01 web01 sshd[812]: Failed password for root from 203.0.113.42 port 52144 ssh2
Aug 1 03:11:04 web01 sshd[812]: Failed password for root from 203.0.113.42 port 52144 ssh2
Aug 1 03:11:07 web01 sshd[812]: Failed password for admin from 203.0.113.42 port 52190 ssh2
Aug 1 03:12:13 web01 sshd[820]: Failed password for invalid user oracle from 198.51.100.7 port 40022 ssh2
Aug 1 03:12:19 web01 sshd[820]: Failed password for invalid user postgres from 198.51.100.7 port 40025 ssh2
Aug 1 06:30:44 web01 sshd[955]: Accepted password for steve from 192.0.2.10 port 51820 ssh2
Aug 1 07:02:11 web01 sshd[990]: Failed password for root from 203.0.113.42 port 53001 ssh2
Aug 1 07:02:15 web01 sshd[990]: Failed password for root from 203.0.113.42 port 53003 ssh2
Aug 1 07:15:33 web01 sshd[1003]: Accepted publickey for steve from 192.0.2.10 port 51944 ssh2
Aug 1 08:41:09 web01 sshd[1100]: Failed password for invalid user test from 198.51.100.7 port 41888 ssh2
EOF

(Those addresses are from ranges reserved for documentation, so nobody's real server is being accused of anything.)

The pipeline, one station at a time

Build it up a piece at a time and run each stage. Watching the text transform is the lesson.

# Stage 1: keep only the lines about failed passwords.
# grep prints lines matching a pattern and drops the rest.
grep "Failed password" auth.log

# Stage 2: keep only the attacker's IP from each line.
# awk splits each line into fields. $NF means "the last field",
# so $(NF-3) is "three fields before the last one", where the IP
# sits in this log format.
grep "Failed password" auth.log | awk '{print $(NF-3)}'

# Stage 3: count them.
# sort groups identical lines together; uniq -c collapses each
# group and prefixes it with a count; sort -rn puts the biggest
# count first. This trio is the most useful ten characters in
# log analysis.
grep "Failed password" auth.log | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn

That final output is a ranked list of who's attacking you. Five tries from 203.0.113.42 and three from 198.51.100.7, discovered in one line, from a log you could never have read by eye at real size. Real auth logs run to millions of lines, and this pipeline doesn't care.

Now make it a script

One-liners evaporate; scripts accumulate. In VS Code, which you set up in lesson 2.1, right-click Resources/scripts in the file tree, choose New File, and name it failed-logins.sh, extension included. Paste this in and save it next to the log you planted a moment ago:

#!/usr/bin/env bash
# failed-logins.sh: rank source IPs by failed SSH login attempts.
# Usage: ./failed-logins.sh <logfile>

# The first line above is the "shebang": it tells the system which
# program runs this file.

# $1 is the first argument given on the command line. We store it
# in a named variable so the script reads like a sentence.
logfile="$1"

# A safety check. [ -z ... ] tests for an empty value; if nobody
# gave us a file, explain and stop rather than fail confusingly.
if [ -z "$logfile" ]; then
echo "Usage: $0 <logfile>"
exit 1
fi

echo "Failed login attempts by source IP for: $logfile"
echo

grep "Failed password" "$logfile" | awk '{print $(NF-3)}' | sort | uniq -c | sort -rn

Windows users: the invisible character that breaks this

Before you run it, look at the bottom-right of the VS Code window, at the LF or CRLF indicator from lesson 2.1. If it says CRLF, click it and choose LF, then save again.

That indicator is worth understanding, because this is a genuinely confusing failure and you'll meet it for years. Windows ends each line of a text file with two invisible characters, carriage return plus line feed, written \r\n. Linux and macOS use just the line feed, \n. Windows editors default to the Windows convention, quite reasonably.

Bash does not forgive it. The first line of your script says the file should be run by bash, but with Windows line endings it actually says run this with a program called bash\r, and no such program exists. The error is spectacularly unhelpful:

/usr/bin/env: ‘bash\r’: No such file or directory

The exact wording varies between Git Bash and Linux; the meaning doesn't. Any error naming a command you did type, with something odd stuck to the end of it, is this. It is not your pipeline, your quoting, or your typing.

If it already happened, fix the file in place from the terminal:

# Delete a carriage return at the end of every line. The $ means
# "at end of line", so it only removes the ones causing trouble.
sed -i 's/\r$//' failed-logins.sh

# Confirm. A healthy script says "Bourne-Again shell script"; a sick
# one adds "with CRLF line terminators".
file failed-logins.sh

Linux and macOS students: nothing above applies to you today. Read it anyway. The day you're handed a script that somebody wrote on Windows, you'll recognise it in five seconds instead of an hour.

Make it executable and run it

chmod changes a file's permissions, and +x adds "may be executed":

chmod +x failed-logins.sh
./failed-logins.sh auth.log

A file you can execute is not the same as a file you can read, which is why this step exists at all. Bash refuses to run a script that nobody gave permission to run, and Permission denied here means you skipped the chmod.

Run it with no argument as well, to watch the safety check you wrote do its job:

./failed-logins.sh
Usage: ./failed-logins.sh <logfile>

You just wrote a program with an argument, a variable, a conditional, and a pipeline in it. That's most of daily bash, and you met each piece inside a job worth doing.

Make it yours

  1. Change the script to report accepted logins instead. One word. Notice what it tells you: which IPs successfully got in, which is the list you check after seeing a brute-force attempt.
  2. Harder: make it report the usernames being guessed instead of the IPs. Try awk '{print $9}' first, counting fields from the front, and watch it work on the root lines but fall apart on the "invalid user" lines, which shove the name two fields to the right. Then count from the end instead: $(NF-5). Working out why the second one lands on the name in both kinds of line is the exercise, and it's the same trick the IP stage was quietly using all along.