SSH Brute Force Protection with SSH Proxy

This script is designed to add brute force protection for SSH services on a virtual server. It makes use of the Protocol Security feature called SSH Proxy which makes it possible to look into the ssh session and see the actual logon results.

Documentation for setting up SSH Proxy can be found here: https://techdocs.f5.com/en-us/bigip-14-1-0/big-ip-network-firewall-policies-and-implementations-14-1-0/ssh-proxy-security.html

The script basically looks in the ltm log for these lines:

Jun  8 11:40:57 f5-01 info tmm[12656]: 23003164 "Jun 08 2025 11:40:57","ssh_serverside_auth_fail","10.1.0.2","10.10.9.64","49862","22","4092","TCP","root","Password authentication failure"

and counts the number of failed attempts for each IP.

When a threshold has been reached that IP goes into a AFM address-list. This address-list needs to be part of a rule and policy, and attached to the SSH VS. This way be can block access for that source. The name of the address-list is specified in a variable in the script, so you can adjust it easily for your needs.

You should use iCall to run the script periodically:

sys icall script ssh_bf {
    app-service none
    definition {
        exec /shared/scripts/ban_failed_ssh.sh > /var/log/ssh_log.log
    }
    description none
    events none
}

sys icall handler periodic ssh_bf {
    interval 300
    script ssh_bf
}

Make sure that the location of the script matches the exec statement in the iCall script, and that the script is executable. If you are having an HA, you also need to make sure you have it on both units.

You can consume the above config by running this command:

tmsh load sys config merge from-terminal

and simply paste it in and on an empty line hit CTRL-d.

Next you need to have a logging profile configured with these settings:

It is important you have that particular publisher set as it is sending the failures to the ltm log. You should be aware of the limitations of logging locally before you make use of this solution. If it is a very busy unit the disk might not be happy with some extra writing. In a perfect world you could log these failures to a remote logging server, but then you need to have the counting logic running elsewhere and have the script use the API to ingest the IPs. Or make use of a IP Intelligence feed which can load the IPs from a webserver. This is however out of scope for this solution, but I might make a follow up article on this if time allows it.

With the AFM policy on the SSH VS and the logging profile attached, you now only need to save this script to your favorite script location on the BigIP:

#!/bin/bash
#
# ban_failed_ssh.sh - Detect and ban IPs with repeated SSH login failures from /var/log/ltm
#
# This script uses AFM address lists and maintains a state file with banned IPs and expiry timestamps.
# Author: Thomas Domingo Dahlmann
#

# --- Configuration ---
LOG_FILE="/var/log/ltm"
STATE_FILE="/var/tmp/ssh_ban_state.csv"
AFM_ADDR_LIST="/Common/ssh_banned_ips"
DUMMY_IP="192.0.2.254"
BAN_THRESHOLD=3
BAN_WINDOW_SECONDS=300   # 5 minutes
BAN_DURATION_SECONDS=1800 # 1 hour
TMP_LOG="/var/tmp/ssh_ban_tmp.log"
DEBUG=1  # Set to 1 for verbose debug logging, 0 to disable

# --- Logging helper ---
log_debug() {
    [[ "$DEBUG" -eq 1 ]] && echo "[DEBUG] $(date '+%F %T') - $*" >&2
}

# --- Ensure state file and dummy IP exists ---
ensure_state_file() {
    touch "$STATE_FILE"
    if ! grep -q "^$DUMMY_IP," "$STATE_FILE"; then
        echo "$DUMMY_IP,9999999999" >> "$STATE_FILE"
        log_debug "Adding dummy IP $DUMMY_IP to state file and AFM list"
        tmsh modify security firewall address-list "$AFM_ADDR_LIST" { addresses add { "$DUMMY_IP" } } 2>/dev/null
    else
        log_debug "Dummy IP $DUMMY_IP already present in state"
    fi
}

# --- Remove expired IPs from state and AFM ---
cleanup_expired_bans() {
    local now
    now=$(date +%s)
    local new_state=()

    log_debug "Cleaning up expired bans at epoch time $now"
    while IFS=',' read -r ip expiry; do
        [[ -z "$ip" || -z "$expiry" ]] && continue

        if [[ "$ip" == "$DUMMY_IP" ]]; then
            new_state+=("$ip,9999999999")
            continue
        fi

        if (( expiry > now )); then
            log_debug "Keeping active ban for $ip (expires at $expiry)"
            new_state+=("$ip,$expiry")
        else
            log_debug "Unbanning expired IP $ip (expired at $expiry)"
            tmsh modify security firewall address-list "$AFM_ADDR_LIST" { addresses delete { "$ip" } } 2>/dev/null
        fi
    done < "$STATE_FILE"

    printf "%s\n" "${new_state[@]}" > "$STATE_FILE"
    log_debug "State file rewritten with ${#new_state[@]} active entries"
}

# --- Add or update ban for IP ---
ban_ip() {
    local ip=$1
    local expiry=$(( $(date +%s) + BAN_DURATION_SECONDS ))

    log_debug "Initiating ban for IP $ip until $(date -d "@$expiry") [$expiry]"

    # Remove old entry
    grep -v "^$ip," "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"
    log_debug "Removed previous entry for $ip (if any)"

    # Add new ban
    echo "$ip,$expiry" >> "$STATE_FILE"
    log_debug "Added $ip,$expiry to $STATE_FILE"

    # Add to AFM list
    tmsh modify security firewall address-list "$AFM_ADDR_LIST" { addresses add { "$ip" } } 2>/dev/null
    log_debug "Added IP $ip to AFM address list $AFM_ADDR_LIST"
}

# --- Check recent log entries for violations ---
check_for_new_violations() {
    local now start_epoch
    now=$(date +%s)
    start_epoch=$(( now - BAN_WINDOW_SECONDS ))

    log_debug "Checking for new violations between $start_epoch and $now"

    > "$TMP_LOG"
    awk -v threshold="$start_epoch" '
        match($0, /"([A-Z][a-z]{2} [ 0-9][0-9] [0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2})","ssh_.*auth_fail","([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)"/, m) {
            cmd = "date -d \"" m[1] "\" +%s"
            cmd | getline epoch
            close(cmd)
            if (epoch >= threshold) {
                print epoch, m[2]
            }
        }
    ' "$LOG_FILE" > "$TMP_LOG"

    log_debug "Parsed entries written to $TMP_LOG:"
    [[ "$DEBUG" -eq 1 ]] && cat "$TMP_LOG" >&2

    awk '{count[$2]++} END { for (ip in count) if (count[ip] >= '"$BAN_THRESHOLD"') print ip }' "$TMP_LOG" | while read -r ip; do
        log_debug "Found IP with threshold violations: $ip"
        if grep -q "^$ip," "$STATE_FILE"; then
            log_debug "IP $ip already in state file, skipping ban"
        else
            ban_ip "$ip"
        fi
    done
}

# --- Main ---
log_debug "==== SSH Ban Monitor Started ===="
ensure_state_file
cleanup_expired_bans
check_for_new_violations
log_debug "==== SSH Ban Monitor Finished ===="

 

Happy hunting 😄

Published Jun 12, 2025
Version 1.0
No CommentsBe the first to comment