Back to posts
Post

Fail2ban + LLM: Auto-block SSH brute force using log analysis

Use an LLM to analyze SSH auth logs and dynamically adjust Fail2ban bans for smarter brute force defense.

Yapay ZekaFail2banLLMSSHintrusion prevention

I’ve been running Fail2ban on my public SSH servers for years. It works well out of the box, but static ban times and static ignore rules often miss the mark—either letting persistent attackers linger too long or locking out legitimate users who fat-finger a password. Recently I started feeding Fail2ban’s log output into a local LLM to see if I could make the response more adaptive. The idea is simple: instead of relying solely on regex match counts, let the LLM judge the intent behind each failed login attempt and suggest a custom ban duration or even a permanent block.

Here’s how I set it up in my environment.

Why static Fail2ban rules fall short

Fail2ban excels at pattern matching. You define a filter, set a maxretry, and it bans the IP after N failures within a time window. That’s solid for noisy bots trying credential stuffing with default passwords. But targeted attacks often stay just under the threshold—say, three failed attempts every ten minutes—to avoid triggering the ban. Meanwhile, a sysadmin typing the wrong password twice gets locked out for ten minutes because the jail doesn’t distinguish between fatigue and malice.

I noticed this pattern in my auth logs: low-volume, repeated attempts from the same IP over hours or days, sometimes with usernames that don’t exist on the system. These aren’t random scans; they’re reconnaissance. Fail2ban’s default jails don’t weigh username validity or timing patterns—they just count matches.

Feeding logs to an LLM for context

I use a small, locally hosted LLM (Llama 3 via Ollama) to avoid sending auth data to third parties. Every time Fail2ban triggers a ban, I run a script that pulls the last 20 failed attempts for that IP from /var/log/auth.log and passes them to the model with a prompt like this:

Analyze these SSH login failures. Determine if this is likely a brute force attack, credential stuffing, or human error. Recommend: 1) ban duration (minutes), 2) whether to escalate to permanent block, and 3) confidence level (low/medium/high).

[LOG LINES HERE]

The model returns a JSON-ish response I parse with jq. For example:

{
  "assessment": "brute force",
  "recommended_ban_minutes": 1440,
  "permanent_block": false,
  "confidence": "high"
}

If confidence is medium or high, I adjust the Fail2ban jail dynamically using the fail2ban-client command.

Automating the response with a helper script

I created /usr/local/bin/fail2ban-llm-adjust.sh and made it executable. It gets called from Fail2ban’s action.d via an actionstart or actionban hook.

Here’s the core of the script:

#!/bin/bash
IP="$1"
JAIL="$2"

# Grab recent failures for this IP
LOG LINES=$(grep "$IP" /var/log/auth.log | grep "Failed password" | tail -20)

if [ -z "$LOG LINES" ]; then
  exit 0
fi

# Call local LLM via Ollama
RESPONSE=$(ollama run llama3 "Analyze these SSH login failures. Determine if this is likely a brute force attack, credential stuffing, or human error. Recommend: 1) ban duration (minutes), 2) whether to escalate to permanent block, and 3) confidence level (low/medium/high).\n\n$LOG LINES" | tail -1)

# Extract values (simple parsing; improve for production)
BAN_MIN=$(echo "$RESPONSE" | grep -o '"recommended_ban_minutes":[0-9]*' | cut -d: -f2)
PERM=$(echo "$RESPONSE" | grep -o '"permanent_block":[true|false]' | cut -d: -f2 | tr -d ' ',)
CONF=$(echo "$RESPONSE" | grep -o '"confidence":[a-z]*' | cut -d: -f2 | tr -d ' "')

if [ "$CONF" = "medium" ] || [ "$CONF" = "high" ]; then
  if [ "$BAN_MIN" -gt 0 ]; then
    fail2ban-client set "$JAIL" banip "$IP" bantime "$BAN_MIN"
  fi
  if [ "$PERM" = "true" ]; then
    # Add to permanent block list (e.g., ipset or firewall)
    /usr/local/bin/add-to-permanent-block.sh "$IP"
  fi
fi

I hook this into the sshd jail by adding to /etc/fail2ban/action.d/ssh.conf:

actionban = /usr/local/bin/fail2ban-llm-adjust.sh <ip> sshd

Tuning and safety considerations

This isn’t a drop-in replacement for Fail2ban’s built-in logic—it’s a supplement. I still rely on the regex filters to catch the obvious stuff. The LLM only runs after a ban is triggered, so it adds overhead but not latency to the initial block.

I added a few safeguards:

  • The script exits if the LLM doesn’t respond within 10 seconds (using timeout in the actual version).
  • I whitelist known admin IPs so they never get fed to the model, even if they fail auth.
  • All logging goes to /var/log/fail2ban-llm.log for auditing.

In my first week, the LLM suggested permanent blocks for two IPs that were trying non-existent usernames like ‘admin’, ‘root’, and ‘user’ in slow succession—exactly the low-and-slow pattern that evaded static rules. It also reduced ban time for a few IPs that looked like typos from a known subnet.

Where this could go next

I’m experimenting with feeding the LLM additional context: time of day, geographic origin (via ipinfo.io, cached), and whether the username exists in /etc/passwd. The goal isn’t to replace rule-based systems but to augment them with lightweight judgment where patterns are ambiguous.

If you’re running Fail2ban and want to reduce false positives without weakening defenses, try pairing it with a local LLM. Start small—just log the model’s recommendations before acting on them. As I mentioned before in my post about using LLMs with syslog for sudo monitoring, the key is keeping the model local and the prompts focused.

This approach won’t stop a determined attacker, but it makes the noise floor smarter—and that’s half the battle in log-driven defense.


Cover image: solaranamnesis-pub · CC0 (Openverse / kamu malı) · https://www.flickr.com/photos/202852857@N07/55059766538