|
Directory : /scripts2/ |
#!/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