Zurück zum Blog

Sicherheitssysteme können sich gegenseitig sabotieren

Security Hardening: Mailcow-Netfilter-Konflikt, Wazuh Active Response & Fail2ban Eskalation

Datum 5. Februar 2026
Kategorie Security, Infrastruktur, Incident Response
Lesezeit 20 Minuten

Zusammenfassung

Bei der Analyse einer erhöhten System-Load auf unserem Produktionsserver ssh2.matzka.cloud wurde ein schwerwiegender Konflikt zwischen zwei Sicherheitssystemen entdeckt: Der Wazuh Active Response Mechanismus und der Mailcow Netfilter Container kämpften seit Wochen um die erste Position in den iptables-Chains. Dieser Konflikt führte zu über 5.300 Container-Neustarts, einem dauerhaft überlasteten Docker Daemon und 200% CPU-Auslastung.

Dieser Artikel dokumentiert die vollständige Analyse, die entwickelte Lösung und zusätzliche Security-Verbesserungen, die im Rahmen dieses Incidents umgesetzt wurden.

Überblick der Änderungen:
  • Custom Wazuh Active Response Script – iptables-Regeln nach Mailcow Chain einfügen
  • Docker Daemon bereinigt – Stuck Threads und Memory Overhead beseitigt
  • Wazuh Manager Memory-Limit von 2 GiB auf 3 GiB erhöht
  • Fail2ban bantime.increment – eskalierende Ban-Zeiten bei Wiederholung
  • Fail2ban Recidive Jail – 1-Wochen-Komplettsperrung für Wiederholungstäter

Symptome: Warum war die System-Load hoch?

Die initiale Beobachtung war eine System-Load von ~2.5 auf einem 8-Core-System – nicht kritisch, aber deutlich höher als erwartet für einen Server, der primär Docker-Container betreibt. Die Analyse ergab:

Metrik Wert Bewertung
System Load (1 min) 2.28 Erhöht für 8 Kerne
dockerd CPU 200% Kritisch – 2 Kerne dauerhaft belegt
dockerd RAM 900 MiB Anomal hoch für den Daemon
dockerd CPU Time 13+ Tage Massiver kumulierter Verbrauch
Container-Count 57 aktiv Normal

Der dockerd-Prozess stach sofort heraus. Mit 200% CPU-Auslastung belegte er konstant zwei volle CPU-Kerne. Die top-Analyse auf Thread-Ebene offenbarte zwei Threads in einer Busy-Loop mit jeweils ~90% CPU:

    PID USER      %CPU  %MEM     TIME+ COMMAND
3387516 root      90.9   5.6  82:33.38 dockerd    # Stuck Thread 1
2651096 root      90.9   5.6      9,55 dockerd    # Stuck Thread 2
   1073 root       9.1   5.6     11,29 dockerd    # Normaler Worker

Ursachenanalyse: Die iptables-Positionsfalle

Der Mailcow Netfilter Container

Mailcow betreibt einen netfilter-Container, der eine eigene iptables-Chain namens MAILCOW verwaltet. Diese Chain isoliert die Mailcow-Container im Netzwerk und implementiert Mailcow-eigene Firewall-Regeln (z.B. fail2ban für SMTP/IMAP).

Kritisch: Der Container prüft jede Minute, ob die MAILCOW-Chain an Position 1 der iptables INPUT- und FORWARD-Chains steht. Steht sie an Position 2 oder später, startet sich der Container selbst neu, um die Position zu korrigieren.

Das Wazuh Active Response Problem

Wazuh SIEM verwendet Active Response, um Angreifer-IPs automatisch per iptables zu blocken. Das Standard-Binary firewall-drop führt dazu folgenden Befehl aus:

# Standard Wazuh firewall-drop (kompiliertes Binary):
iptables -I INPUT -s <ANGREIFER_IP> -j DROP
iptables -I FORWARD -s <ANGREIFER_IP> -j DROP

Der Schalter -I INPUT ohne Positionsangabe fügt die Regel an Position 1 ein – also vor der MAILCOW-Chain.

Der Teufelskreis

1. Mailcow Netfilter startet → MAILCOW Chain an Position 1 in INPUT 2. Wazuh blockt eine IP → DROP-Regel an Position 1 eingefügt 3. MAILCOW rutscht auf Position 2 4. Netfilter-Check (alle 60s) → "CRIT: MAILCOW target is in position 2!" 5. Container startet sich neu → MAILCOW wieder an Position 1 6. Nächster Wazuh-Block → Zurück zu Schritt 2 Ergebnis: 5.314 Neustarts in ~6 Wochen = ~130 Neustarts pro Tag = ~5 Neustarts pro Stunde

Die Logs des Netfilter-Containers bestätigten das Muster eindeutig:

2026-02-05 07:47:12 CRIT: MAILCOW target is in position 2 in the ip input table, restarting...
2026-02-05 07:47:13 INFO: Using NFTables backend
2026-02-05 07:47:13 INFO: Initializing mailcow netfilter chain
2026-02-05 07:47:13 INFO: MAILCOW ip chain created successfully.
2026-02-05 07:47:13 INFO: Watching Redis channel F2B_CHANNEL
2026-02-05 07:48:13 CRIT: MAILCOW target is in position 2 in the ip input table, restarting...
Auswirkung: Jeder Container-Neustart erzeugt Network-Disconnect, Container-Remove, Container-Create und Network-Connect Events im Docker Daemon. Bei ~5 Neustarts pro Stunde führte das zu massivem Overhead in dockerd und letztlich zu Stuck Threads mit 200% CPU-Dauerlast.

Lösung: Mailcow-aware Active Response Script

Strategie

Das Wazuh Standard-Binary firewall-drop ist kompiliert und kann nicht modifiziert werden. Die Lösung: Ein Custom Active Response Script, das iptables-Regeln an Position 2 statt Position 1 einfügt – also nach der MAILCOW-Chain.

Das Script: mailcow-aware-drop

Das Script wurde auf dem Wazuh Agent (Host-System, Agent 001) installiert unter /var/ossec/active-response/bin/mailcow-aware-drop:

#!/bin/bash
# mailcow-aware-drop - Custom Wazuh Active Response
# Inserts iptables DROP rules at position 2 (after MAILCOW chain)

LOCAL=$(dirname "$0")
cd "$LOCAL"/../../ || exit 1
PWD=$(pwd)
LOG_FILE="${PWD}/logs/active-responses.log"

# Read JSON input from Wazuh execd
read -r INPUT_JSON

# Parse command (add/delete) and source IP via jq
COMMAND=$(echo "$INPUT_JSON" | jq -r '.command // empty')
SRCIP=$(echo "$INPUT_JSON" | jq -r '.parameters.alert.data.srcip // empty')

case "$COMMAND" in
    add)
        # Position 2: NACH der MAILCOW Chain
        iptables -I INPUT 2 -s "$SRCIP" -j DROP
        iptables -I FORWARD 2 -s "$SRCIP" -j DROP
        ;;
    delete)
        iptables -D INPUT -s "$SRCIP" -j DROP
        iptables -D FORWARD -s "$SRCIP" -j DROP
        ;;
esac
Hinweis: Das vollständige Script enthält zusätzlich IP-Validierung, IPv6-Unterstützung, einen Lock-Mechanismus gegen Race Conditions und ausführliches Logging. Die obige Version ist zur Veranschaulichung vereinfacht.

Wazuh Manager Konfiguration

Im Wazuh Manager wurde ein neuer <command> definiert und alle drei Active Response Blöcke umgestellt:

<!-- Neuer Command für Mailcow-kompatibles Blocking -->
<command>
    <name>mailcow-aware-drop</name>
    <executable>mailcow-aware-drop</executable>
    <timeout_allowed>yes</timeout_allowed>
</command>

<!-- SSH Brute Force (vorher: firewall-drop) -->
<active-response>
    <command>mailcow-aware-drop</command>
    <location>defined-agent</location>
    <agent_id>001</agent_id>
    <rules_id>5710,5711,5712,5716</rules_id>
    <timeout>600</timeout>
</active-response>

<!-- Auth Failures -->
<active-response>
    <command>mailcow-aware-drop</command>
    <rules_id>5503,5504</rules_id>
    <timeout>300</timeout>
</active-response>

<!-- Web Service Brute Force (Nextcloud, Authentik, Grafana, ...) -->
<active-response>
    <command>mailcow-aware-drop</command>
    <rules_id>100002,100012,100021,100041,100051,...</rules_id>
    <timeout>900</timeout>
</active-response>

Ergebnis nach Umstellung

Die iptables-Chains zeigen jetzt das korrekte Layout:

Chain INPUT (policy DROP)
num  target     source               destination
1    MAILCOW    0.0.0.0/0            0.0.0.0/0      ← Position 1: Mailcow (stabil)
2    DROP       122.8.153.71         0.0.0.0/0      ← Position 2+: Wazuh Blocks
3    DROP       205.185.125.150      0.0.0.0/0
4    DROP       45.148.10.240        0.0.0.0/0
...

Die Active Response Logs bestätigen die korrekte Einfügung:

mailcow-aware-drop: ADD - Blocked 122.8.153.71 (position 2, after MAILCOW)
mailcow-aware-drop: ADD - Blocked 205.185.125.150 (position 2, after MAILCOW)
mailcow-aware-drop: DELETE - Unblocked 122.8.153.71

Docker Daemon: Stuck Threads bereinigen

Nach dem Stoppen der Restart-Loop blieb die dockerd CPU-Auslastung bei 200%. Die Thread-Analyse zeigte zwei Goroutines, die in einer Busy-Loop feststeckten – wahrscheinlich verursacht durch korrumpierte interne Netzwerk-Datenstrukturen nach 5.314 Container-Neustarts.

# Vor dem Restart:
PID       %CPU   TIME+      COMMAND
3387516   90.9   82:33.38   dockerd    # Stuck Goroutine
2651096   90.9    9:55      dockerd    # Stuck Goroutine

# dockerd Gesamtstatus:
Memory:    900 MiB (anomal hoch)
CPU:       200% (konstant)
Uptime:    42 Tage
CPU Time:  14+ Tage kumuliert

Da alle 57 Container Restart-Policies (unless-stopped oder always) besitzen, konnte der Docker Daemon sicher neugestartet werden:

systemctl restart docker
dockerd CPU vorher
200%
2 Kerne dauerhaft belegt
dockerd CPU nachher
0–20%
Normal für 57 Container
dockerd RAM vorher
900 MiB
Angesammelter Overhead
dockerd RAM nachher
129 MiB
Normaler Betrieb
System Load vorher
~2.5
31% der 8-Kern-Kapazität
System Load nachher
0.94
12% der 8-Kern-Kapazität

Wazuh Memory-Limit: OOM-Prävention

Während der Analyse wurde zusätzlich ein CRITICAL Alert für den wazuh-manager Container ausgelöst: Die Memory-Auslastung erreichte 99,4% des konfigurierten 2-GiB-Limits.

Ursache

Der Wazuh Vulnerability Scanner führt stündlich ein Feed-Update durch, bei dem große Datenmengen heruntergeladen, geparst und mit den bekannten Paketen abgeglichen werden. Während dieses Prozesses steigt der Speicherverbrauch temporär stark an:

16:04:10 vulnerability-scanner: ERROR: array index 2 is out of range,
         trying to re-download the feed.
16:04:45 vulnerability-scanner: Initiating update feed process.
16:33:04 vulnerability-scanner: ERROR: Invalid resource type: TCPE.
16:43:56 vulnerability-scanner: Feed update process completed.

# Während des Feed-Updates:
Memory: 1.987 GiB / 2.0 GiB (99.4%) ← CRITICAL Alert

Lösung

Das Memory-Limit wurde in docker-compose.yml angepasst:

Parameter Vorher Nachher
Memory Limit 2 GiB 3 GiB
Memory Reservation 1 GiB 1.5 GiB
# /opt/stacks/security/docker-compose.yml
deploy:
  resources:
    limits:
      memory: 3g       # vorher: 2g
      cpus: "2"
    reservations:
      memory: 1.5g     # vorher: 1g
      cpus: "1"

Nach dem Recreate des Containers liegt der Speicherverbrauch im Normalbetrieb bei ~425 MiB / 3 GiB (14%), mit 1 GiB mehr Headroom für Vulnerability-Feed-Updates.

Fail2ban: Eskalierende Ban-Zeiten & Recidive

Ausgangslage

Die bestehende Fail2ban-Konfiguration verwendet fixe Ban-Zeiten: Jeder Verstoß führt zur gleichen Sperrzeit, unabhängig davon, ob die IP zum ersten oder zum zehnten Mal auffällt. Ein Angreifer mit wechselnden Benutzernamen wird immer wieder für nur 1 Stunde gesperrt.

Jail Bantime (vorher) Max Retry
sshd 1 Stunde 5
authentik-login 1 Stunde 5
nextcloud-login 1 Stunde 5
grafana-login, wazuh-login 2 Stunden 3
umami-login 30 Minuten 10

Verbesserung 1: bantime.increment

Fail2ban 1.0+ unterstützt bantime.increment – eine eingebaute Funktion, die die Ban-Zeit bei jedem Wiederholungsverstoß automatisch verdoppelt:

[DEFAULT]
bantime = 3600

# Eskalierende Ban-Zeiten aktivieren
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
bantime.overalljails = true

Die Eskalation funktioniert wie folgt:

Wiederholung Ban-Formel Effektive Sperrzeit
1. Ban 3600 × 20 1 Stunde
2. Ban 3600 × 21 2 Stunden
3. Ban 3600 × 22 4 Stunden
4. Ban 3600 × 23 8 Stunden
5. Ban 3600 × 24 16 Stunden
6. Ban 3600 × 25 1 Tag 8 Stunden
7.+ Ban Maximum erreicht 1 Woche

Durch bantime.overalljails = true zählt die Ban-Historie über alle Jails hinweg. Eine IP, die zuerst im SSH-Jail und dann im Nextcloud-Jail auffällt, erhält beim zweiten Ban bereits 2 Stunden statt 1 Stunde.

Verbesserung 2: Recidive Jail

Zusätzlich zur Eskalation wurde der [recidive]-Jail aktiviert. Dieser spezielle Jail überwacht das Fail2ban-Log selbst und erkennt IPs, die wiederholt in verschiedenen Jails gesperrt werden:

[recidive]
enabled = true
logpath = /var/log/fail2ban.log
banaction = iptables-allports[name=Recidive]
bantime = 1w
findtime = 1d
maxretry = 3
# Recidive selbst nicht eskalieren
bantime.increment = false
Recidive-Logik: Wird eine IP innerhalb von 24 Stunden in 3 verschiedenen Jails gebannt, erfolgt eine 1-Wochen-Komplettsperrung auf allen Ports (iptables-allports). Das betrifft nicht nur HTTP/HTTPS, sondern auch SSH, SMTP und alle anderen Dienste.

Zusammenspiel beider Mechanismen

Angreifer versucht Login | v [Fail2ban Jail] → 1. Ban: 1 Stunde (Port 80/443) | Kommt zurück nach 1h | v [Fail2ban Jail] → 2. Ban: 2 Stunden (bantime.increment) | Kommt zurück nach 2h, probiert anderen Service | v [Anderer Jail] → 3. Ban: 4 Stunden + RECIDIVE TRIGGER | v [Recidive Jail] → 1 WOCHE auf ALLEN PORTS (SSH, HTTP, HTTPS, SMTP, ...)

Ergebnisse & Metriken

System-Performance

System Load
2.5
Vorher
System Load
0.9
Nachher (-64%)
Netfilter Restarts
5.314
In 6 Wochen
Netfilter Restarts
1
Nur initialer Start

Sicherheitslage am Tag der Analyse

Während der Arbeiten wurde auch die aktuelle Bedrohungslage ausgewertet. Die Zahlen zeigen normales Internet-Hintergrundrauschen, keinen gezielten Angriff:

Metrik Wert
Fehlgeschlagene SSH-Logins (gesamt) 8.957
Unique IPs von Wazuh geblockt 114
HTTP 401/403 (letzte Stunde) 25
Fail2ban aktive Bans 0 (von Wazuh abgefangen)

Die häufigsten Angreifer-Quellen waren bekannte Scanner-Netzwerke:

IP-Bereich Organisation Land Versuche
186.96.145.x Total Play Telecomunicaciones Mexiko 1.096
2.57.x.x Techoff SRV Limited Niederlande ~900
92.118.39.x Pptechnology (Censys-Scanner) USA ~600
45.148.10.x Techoff SRV Limited Niederlande ~400
80.94.92.x Techoff SRV Limited Niederlande ~200

Lessons Learned

1. Sicherheitssysteme können sich gegenseitig sabotieren

Der Kernfehler war kein Bug in einem einzelnen System, sondern eine Interaktions-Inkompatibilität zwischen zwei korrekt funktionierenden Sicherheitsmechanismen. Mailcow schützt seine Chain-Position, Wazuh schützt den Server durch IP-Blocking – beide tun genau das Richtige, aber ihre Strategien kollidieren auf iptables-Ebene.

Empfehlung: Bei jeder neuen Sicherheitskomponente prüfen, ob sie iptables/nftables-Regeln modifiziert und wie sich das auf bestehende Chain-Strukturen anderer Dienste auswirkt.

2. Graduelle Degradation ist schwer zu erkennen

Der Konflikt bestand seit Wochen, aber die Auswirkungen (erhöhte Load, mehr CPU) waren subtil genug, um nicht sofort aufzufallen. Erst die gezielte Analyse des dockerd-Prozesses offenbarte das Problem.

Empfehlung: Monitoring-Alerts für Container-Restart-Counts einrichten. Ein Container mit >10 Restarts pro Stunde sollte einen Alert auslösen.

3. Docker Daemon Restart als letztes Mittel

Die Stuck Threads im dockerd-Prozess konnten nicht ohne Daemon-Restart behoben werden. Wichtig: Vor dem Restart sicherstellen, dass alle Container Restart-Policies besitzen. Der Restart selbst dauerte ~30 Sekunden mit vollständiger Wiederherstellung aller 57 Container.

4. Memory-Limits realistisch dimensionieren

Das 2-GiB-Limit für den Wazuh Manager war im Normalbetrieb ausreichend, aber nicht für periodische Lastspitzen wie Vulnerability-Feed-Updates. Container-Memory-Limits sollten mindestens 50% Headroom über dem typischen Spitzenverbrauch bieten.

5. Eskalierende Sperrung ist effektiver als feste Zeiten

Feste Ban-Zeiten behandeln den ersten und den hundertsten Verstoß gleich. Mit bantime.increment wird der Aufwand für Angreifer exponentiell erhöht, während einmalige Fehlversuche (z.B. durch Tippfehler) milde bestraft werden. In Kombination mit dem Recidive-Jail entsteht ein System, das proportional zur Bedrohung reagiert.

Aktuelle Security-Architektur

Eingehender Traffic | v [Layer 1: Traefik] - HTTPS Enforcement - Rate Limiting - IP Whitelisting | v [Layer 2: Fail2ban] - 13 aktive Jails - Eskalierende Ban-Zeiten (1h → 1w) - Recidive: 3 Bans/24h → 1w Allports - Schutz aller Web-Endpoints | v [Layer 3: Wazuh Active Response] - SSH Brute-Force Blocking - Auth-Failure Blocking - Web-Service Brute-Force Blocking - Mailcow-aware (Position 2) | v [Layer 4: Wazuh SIEM] - 57+ Custom Rules - File Integrity Monitoring - Vulnerability Scanner (Trivy) - Gotify Push-Alerts (Level 7+) - Email-Alerts (Level 10+) | v [Monitoring] - Prometheus Metriken - Grafana Dashboards - Daily Watchdog Report
Status: Alle Sicherheitssysteme arbeiten korrekt zusammen. Der Mailcow-Netfilter-Konflikt ist behoben, die System-Load ist um 64% gesunken, und Wiederholungstäter werden mit eskalierenden Sperrzeiten bis zu einer Woche bestraft.