Sh3ll



Directory :  /scripts2/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

Current File : //scripts2/block_wordpress_bruteforce.sh
#!/bin/bash

# Detect and block WordPress brute-force and abuse attempts using Apache access logs and CSF.
# This script is designed for cPanel servers hosting multiple WordPress websites.
# It analyzes a configurable time window from the global Apache access log.
#
# Detected WordPress attack targets:
# - /wp-login.php
# - /xmlrpc.php
# - /wp-admin/admin-ajax.php
#
# The script does not write custom log files.
# Output can be disabled with -nolog, which is useful for cron.
#
# CSF allow/ignore lists should be managed directly in CSF.
# This script does not maintain its own whitelist.
#
# Common usage:
#   /scripts2/block-wordpress-bruteforce.sh
#   /scripts2/block-wordpress-bruteforce.sh -dry
#   /scripts2/block-wordpress-bruteforce.sh -nolog
#   /scripts2/block-wordpress-bruteforce.sh -dry -nolog
#   /scripts2/block-wordpress-bruteforce.sh -window 3600 -ban-mode temp -ban-seconds 86400

set -euo pipefail

LOG_FILE="/usr/local/apache/logs/httpd-access.log"
CSF_BIN="/usr/sbin/csf"

# Default time window to analyze, in seconds.
# 3600 seconds = 1 hour.
WINDOW_SECONDS=3600

# Client IP field in the Apache log.
# cPanel global Apache access log example:
# domain.com:443 1.2.3.4 - - [04/Jul/2026:18:38:37 -0300] "POST /wp-login.php HTTP/1.1" ...
# Therefore, the source IP is field 2.
IP_FIELD=2

# Ban mode:
# temp = temporary block using csf -td
# perm = permanent block using csf -d
BAN_MODE="temp"

# Temporary ban duration, in seconds.
# 86400 seconds = 24 hours.
BAN_SECONDS=86400

# Runtime modes.
DRY_RUN=0
NOLOG=0
VERBOSE=1

# Minimum total suspicious WordPress events required before considering any block.
# IPs below this value will never be blocked, even if other thresholds match.
MIN_TOTAL_EVENTS=500

# Blocking thresholds evaluated over the selected time window.
# These defaults are intentionally conservative.
WP_LOGIN_THRESHOLD=100
XMLRPC_THRESHOLD=300
ADMIN_AJAX_THRESHOLD=300
TOTAL_EVENTS_THRESHOLD=150
CROSS_DOMAIN_MIN_DOMAINS=5
CROSS_DOMAIN_MIN_EVENTS=100
SCORE_THRESHOLD=250

# Lock file to prevent concurrent runs.
LOCK_FILE="/var/run/block-wordpress-bruteforce.lock"

TMP_EVENTS="$(mktemp)"
TMP_COUNTS="$(mktemp)"
TMP_BLOCK="$(mktemp)"

cleanup() {
    rm -f "$TMP_EVENTS" "$TMP_COUNTS" "$TMP_BLOCK"
}
trap cleanup EXIT

print_help() {
    cat <<EOF
WordPress brute-force blocker for cPanel Apache logs and CSF.

Usage:
  $0 [options]

Options:
  -dry
      Dry-run mode. Detect and show what would be blocked, but do not call CSF.

  -nolog
      Non-verbose mode. Do not print informational output.
      Errors are still printed to stderr.

  -quiet
      Same as -nolog.

  -verbose
      Force verbose output.

  -window SECONDS
      Time window to analyze.
      Default: $WINDOW_SECONDS

  -ban-mode temp|perm
      CSF ban mode.
      temp = csf -td IP SECONDS reason
      perm = csf -d IP reason
      Default: $BAN_MODE

  -ban-seconds SECONDS
      Temporary ban duration when using -ban-mode temp.
      Default: $BAN_SECONDS

  -log-file FILE
      Apache access log file to analyze.
      Default: $LOG_FILE

  -csf-bin FILE
      CSF binary path.
      Default: $CSF_BIN

  -min-total-events NUMBER
      Minimum total suspicious WordPress events required before considering any block.
      IPs below this number will never be blocked.
      Default: $MIN_TOTAL_EVENTS

  -wp-login-threshold NUMBER
      Block if wp-login.php POST requests from one IP reach this number.
      Default: $WP_LOGIN_THRESHOLD

  -xmlrpc-threshold NUMBER
      Block if xmlrpc.php POST requests from one IP reach this number.
      Default: $XMLRPC_THRESHOLD

  -admin-ajax-threshold NUMBER
      Block if admin-ajax.php POST requests from one IP reach this number.
      Default: $ADMIN_AJAX_THRESHOLD

  -total-events-threshold NUMBER
      Block if total suspicious WordPress POST requests from one IP reach this number.
      Default: $TOTAL_EVENTS_THRESHOLD

  -cross-domain-min-domains NUMBER
      Minimum number of attacked domains for cross-domain blocking.
      Default: $CROSS_DOMAIN_MIN_DOMAINS

  -cross-domain-min-events NUMBER
      Minimum number of events for cross-domain blocking.
      Default: $CROSS_DOMAIN_MIN_EVENTS

  -score-threshold NUMBER
      Block if calculated score reaches this value.
      Default: $SCORE_THRESHOLD

  -help
      Show this help.

Examples:
  $0
  $0 -dry
  $0 -nolog
  $0 -dry -nolog
  $0 -window 3600 -ban-mode temp -ban-seconds 86400
  $0 -min-total-events 100 -wp-login-threshold 100
EOF
}

info() {
    if [ "$NOLOG" -eq 0 ] && [ "$VERBOSE" -eq 1 ]; then
        echo "$@"
    fi
}

error() {
    echo "$@" >&2
}

is_positive_integer() {
    [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ]
}

while [ "$#" -gt 0 ]; do
    case "$1" in
        -dry|--dry|--dry-run)
            DRY_RUN=1
            shift
            ;;

        -nolog|--nolog|-quiet|--quiet)
            NOLOG=1
            VERBOSE=0
            shift
            ;;

        -verbose|--verbose)
            NOLOG=0
            VERBOSE=1
            shift
            ;;

        -window|--window)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid window value: $2"; exit 1; }
            WINDOW_SECONDS="$2"
            shift 2
            ;;

        -ban-mode|--ban-mode)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            if [ "$2" != "temp" ] && [ "$2" != "perm" ]; then
                error "ERROR: Invalid ban mode: $2. Valid values: temp, perm"
                exit 1
            fi
            BAN_MODE="$2"
            shift 2
            ;;

        -ban-seconds|--ban-seconds)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid ban seconds value: $2"; exit 1; }
            BAN_SECONDS="$2"
            shift 2
            ;;

        -log-file|--log-file)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            LOG_FILE="$2"
            shift 2
            ;;

        -csf-bin|--csf-bin)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            CSF_BIN="$2"
            shift 2
            ;;

        -min-total-events|--min-total-events)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid minimum total events value: $2"; exit 1; }
            MIN_TOTAL_EVENTS="$2"
            shift 2
            ;;

        -wp-login-threshold|--wp-login-threshold)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid wp-login threshold: $2"; exit 1; }
            WP_LOGIN_THRESHOLD="$2"
            shift 2
            ;;

        -xmlrpc-threshold|--xmlrpc-threshold)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid xmlrpc threshold: $2"; exit 1; }
            XMLRPC_THRESHOLD="$2"
            shift 2
            ;;

        -admin-ajax-threshold|--admin-ajax-threshold)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid admin-ajax threshold: $2"; exit 1; }
            ADMIN_AJAX_THRESHOLD="$2"
            shift 2
            ;;

        -total-events-threshold|--total-events-threshold)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid total events threshold: $2"; exit 1; }
            TOTAL_EVENTS_THRESHOLD="$2"
            shift 2
            ;;

        -cross-domain-min-domains|--cross-domain-min-domains)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid cross-domain domains value: $2"; exit 1; }
            CROSS_DOMAIN_MIN_DOMAINS="$2"
            shift 2
            ;;

        -cross-domain-min-events|--cross-domain-min-events)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid cross-domain events value: $2"; exit 1; }
            CROSS_DOMAIN_MIN_EVENTS="$2"
            shift 2
            ;;

        -score-threshold|--score-threshold)
            [ "${2:-}" ] || { error "ERROR: Missing value for $1"; exit 1; }
            is_positive_integer "$2" || { error "ERROR: Invalid score threshold: $2"; exit 1; }
            SCORE_THRESHOLD="$2"
            shift 2
            ;;

        -help|--help|-h)
            print_help
            exit 0
            ;;

        *)
            error "ERROR: Unknown option: $1"
            error "Run: $0 -help"
            exit 1
            ;;
    esac
done

exec 200>"$LOCK_FILE"
flock -n 200 || {
    info "Another instance is already running. Exiting."
    exit 0
}

if [ ! -f "$LOG_FILE" ]; then
    error "ERROR: Log file not found: $LOG_FILE"
    exit 1
fi

if [ ! -x "$CSF_BIN" ]; then
    error "ERROR: CSF binary not found or not executable: $CSF_BIN"
    exit 1
fi

info "============================================================"
info "WordPress brute-force detection"
info "Date: $(date '+%F %T %Z')"
info "Log file: $LOG_FILE"
info "Time window: last $WINDOW_SECONDS seconds"
info "Ban mode: $BAN_MODE"
info "Temporary ban duration: $BAN_SECONDS seconds"
info "Dry run: $DRY_RUN"
info "No log output: $NOLOG"
info "Thresholds:"
info "  minimum total events: $MIN_TOTAL_EVENTS"
info "  wp-login.php: $WP_LOGIN_THRESHOLD"
info "  xmlrpc.php: $XMLRPC_THRESHOLD"
info "  admin-ajax.php: $ADMIN_AJAX_THRESHOLD"
info "  total events: $TOTAL_EVENTS_THRESHOLD"
info "  cross-domain: domains >= $CROSS_DOMAIN_MIN_DOMAINS and events >= $CROSS_DOMAIN_MIN_EVENTS"
info "  score: $SCORE_THRESHOLD"
info "============================================================"

# Parse the Apache access log and classify suspicious WordPress POST requests.
# Output format:
# IP|DOMAIN|RULE|STATUS|METHOD|URI
LC_ALL=C awk -v now="$(date +%s)" \
    -v window="$WINDOW_SECONDS" \
    -v ip_field="$IP_FIELD" '
BEGIN {
    months["Jan"]="01"; months["Feb"]="02"; months["Mar"]="03"; months["Apr"]="04";
    months["May"]="05"; months["Jun"]="06"; months["Jul"]="07"; months["Aug"]="08";
    months["Sep"]="09"; months["Oct"]="10"; months["Nov"]="11"; months["Dec"]="12";

    from = now - window;
}

function valid_ipv4(ip, a, i) {
    if (ip !~ /^([0-9]{1,3}\.){3}[0-9]{1,3}$/) return 0;

    split(ip, a, ".");

    for (i = 1; i <= 4; i++) {
        if (a[i] < 0 || a[i] > 255) return 0;
    }

    return 1;
}

function is_private_or_reserved(ip, a) {
    split(ip, a, ".");

    if (a[1] == 0) return 1;
    if (a[1] == 10) return 1;
    if (a[1] == 127) return 1;
    if (a[1] == 169 && a[2] == 254) return 1;
    if (a[1] == 172 && a[2] >= 16 && a[2] <= 31) return 1;
    if (a[1] == 192 && a[2] == 168) return 1;
    if (a[1] >= 224) return 1;

    return 0;
}

function classify(method, uri) {
    # WordPress login brute-force.
    if (method == "POST" && uri ~ /^\/wp-login\.php([?].*)?$/) {
        return "wp_login";
    }

    # WordPress XML-RPC abuse.
    # This endpoint is commonly abused for credential stuffing and pingback attacks.
    if (method == "POST" && uri ~ /^\/xmlrpc\.php([?].*)?$/) {
        return "xmlrpc";
    }

    # WordPress admin AJAX abuse.
    # This endpoint can be legitimate, so it uses a higher threshold.
    if (method == "POST" && uri ~ /^\/wp-admin\/admin-ajax\.php([?].*)?$/) {
        return "admin_ajax";
    }

    return "";
}

{
    # Extract Apache timestamp.
    if (match($0, /\[([0-9]{2})\/([A-Za-z]{3})\/([0-9]{4}):([0-9]{2}):([0-9]{2}):([0-9]{2}) /, d)) {
        ts = mktime(d[3] " " months[d[2]] " " d[1] " " d[4] " " d[5] " " d[6]);

        if (ts < from || ts > now) {
            next;
        }
    } else {
        next;
    }

    domain = $1;
    ip = $ip_field;

    if (!valid_ipv4(ip)) {
        next;
    }

    if (is_private_or_reserved(ip)) {
        next;
    }

    # Extract HTTP request fields.
    # Example:
    # "POST /wp-login.php HTTP/1.1" 200
    if (!match($0, /"([A-Z]+) ([^ ]+) HTTP\/[0-9.]+"/, r)) {
        next;
    }

    method = r[1];
    uri = r[2];

    # Extract HTTP status code after the request.
    status = "";
    if (match($0, /" [0-9]{3} /)) {
        status = substr($0, RSTART + 2, 3);
    }

    rule = classify(method, uri);

    if (rule != "") {
        print ip "|" domain "|" rule "|" status "|" method "|" uri;
    }
}
' "$LOG_FILE" > "$TMP_EVENTS"

if [ ! -s "$TMP_EVENTS" ]; then
    info
    info "No suspicious WordPress events detected in the selected time window."
    exit 0
fi

info
info "Detected WordPress suspicious events by rule:"
info "------------------------------------------------------------"

if [ "$NOLOG" -eq 0 ] && [ "$VERBOSE" -eq 1 ]; then
    awk -F'|' '
    {
        count[$3]++;
    }
    END {
        printf "%-20s %-10s\n", "RULE", "EVENTS";
        printf "%-20s %-10s\n", "----", "------";

        for (rule in count) {
            printf "%-20s %-10s\n", rule, count[rule];
        }
    }
    ' "$TMP_EVENTS" | sort
fi

info
info "Detected WordPress suspicious events by IP and rule:"
info "------------------------------------------------------------"

if [ "$NOLOG" -eq 0 ] && [ "$VERBOSE" -eq 1 ]; then
    awk -F'|' '
    {
        key = $1 "|" $3;
        count[key]++;
        domains[key "|" $2] = 1;
    }
    END {
        printf "%-18s %-20s %-10s %-10s\n", "IP", "RULE", "REQUESTS", "DOMAINS";
        printf "%-18s %-20s %-10s %-10s\n", "--", "----", "--------", "-------";

        for (key in count) {
            split(key, parts, "|");
            domain_count = 0;

            for (d in domains) {
                split(d, dp, "|");
                if (dp[1] == parts[1] && dp[2] == parts[2]) {
                    domain_count++;
                }
            }

            printf "%-18s %-20s %-10s %-10s\n", parts[1], parts[2], count[key], domain_count;
        }
    }
    ' "$TMP_EVENTS" | sort -k3,3nr
fi

# Aggregate events per IP.
# The script uses endpoint-specific thresholds and score-based detection.
awk -F'|' '
{
    ip = $1;
    domain = $2;
    rule = $3;

    total[ip]++;
    rule_count[ip "|" rule]++;
    seen_domain[ip "|" domain] = 1;
}

END {
    for (ip in total) {
        wp_login = rule_count[ip "|wp_login"] + 0;
        xmlrpc = rule_count[ip "|xmlrpc"] + 0;
        admin_ajax = rule_count[ip "|admin_ajax"] + 0;

        domain_count = 0;

        for (k in seen_domain) {
            split(k, p, "|");

            if (p[1] == ip) {
                domain_count++;
            }
        }

        # Score weights:
        # wp-login.php is the strongest brute-force indicator.
        # xmlrpc.php is also suspicious but may have legitimate integrations.
        # admin-ajax.php is weaker because it can be legitimately noisy.
        score = 0;
        score += wp_login * 3;
        score += xmlrpc * 2;
        score += admin_ajax * 1;

        # Add extra score for cross-domain attacks.
        if (domain_count >= 3) {
            score += 20;
        }

        if (domain_count >= 5) {
            score += 40;
        }

        print ip, total[ip], score, domain_count, wp_login, xmlrpc, admin_ajax;
    }
}
' "$TMP_EVENTS" | sort -k3,3nr > "$TMP_COUNTS"

info
info "WordPress suspicious IP scoring:"
info "------------------------------------------------------------"

if [ "$NOLOG" -eq 0 ] && [ "$VERBOSE" -eq 1 ]; then
    printf "%-18s %-10s %-10s %-10s %-10s %-10s %-12s\n" "IP" "TOTAL" "SCORE" "DOMAINS" "WPLOGIN" "XMLRPC" "ADMINAJAX"
    printf "%-18s %-10s %-10s %-10s %-10s %-10s %-12s\n" "--" "-----" "-----" "-------" "-------" "------" "---------"
    awk '{ printf "%-18s %-10s %-10s %-10s %-10s %-10s %-12s\n", $1, $2, $3, $4, $5, $6, $7 }' "$TMP_COUNTS"
fi

# Apply blocking criteria.
# The minimum total events threshold is mandatory.
# This prevents blocking IPs with low request volume, even if other thresholds match.
awk -v min_total_events="$MIN_TOTAL_EVENTS" \
    -v wp_login_threshold="$WP_LOGIN_THRESHOLD" \
    -v xmlrpc_threshold="$XMLRPC_THRESHOLD" \
    -v admin_ajax_threshold="$ADMIN_AJAX_THRESHOLD" \
    -v total_events_threshold="$TOTAL_EVENTS_THRESHOLD" \
    -v cross_domain_min_domains="$CROSS_DOMAIN_MIN_DOMAINS" \
    -v cross_domain_min_events="$CROSS_DOMAIN_MIN_EVENTS" \
    -v score_threshold="$SCORE_THRESHOLD" '
{
    ip = $1;
    total = $2;
    score = $3;
    domains = $4;
    wp_login = $5;
    xmlrpc = $6;
    admin_ajax = $7;

    reason = "";

    if (total < min_total_events) {
        next;
    }

    if (wp_login >= wp_login_threshold) {
        reason = reason "wp_login_threshold ";
    }

    if (xmlrpc >= xmlrpc_threshold) {
        reason = reason "xmlrpc_threshold ";
    }

    if (admin_ajax >= admin_ajax_threshold) {
        reason = reason "admin_ajax_threshold ";
    }

    if (total >= total_events_threshold) {
        reason = reason "total_events_threshold ";
    }

    if (domains >= cross_domain_min_domains && total >= cross_domain_min_events) {
        reason = reason "cross_domain_threshold ";
    }

    if (score >= score_threshold) {
        reason = reason "score_threshold ";
    }

    if (reason != "") {
        print ip, total, score, domains, wp_login, xmlrpc, admin_ajax, reason;
    }
}
' "$TMP_COUNTS" > "$TMP_BLOCK"

if [ ! -s "$TMP_BLOCK" ]; then
    info
    info "No IP matched the block criteria. No blocking action required."
    exit 0
fi

info
info "IPs matching block criteria:"
info "------------------------------------------------------------"

if [ "$NOLOG" -eq 0 ] && [ "$VERBOSE" -eq 1 ]; then
    printf "%-18s %-8s %-8s %-8s %-8s %-8s %-10s %s\n" "IP" "TOTAL" "SCORE" "DOMAINS" "WPLOGIN" "XMLRPC" "ADMINAJAX" "REASON"
    printf "%-18s %-8s %-8s %-8s %-8s %-8s %-10s %s\n" "--" "-----" "-----" "-------" "-------" "------" "---------" "------"

    awk '{
        ip=$1;
        total=$2;
        score=$3;
        domains=$4;
        wp_login=$5;
        xmlrpc=$6;
        admin_ajax=$7;

        reason="";
        for (i=8; i<=NF; i++) {
            reason=reason $i " ";
        }

        printf "%-18s %-8s %-8s %-8s %-8s %-8s %-10s %s\n", ip, total, score, domains, wp_login, xmlrpc, admin_ajax, reason;
    }' "$TMP_BLOCK"
fi

info
info "CSF actions:"
info "------------------------------------------------------------"

while read -r ip total score domains wp_login xmlrpc admin_ajax reason; do
    [ -z "${ip:-}" ] && continue

    if grep -qE "(^|[^0-9.])${ip}([^0-9.]|$)" /etc/csf/csf.allow 2>/dev/null; then
        info "SKIP: $ip is listed in /etc/csf/csf.allow. Total: $total. Score: $score. Domains: $domains"
        continue
    fi

    if grep -qE "(^|[^0-9.])${ip}([^0-9.]|$)" /etc/csf/csf.ignore 2>/dev/null; then
        info "SKIP: $ip is listed in /etc/csf/csf.ignore. Total: $total. Score: $score. Domains: $domains"
        continue
    fi

    if grep -qE "(^|[^0-9.])${ip}([^0-9.]|$)" /etc/csf/csf.deny 2>/dev/null; then
        info "SKIP: $ip is already listed in /etc/csf/csf.deny. Total: $total. Score: $score. Domains: $domains"
        continue
    fi

    if grep -qE "(^|[^0-9.])${ip}([^0-9.]|$)" /var/lib/csf/csf.tempban 2>/dev/null; then
        info "SKIP: $ip is already listed in CSF temporary bans. Total: $total. Score: $score. Domains: $domains"
        continue
    fi

    csf_reason="WordPress brute force: total=${total}, score=${score}, domains=${domains}, wp_login=${wp_login}, xmlrpc=${xmlrpc}, admin_ajax=${admin_ajax}, window=${WINDOW_SECONDS}s"

    if [ "$DRY_RUN" = "1" ]; then
        info "DRY-RUN: $ip would be blocked. Total: $total. Score: $score. Domains: $domains. Reason: $reason"
        continue
    fi

    if [ "$BAN_MODE" = "perm" ]; then
        if "$CSF_BIN" -d "$ip" "$csf_reason" >/dev/null 2>&1; then
            info "BLOCKED PERMANENTLY: $ip. Total: $total. Score: $score. Domains: $domains. Reason: $reason"
        else
            error "ERROR: Failed to permanently block $ip. Total: $total. Score: $score. Domains: $domains"
        fi
    else
        if "$CSF_BIN" -td "$ip" "$BAN_SECONDS" "$csf_reason" >/dev/null 2>&1; then
            info "BLOCKED TEMPORARILY: $ip. Total: $total. Score: $score. Domains: $domains. Duration: $BAN_SECONDS seconds. Reason: $reason"
        else
            error "ERROR: Failed to temporarily block $ip. Total: $total. Score: $score. Domains: $domains"
        fi
    fi

done < "$TMP_BLOCK"

info
info "Done."

exit 0

Sh3LL