diff --git a/adguard-shield.conf b/adguard-shield.conf index 1a9fb04..9d0156f 100644 --- a/adguard-shield.conf +++ b/adguard-shield.conf @@ -21,6 +21,22 @@ RATE_LIMIT_WINDOW=60 # Wie oft das Script die Logs prüft (in Sekunden) CHECK_INTERVAL=10 +# --- Subdomain-Flood-Erkennung (Random Subdomain Attack) --- +# Erkennt Bots/Clients die massenhaft zufällige Subdomains einer Domain abfragen +# Beispiel: abc123.microsoft.com, xyz456.microsoft.com, ... +# Dabei wird pro Client gezählt, wie viele EINDEUTIGE Subdomains einer +# Basisdomain (z.B. microsoft.com) im Zeitfenster aufgerufen werden. + +# Subdomain-Flood-Erkennung aktivieren +SUBDOMAIN_FLOOD_ENABLED=true + +# Maximale Anzahl eindeutiger Subdomains pro Basisdomain pro Client im Zeitfenster +# Beispiel: 50 = ein Client darf max. 50 verschiedene Subdomains von microsoft.com abfragen +SUBDOMAIN_FLOOD_MAX_UNIQUE=50 + +# Zeitfenster in Sekunden für die Subdomain-Flood-Erkennung (60 = 1 Minute) +SUBDOMAIN_FLOOD_WINDOW=60 + # --- Sperr-Einstellungen --- # Wie lange ein Client gesperrt wird (in Sekunden, 3600 = 1 Stunde) BAN_DURATION=3600 diff --git a/adguard-shield.sh b/adguard-shield.sh index 2250ce0..3fe078b 100644 --- a/adguard-shield.sh +++ b/adguard-shield.sh @@ -5,11 +5,11 @@ # # Autor: Patrick Asmus # E-Mail: support@techniverse.net -# Datum: 2026-03-03 +# Datum: 2026-03-04 # Lizenz: MIT ############################################################################### -VERSION="0.3.1" +VERSION="0.4.0" set -euo pipefail @@ -111,8 +111,8 @@ get_offense_level() { fi local level last_offense now reset_after - level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2) - last_offense=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" | cut -d= -f2) + level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2 || true) + last_offense=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" | cut -d= -f2 || true) now=$(date '+%s') reset_after="${PROGRESSIVE_BAN_RESET_AFTER:-86400}" @@ -141,7 +141,7 @@ increment_offense_level() { # Erstes Vergehen merken (bevor Datei überschrieben wird) local first_offense - first_offense=$(grep '^FIRST_OFFENSE=' "$offense_file" 2>/dev/null | cut -d= -f2) + first_offense=$(grep '^FIRST_OFFENSE=' "$offense_file" 2>/dev/null | cut -d= -f2 || true) [[ -z "$first_offense" ]] && first_offense="$now_readable" cat > "$offense_file" << EOF @@ -296,6 +296,8 @@ ban_client() { local client_ip="$1" local domain="$2" local count="$3" + local reason="${4:-rate-limit}" + local window="${5:-$RATE_LIMIT_WINDOW}" # Prüfen ob bereits gesperrt local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban" @@ -333,18 +335,18 @@ ban_client() { if [[ "$DRY_RUN" == "true" ]]; then if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s) für ${duration_display} [Stufe $offense_level]" + log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${window}s) für ${duration_display} [Stufe $offense_level] [${reason}]" else - log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s)" + log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${window}s) [${reason}]" fi - log_ban_history "DRY" "$client_ip" "$domain" "$count" "dry-run" "${duration_display}" + log_ban_history "DRY" "$client_ip" "$domain" "$count" "dry-run (${reason})" "${duration_display}" return 0 fi if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]]; then - log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s) für ${duration_display} [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]" + log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${window}s) für ${duration_display} [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}] [${reason}]" else - log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s) für ${duration_display}" + log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${window}s) für ${duration_display} [${reason}]" fi # IPv4 oder IPv6 erkennen @@ -367,16 +369,17 @@ BAN_UNTIL=$ban_until_display BAN_DURATION=${effective_duration} OFFENSE_LEVEL=$offense_level IS_PERMANENT=$is_permanent +REASON=$reason EOF # Ban-History Eintrag local history_duration="${duration_display}" [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" ]] && history_duration="${duration_display} (Stufe ${offense_level})" - log_ban_history "BAN" "$client_ip" "$domain" "$count" "rate-limit" "$history_duration" + log_ban_history "BAN" "$client_ip" "$domain" "$count" "$reason" "$history_duration" # Benachrichtigung senden if [[ "$NOTIFY_ENABLED" == "true" ]]; then - send_notification "ban" "$client_ip" "$domain" "$count" "$offense_level" "$duration_display" + send_notification "ban" "$client_ip" "$domain" "$count" "$offense_level" "$duration_display" "$reason" "$window" fi } @@ -389,7 +392,7 @@ unban_client() { # Domain aus State lesen bevor wir löschen local domain="-" if [[ -f "$state_file" ]]; then - domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2) + domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2 || true) fi log "INFO" "ENTSPERRE Client: $client_ip ($reason)" @@ -419,11 +422,11 @@ check_expired_bans() { [[ -f "$state_file" ]] || continue local ban_until_epoch - ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" | cut -d= -f2) + ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" | cut -d= -f2 || true) local client_ip - client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) + client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2 || true) local is_permanent - is_permanent=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2) + is_permanent=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2 || true) # Permanente Sperren nicht automatisch aufheben if [[ "$is_permanent" == "true" || "$ban_until_epoch" == "0" ]]; then @@ -445,20 +448,25 @@ send_notification() { local count="$4" local offense_level="${5:-}" local duration_display="${6:-}" + local reason="${7:-rate-limit}" + local window="${8:-$RATE_LIMIT_WINDOW}" # Ntfy benötigt keine Webhook-URL (nutzt NTFY_SERVER_URL + NTFY_TOPIC) if [[ "$NOTIFY_TYPE" != "ntfy" && -z "$NOTIFY_WEBHOOK_URL" ]]; then return fi + local reason_label="Rate-Limit" + [[ "$reason" == "subdomain-flood" ]] && reason_label="Subdomain-Flood" + local message if [[ "$action" == "ban" ]]; then if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" == "true" && -n "$offense_level" ]]; then - message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${RATE_LIMIT_WINDOW}s). Sperre für **${duration_display}** [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]." + message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${window}s, ${reason_label}). Sperre für **${duration_display}** [Stufe ${offense_level}/${PROGRESSIVE_BAN_MAX_LEVEL:-0}]." else local simple_dur simple_dur=$(format_duration "${BAN_DURATION}") - message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${RATE_LIMIT_WINDOW}s). Sperre für ${simple_dur}." + message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${window}s, ${reason_label}). Sperre für ${simple_dur}." fi elif [[ "$action" == "service_start" ]]; then message="🟢 AdGuard Shield v${VERSION} wurde gestartet." @@ -651,6 +659,112 @@ analyze_queries() { done <<< "$violations" } +# ─── Subdomain-Flood-Erkennung ────────────────────────────────────────────── +# Erkennt Random-Subdomain-Attacken: Bots die massenhaft zufällige Subdomains +# einer Domain abfragen (z.B. abc123.microsoft.com, xyz456.microsoft.com, ...) +# Zählt eindeutige Subdomains pro Basisdomain und Client im Zeitfenster +analyze_subdomain_flood() { + local api_response="$1" + + if [[ "${SUBDOMAIN_FLOOD_ENABLED:-false}" != "true" ]]; then + return + fi + + local now_epoch + now_epoch=$(date '+%s') + local window="${SUBDOMAIN_FLOOD_WINDOW:-60}" + local window_start=$((now_epoch - window)) + local max_unique="${SUBDOMAIN_FLOOD_MAX_UNIQUE:-50}" + + log "DEBUG" "Subdomain-Flood-Prüfung: max ${max_unique} eindeutige Subdomains pro Basisdomain in ${window}s" + + # jq-Analyse: Gruppiere nach Client + Basisdomain, zähle eindeutige Subdomains + local violations="" + violations=$(echo "$api_response" | jq -r --argjson window_start "$window_start" --argjson max_unique "$max_unique" ' + # Basisdomain extrahieren (eTLD+1) + # Behandelt gängige Multi-Part-TLDs wie .co.uk, .com.au, .co.jp etc. + def base_domain: + split(".") | + if length <= 2 then join(".") + elif ((.[-2:] | join(".")) | test("^(co|com|net|org|gov|edu|ac|gv|ne|or|go)\\.[a-z]{2,3}$")) then + if length >= 3 then .[-3:] | join(".") else join(".") end + else + .[-2:] | join(".") + end; + + # ISO 8601 Timestamp zu Unix-Epoch konvertieren + def to_epoch: + sub("\\.[0-9]+(?=[+-Z])"; "") | + if endswith("Z") then + fromdateiso8601 + elif test("[+-][0-9]{2}:[0-9]{2}$") then + (.[:-6]) as $base | + (.[-6:-5]) as $sign | + (.[-5:-3] | tonumber) as $h | + (.[-2:] | tonumber) as $m | + ($base + "Z" | fromdateiso8601) + + (if $sign == "+" then -1 else 1 end * ($h * 3600 + $m * 60)) + else + fromdateiso8601 + end; + + .data // [] | + [.[] | + select(.time != null) | + select((.time | to_epoch) >= $window_start) | + ((.question.name // .question.host // "unknown") | rtrimstr(".")) as $domain | + ($domain | base_domain) as $base | + { + client: (.client // .client_info.ip // "unknown"), + domain: $domain, + base_domain: $base + } + ] | + # Nur Einträge mit echten Subdomains (domain != base_domain) + [.[] | select(.domain != .base_domain)] | + group_by(.client + "|" + .base_domain) | + map({ + client: .[0].client, + base_domain: .[0].base_domain, + unique_subdomains: ([.[].domain] | unique | length), + total_queries: length, + example_domains: ([.[].domain] | unique | .[0:3] | join(", ")) + }) | + .[] | + select(.unique_subdomains > $max_unique) | + "\(.client)|\(.base_domain)|\(.unique_subdomains)|\(.total_queries)|\(.example_domains)" + ') || { + log "ERROR" "jq Subdomain-Flood-Analyse fehlgeschlagen" + return + } + + if [[ -z "$violations" ]]; then + log "DEBUG" "Keine Subdomain-Flood-Verstöße erkannt" + return + fi + + # Gefundene Verstöße verarbeiten + while IFS='|' read -r client base_domain unique_count total_count examples; do + [[ -z "$client" || -z "$base_domain" || -z "$unique_count" ]] && continue + + log "WARN" "Subdomain-Flood erkannt: $client → ${unique_count} eindeutige Subdomains von $base_domain (${total_count} Anfragen, z.B. $examples)" + + if is_whitelisted "$client"; then + log "INFO" "Client $client ist auf der Whitelist - keine Sperre (Subdomain-Flood: ${unique_count}x $base_domain)" + continue + fi + + # Prüfen ob bereits gesperrt + local state_file="${STATE_DIR}/${client//[:\/]/_}.ban" + if [[ -f "$state_file" ]]; then + log "DEBUG" "Client $client ist bereits gesperrt (Subdomain-Flood übersprungen)" + continue + fi + + ban_client "$client" "*.${base_domain}" "$unique_count" "subdomain-flood" "$window" + done <<< "$violations" +} + # ─── Status anzeigen ───────────────────────────────────────────────────────── show_status() { echo "═══════════════════════════════════════════════════════════════" @@ -667,26 +781,49 @@ show_status() { echo "" fi + # Subdomain-Flood-Schutz Info + if [[ "${SUBDOMAIN_FLOOD_ENABLED:-false}" == "true" ]]; then + echo " 🌐 Subdomain-Flood-Schutz: AKTIV" + echo " Max eindeutige Subdomains: ${SUBDOMAIN_FLOOD_MAX_UNIQUE:-50} pro Basisdomain" + echo " Zeitfenster: ${SUBDOMAIN_FLOOD_WINDOW:-60}s" + echo "" + fi + # Aktive Sperren local ban_count=0 if [[ -d "$STATE_DIR" ]]; then for state_file in "${STATE_DIR}"/*.ban; do [[ -f "$state_file" ]] || continue ban_count=$((ban_count + 1)) - local s_ip s_domain s_level s_perm s_dur s_until - s_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) - s_domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2) - s_level=$(grep '^OFFENSE_LEVEL=' "$state_file" | cut -d= -f2) - s_perm=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2) - s_dur=$(grep '^BAN_DURATION=' "$state_file" | cut -d= -f2) - s_until=$(grep '^BAN_UNTIL=' "$state_file" | cut -d= -f2) + local s_ip s_domain s_level s_perm s_dur s_until s_reason s_count + s_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2 || true) + s_domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2 || true) + s_level=$(grep '^OFFENSE_LEVEL=' "$state_file" | cut -d= -f2 || true) + s_perm=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2 || true) + s_dur=$(grep '^BAN_DURATION=' "$state_file" | cut -d= -f2 || true) + s_until=$(grep '^BAN_UNTIL=' "$state_file" | cut -d= -f2 || true) + s_reason=$(grep '^REASON=' "$state_file" | cut -d= -f2 || true) + s_count=$(grep '^COUNT=' "$state_file" | cut -d= -f2 || true) + s_reason="${s_reason:-rate-limit}" + + local reason_tag="" + [[ "$s_reason" == "subdomain-flood" ]] && reason_tag=" (Subdomain-Flood)" + + local count_info="" + if [[ -n "$s_count" && "$s_count" != "-" ]]; then + if [[ "$s_reason" == "subdomain-flood" ]]; then + count_info=", ${s_count} Subdomains" + else + count_info=", ${s_count} Anfragen" + fi + fi if [[ "$s_perm" == "true" ]]; then - echo " 🚫 Gesperrt: $s_ip → $s_domain [PERMANENT, Stufe ${s_level:-?}]" + echo " 🚫 Gesperrt: $s_ip → $s_domain [PERMANENT, Stufe ${s_level:-?}${count_info}]${reason_tag}" elif [[ -n "$s_level" && "$s_level" -gt 0 ]]; then - echo " 🚫 Gesperrt: $s_ip → $s_domain [Stufe ${s_level}, $(format_duration "${s_dur:-$BAN_DURATION}"), bis $s_until]" + echo " 🚫 Gesperrt: $s_ip → $s_domain [Stufe ${s_level}, $(format_duration "${s_dur:-$BAN_DURATION}"), bis $s_until${count_info}]${reason_tag}" else - echo " 🚫 Gesperrt: $s_ip → $s_domain [bis $s_until]" + echo " 🚫 Gesperrt: $s_ip → $s_domain [bis $s_until${count_info}]${reason_tag}" fi done fi @@ -705,9 +842,9 @@ show_status() { for offense_file in "${STATE_DIR}"/*.offenses; do [[ -f "$offense_file" ]] || continue local o_ip o_level o_last - o_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2) - o_level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2) - o_last=$(grep '^LAST_OFFENSE=' "$offense_file" | cut -d= -f2) + o_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2 || true) + o_level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2 || true) + o_last=$(grep '^LAST_OFFENSE=' "$offense_file" | cut -d= -f2 || true) offense_count=$((offense_count + 1)) local next_dur next_dur=$(calculate_ban_duration "$((o_level + 1))") @@ -781,7 +918,7 @@ flush_all_bans() { for state_file in "${STATE_DIR}"/*.ban; do [[ -f "$state_file" ]] || continue local client_ip - client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2) + client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2 || true) unban_client "$client_ip" "manual-flush" done @@ -798,7 +935,7 @@ flush_all_offenses() { for offense_file in "${STATE_DIR}"/*.offenses; do [[ -f "$offense_file" ]] || continue local o_ip - o_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2) + o_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2 || true) log "INFO" "Offense-Zähler zurückgesetzt: $o_ip" rm -f "$offense_file" count=$((count + 1)) @@ -855,6 +992,11 @@ main_loop() { else log "INFO" " Progressive Sperren: deaktiviert" fi + if [[ "${SUBDOMAIN_FLOOD_ENABLED:-false}" == "true" ]]; then + log "INFO" " Subdomain-Flood-Schutz: AKTIV (max ${SUBDOMAIN_FLOOD_MAX_UNIQUE:-50} Subdomains/${SUBDOMAIN_FLOOD_WINDOW:-60}s)" + else + log "INFO" " Subdomain-Flood-Schutz: deaktiviert" + fi log "INFO" "═══════════════════════════════════════════════════════════" # Service-Start-Benachrichtigung senden @@ -873,6 +1015,7 @@ main_loop() { local api_response if api_response=$(query_adguard_log); then analyze_queries "$api_response" + analyze_subdomain_flood "$api_response" fi sleep "$CHECK_INTERVAL" diff --git a/doc/konfiguration.md b/doc/konfiguration.md index 9c33da6..06baaca 100644 --- a/doc/konfiguration.md +++ b/doc/konfiguration.md @@ -37,6 +37,39 @@ Dadurch muss der Benutzer bei Updates die Konfiguration nicht manuell austausche | `CHECK_INTERVAL` | `10` | Wie oft die Logs geprüft werden (Sekunden) | | `API_QUERY_LIMIT` | `500` | Anzahl API-Einträge pro Abfrage (max 5000) | +### Subdomain-Flood-Erkennung (Random Subdomain Attack) + +Erkennt Bot-Angriffe, bei denen massenhaft zufällige Subdomains einer Domain abgefragt werden (z.B. `abc123.microsoft.com`, `xyz456.microsoft.com`, ...). Dabei wird pro Client gezählt, wie viele **eindeutige** Subdomains einer Basisdomain (z.B. `microsoft.com`) im Zeitfenster aufgerufen werden. + +| Parameter | Standard | Beschreibung | +|-----------|----------|--------------| +| `SUBDOMAIN_FLOOD_ENABLED` | `true` | Subdomain-Flood-Erkennung aktivieren | +| `SUBDOMAIN_FLOOD_MAX_UNIQUE` | `50` | Max. eindeutige Subdomains pro Basisdomain/Client im Zeitfenster | +| `SUBDOMAIN_FLOOD_WINDOW` | `60` | Zeitfenster in Sekunden | + +#### Wie funktioniert die Erkennung? + +1. Aus jeder DNS-Anfrage wird die **Basisdomain** extrahiert (z.B. `microsoft.com` aus `abc.microsoft.com`) +2. Pro Client wird gezählt, wie viele **verschiedene** Subdomains einer Basisdomain im Zeitfenster abgefragt wurden +3. Überschreitet die Anzahl eindeutiger Subdomains den Schwellwert, wird der Client gesperrt + +#### Beispiel + +Ein Bot fragt innerhalb von 60 Sekunden folgende Domains ab: + +``` +hbidcw.microsoft.com +ftdzewf.microsoft.com +xk9z3a.microsoft.com +... (50+ verschiedene Subdomains) +``` + +→ Alle Anfragen haben die gleiche Basisdomain `microsoft.com`. Sobald mehr als 50 eindeutige Subdomains erkannt werden, wird der Client gesperrt. + +> **Hinweis:** Nur echte Subdomains werden gezählt. Anfragen direkt an `microsoft.com` (ohne Subdomain) lösen diese Erkennung nicht aus. Multi-Part-TLDs wie `.co.uk`, `.com.au` etc. werden korrekt behandelt. + +> **Tipp:** Der Schwellwert `SUBDOMAIN_FLOOD_MAX_UNIQUE` sollte hoch genug sein, um legitime Clients nicht zu stören (z.B. CDNs nutzen oft viele Subdomains). Ein Wert von 50–100 ist in den meisten Fällen sinnvoll. + ### Sperr-Einstellungen | Parameter | Standard | Beschreibung | diff --git a/install.sh b/install.sh index 1495d26..069acce 100644 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ # Lizenz: MIT ############################################################################### -VERSION="0.3.1" +VERSION="0.4.0" set -euo pipefail @@ -306,7 +306,7 @@ migrate_config() { echo -n "$current_comment_block" >> "$existing_conf" echo "$line" >> "$existing_conf" echo -e " ➕ Neuer Parameter hinzugefügt: ${GREEN}$key${NC}" - ((new_keys_added++)) + new_keys_added=$((new_keys_added + 1)) fi fi