Sicherheitssysteme können sich gegenseitig sabotieren
Security Hardening: Mailcow-Netfilter-Konflikt, Wazuh Active Response & Fail2ban Eskalation
Inhaltsverzeichnis
- Zusammenfassung
- Symptome: Warum war die System-Load hoch?
- Ursachenanalyse: Die iptables-Positionsfalle
- Lösung: Mailcow-aware Active Response Script
- Docker Daemon: Stuck Threads bereinigen
- Wazuh Memory-Limit: OOM-Prävention
- Fail2ban: Eskalierende Ban-Zeiten & Recidive
- Ergebnisse & Metriken
- Lessons Learned
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.
- 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
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...
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
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
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
iptables-allports). Das betrifft nicht nur HTTP/HTTPS, sondern
auch SSH, SMTP und alle anderen Dienste.
Zusammenspiel beider Mechanismen
Ergebnisse & Metriken
System-Performance
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.
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.
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.