Docker umgeht UFW: Ports richtig absichern
Docker leitet veröffentlichte Ports an UFW vorbei. So erklären Sie den Bypass und sperren Ports korrekt, damit ein deny nicht weiter Verbindungen aus dem Internet zulässt.
Warum Docker UFW umgeht
Docker umgeht UFW, weil veröffentlichte Container-Ports die Firewall-Regeln, die UFW verwaltet, nie durchlaufen. Wenn Sie docker run -p 8080:80 ausführen, schreibt Docker eine DNAT-Regel (Destination Network Address Translation) in die eigene DOCKER-Chain in der Kernel-Tabelle nat. Die PREROUTING-Chain springt zu dieser Chain. Die Regel ändert das Ziel jedes Pakets in die private Adresse des Containers, bevor der Kernel entscheidet, wohin das Paket weitergeleitet wird. Das geänderte Paket wird anschließend über die FORWARD-Chain in den Container weitergeleitet. Diese Chain wird von Docker kontrolliert. Die UFW-Regeln befinden sich in der INPUT-Chain. Das Paket erreicht diese Chain nie. Deshalb zeigt ufw status standardmäßig deny an, sudo ufw deny 8080 meldet Erfolg, und Port 8080 antwortet weiterhin aus dem gesamten Internet.
Das ist kein Docker-Fehler, und UFW ist nicht defekt. Beide Werkzeuge konfigurieren dieselbe Kernel-Firewall. Die Regeln von Docker greifen lediglich früher im Paketpfad, sodass UFW gar nicht gefragt wird. Dieser Leitfaden demonstriert den Bypass, erklärt den Mechanismus und behandelt anschließend die beiden funktionierenden Lösungen: Ports auf 127.0.0.1 veröffentlichen und in der DOCKER-USER-Chain filtern. Wenn UFW für Sie noch neu ist, richten Sie es zuerst anhand des Leitfadens zu den UFW-Firewall-Grundlagen ein. Eine Firewall mit standardmäßigem deny bleibt die richtige Grundlage für alle anderen Dienste auf dem Server.
Den Bypass auf Ihrem eigenen Server nachvollziehen
Verwenden Sie zunächst einen VPS, auf dem UFW mit einer standardmäßigen Deny-Richtlinie für eingehenden Netzwerkverkehr aktiv ist. Starten Sie einen Webcontainer mit einem veröffentlichten Port:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose zeigt Default: deny (incoming), allow (outgoing) und keine Regel für Port 8080. Laut dem eigenen Status von UFW ist der Port geschlossen. Testen Sie nun von einem anderen Rechner aus, nicht vom Server selbst:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKDer Container antwortet. Fügen Sie eine explizite Deny-Regel hinzu und testen Sie erneut:
sudo ufw deny 8080/tcpDer Port antwortet weiterhin, weil sich die Deny-Regel in einer Chain befindet, die das Paket nie durchläuft. UFW ist nicht fehlgeschlagen. Die Firewall wurde nie abgefragt. Deshalb bleibt das Problem auch so gut verborgen: An keiner Stelle wird ein Fehler ausgegeben, das Deployment funktioniert, und die Statusausgabe der Firewall sieht exakt wie die eines korrekt abgesicherten Servers aus.
Der Mechanismus: PREROUTING wird vor INPUT ausgeführt
Der Kernel verarbeitet ein eingehendes Paket in einer festen Reihenfolge. Das gesamte Problem ergibt sich aus dieser Reihenfolge.
PREROUTINGwird zuerst ausgeführt. Regeln, die von hier aus erreicht werden, können das Ziel des Pakets ändern. Die Docker-Regel für einen veröffentlichten Port in derDOCKER-Kette, zu derPREROUTINGspringt, macht genau das.- Danach folgt die Routing-Entscheidung. Ein Paket, das an den Host selbst adressiert ist, wird an die
INPUT-Kette weitergeleitet. Ein Paket, das an einen anderen Rechner adressiert ist, wird an dieFORWARD-Kette weitergeleitet. - Die Regeln von UFW befinden sich in
INPUT. Die Regeln von Docker befinden sich inFORWARD.
Sehen Sie sich die Docker-Regel für den Container an, den Sie gerade gestartet haben:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80Die Zeile DNAT zeigt das gesamte Problem. Bei jedem Paket, das für Port 8080 eintrifft, wird das Ziel in 172.17.0.2:80 geändert, die Adresse des Containers im privaten Docker-Bridge-Netzwerk. Nach dieser Änderung ist das Paket nicht mehr an den Host adressiert. Daher leitet die Routing-Entscheidung es über den FORWARD-Pfad weiter. Dort hat Docker bereits Regeln hinzugefügt, die Datenverkehr in die eigenen Netzwerke akzeptieren. Ihre deny 8080/tcp-Regel wartet in INPUT auf ein Paket, das nie eintrifft.
Unter Ubuntu 24.04 ist der Befehl iptables ein Frontend für nftables. Die Reihenfolge der Ketten und das Ergebnis sind jedoch identisch. UFW und Docker schreiben beide in dieselbe Paketverarbeitungskette des Kernels. Der Einstiegspunkt von Docker liegt dabei weiter vorn. Dies betrifft nicht nur UFW: firewalld auf einem Rocky- oder AlmaLinux-VPS filtert an derselben Stelle dieser Verarbeitungskette und wird durch dieselbe DNAT-Regel umgangen. Daher gelten die folgenden Korrekturen auch dort.
Die alltägliche Lösung: Ports auf 127.0.0.1 veröffentlichen
Die meisten Container mussten ursprünglich überhaupt nicht öffentlich erreichbar sein. Eine Datenbank, ein App-Server hinter einem Reverse Proxy, ein Administrationspanel oder ein Metrics-Endpunkt sollten nicht direkt auf Anfragen aus dem Internet antworten. Veröffentlichen Sie sie auf der Loopback-Adresse:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineOder in einer Compose-Datei:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Das funktioniert, weil die DNAT-Regel von Docker jetzt nur Pakete abgleicht, die an 127.0.0.1 adressiert sind. Ab Docker Engine 28.0.0 fügt Docker außerdem eine Regel in der raw-Tabelle in der PREROUTING-Kette hinzu. Diese verwirft alle Pakete für diesen Loopback-Port, die über ein anderes Interface als das Loopback-Interface eintreffen. In diesen Versionen ist der Port vom Host aus erreichbar, aber von keiner anderen Quelle. Für Versionen vor 28.0.0 dokumentiert Docker, dass Hosts im selben L2-Segment, beispielsweise Rechner am selben Netzwerk-Switch, weiterhin auf Ports zugreifen können, die auf localhost veröffentlicht wurden. Prüfen Sie daher docker version und führen Sie ein Upgrade durch, bevor Sie sich darauf verlassen. Überprüfen Sie die Bindung:
sudo ss -tlnp | grep 8080In der Ausgabe soll 127.0.0.1:8080 erscheinen, nicht 0.0.0.0:8080 oder [::]:8080. Bestätigen Sie anschließend von einem anderen Rechner aus, dass curl http://your-vps-ip:8080/ keine Antwort erhält. Ein an Ihre öffentliche Adresse gesendetes Paket wird von Docker nicht mehr umgeschrieben. Es erreicht INPUT, wo die standardmäßige Deny-Richtlinie von UFW und die zuvor eingerichtete deny 8080/tcp-Regel es stillschweigend verwerfen, statt es zurückzuweisen. curl wartet daher und endet mit einem Verbindungs-Timeout, nicht mit „connection refused“.
Für Dienste, die aus dem Internet erreichbar sein sollen, betreiben Sie einen einzigen Reverse Proxy, der die Ports 80 und 443 verwaltet und anhand des Hostnamens weiterleitet. Veröffentlichen Sie keine weiteren Ports. Dieses Muster wird im Leitfaden zum Traefik-Reverse-Proxy umgesetzt. So bleibt eine selbst gehostete Anwendung wie Nextcloud auf einem VPS ausschließlich über ihren Proxy erreichbar. Wie ports:-Einträge deklariert werden und wie der restliche Compose-Workflow funktioniert, wird im Leitfaden zu den Docker-Compose-Grundlagen erklärt.
Wenn jeder interne Container über Loopback erreichbar ist, erfüllt UFW wieder seine normale Aufgabe: Es schützt die Ports, die der Host selbst bereitstellt. Erstellen Sie die Regelmenge hier und führen Sie anschließend die Befehle in dieser Reihenfolge aus:
Echte Filterung: die DOCKER-USER-Kette
Manchmal muss ein Container-Port im Netzwerk veröffentlicht bleiben, aber eingeschränkt werden. Ein Beispiel ist der Port einer Datenbankreplik, auf den nur eine Büro-Adresse zugreifen darf. Dafür stellt Docker die DOCKER-USER-Kette bereit. Jedes Paket, das an einen beliebigen Container gerichtet ist, durchläuft DOCKER-USER, bevor die eigenen Accept-Regeln von Docker greifen. Diese Kette ist für Ihre Regeln vorgesehen. Docker löscht oder ändert sie nicht, auch nicht nach einem Neustart des Daemons.
Diese Kette gehört zum standardmäßigen iptables-Firewall-Backend von Docker. Docker Engine 29 hat ein experimentelles nftables-Backend eingeführt. Sie müssen es mit der firewall-backend-Daemon-Option aktivieren. In diesem Backend gibt es keine DOCKER-USER-Kette. Benutzerdefinierte Filterung erfolgt stattdessen in einer eigenen nftables-Tabelle mit einer Base Chain am forward-Hook. Wenn Sie diese Option nicht geändert haben, verwenden Sie iptables, und die folgenden Schritte gelten. Der Unterschied zwischen den beiden Werkzeugen wird im Leitfaden zu iptables und nftables beschrieben.
Beachten Sie vor dem Befehl eine wichtige Besonderheit: Wenn ein Paket DOCKER-USER erreicht, wurde das DNAT bereits ausgeführt. Der Ziel-Port des Pakets ist dann der Container-Port (in unserem Beispiel 80), nicht der veröffentlichte Port (8080). Eine Regel, die auf --dport 8080 prüft, trifft daher auf kein Paket zu. Zuverlässig ist die Prüfung des Ports, den der Client ursprünglich angesprochen hat. Der Connection Tracker des Kernels speichert diesen Port:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPDie Regel bedeutet: Für Pakete, die über eth0 eingegangen sind und zu einer Verbindung gehören, deren ursprünglicher Ziel-Port 8080 war, werden alle Pakete verworfen, die nicht von 10.0.0.10 gesendet wurden. Die --ctdir ORIGINAL-Prüfung beschränkt die Regel auf die Richtung vom Client zum Container. Antwortpakete werden dadurch nicht versehentlich erfasst. Ersetzen Sie eth0 durch Ihre öffentliche Schnittstelle. ip route | grep default gibt deren Namen aus. Testen Sie die Regel wie zuvor: curl von der erlaubten Adresse ist erfolgreich. Von jeder anderen Adresse läuft die Verbindung in einen Timeout. Dieses Hängenbleiben zeigt, dass eine DROP-Regel ordnungsgemäß arbeitet und nicht, dass hinter dem Port kein Dienst vorhanden ist. Der Unterschied zwischen einer abgewiesenen Verbindung und einer Verbindung, bei der ein Timeout auftritt ist die schnellste Möglichkeit, einen gefilterten Port von einem Dienst zu unterscheiden, der überhaupt nicht lauscht.
Regeln, die mit dem Befehl iptables hinzugefügt wurden, gehen beim Neustart verloren. Da UFW diese Firewall bereits verwaltet, ist /etc/ufw/after.rules der geeignete Ort, um sie dauerhaft zu speichern. Hängen Sie am Ende der Datei einen Block an:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITFühren Sie anschließend sudo ufw reload aus. UFW liest diese Datei bei jedem Reload und bei jedem Boot erneut ein. Dadurch befindet sich die Filterung Ihrer Container am selben Ort wie der Rest Ihrer Firewall und bleibt nach einem Neustart sowie nach einem Docker-Upgrade erhalten.
Warum Sie Dockers iptables-Integration nicht deaktivieren sollten
Ältere Antworten auf dieses Problem schlagen vor, { "iptables": false } in /etc/docker/daemon.json zu setzen. Tun Sie das nicht. Dockers Firewall-Regeln veröffentlichen nicht nur Ports. Die Masquerade-Regel ermöglicht Containern den ausgehenden Internetzugriff über die Adresse des Hosts. Wenn die Integration deaktiviert ist, können Container daher keine Paketspiegel erreichen und keine externe API (application programming interface) aufrufen. Auch die DNAT- und Isolationsregeln entfallen. Die Docker-Dokumentation weist darauf hin, dass die Ports der Container ohne Ersatzregeln für Hosts im lokalen Netzwerk erreichbar werden. Sie würden den Bypass beheben, indem Sie die Container-Vernetzung beeinträchtigen. Außerdem müssten Sie jede dieser Regeln selbst schreiben und manuell verwalten. Die Docker-Dokumentation bezeichnet diese Einstellung als für die meisten Benutzer ungeeignet und beschreibt das nachträgliche Erstellen der Regeln als äußerst aufwendig. Die Kette DOCKER-USER existiert genau deshalb, damit niemand diesen Schalter benötigt.
Die IPv6-Seite desselben Problems
Prüfen Sie zunächst, wie der veröffentlichte Port über IPv6 aussieht:
sudo ss -tlnp | grep 8080Seit Docker Engine 27 verwaltet Docker standardmäßig ip6tables. In einem Docker-Netzwerk mit aktiviertem IPv6 erhält ein veröffentlichter Port in den IPv6-Tabellen dieselbe DNAT-Behandlung. Daher besteht dort derselbe Bypass, und es gilt dieselbe Behebung: Die Kette DOCKER-USER existiert auch in ip6tables. Spiegeln Sie Ihre Regel daher mit sudo ip6tables -I DOCKER-USER ... und testen Sie sie von außerhalb mit curl gegen die öffentliche IPv6-Adresse Ihres Servers, zum Beispiel curl -6 http://[2001:db8:2a::1]:8080/. Das gilt für das standardmäßige iptables-Backend von Docker. Beim experimentellen nftables-Backend gibt es in keinem der beiden Stacks eine Kette DOCKER-USER. Die IPv6-Filterung erfolgt stattdessen in Ihrer eigenen ip6-nftables-Tabelle.
In einem Netzwerk ohne IPv6 werden IPv6-Clients stattdessen von docker-proxy verarbeitet, einem normalen Prozess im Userspace, der auf [::]:8080 lauscht und den Datenverkehr über IPv4 an den Container weiterleitet. Datenverkehr zu einem Hostprozess durchläuft INPUT. UFW kann diesen Pfad daher filtern, allerdings nur, wenn UFW IPv6 überhaupt verwaltet. Ob das der Fall ist und auf welche anderen Arten auf einem VPS eine IPv6-Lücke entsteht, wird im Leitfaden zu UFW und IPv6 beschrieben.
Die Veröffentlichung auf Loopback umgeht die gesamte Frage: -p 127.0.0.1:8080:80 bindet ausschließlich an das IPv4-Loopback-Interface. Daher gibt es keinen IPv6-Listener. Ab Docker Engine 28.0.0 ist außerdem von außerhalb über keinen der beiden Stacks etwas erreichbar.
Ein belastbares Muster
- Veröffentlichen Sie jeden internen Port auf
127.0.0.1. Dadurch ist er auf Docker Engine 28.0.0 oder höher von vornherein nicht exponiert. Ältere Releases ermöglichen Hosts im selben L2-Segment den Zugriff darauf. - Überlassen Sie die öffentliche Seite einem einzigen Reverse Proxy, der die Ports 80 und 443 verwaltet.
- Belassen Sie UFW für den Host bei der Standardrichtlinie deny. Erlauben Sie SSH und die Proxy-Ports.
- Filtern Sie tatsächlich öffentliche Container-Ports in
DOCKER-USERauf dem standardmäßigen iptables-Backend von Docker. Verwenden Sie dabei den ursprünglichen Zielport als Kriterium und speichern Sie die Regeln dauerhaft in/etc/ufw/after.rules. - Lassen Sie die iptables-Integration von Docker aktiviert.
Nach der einmaligen Einrichtung gibt es keine Überraschungen mehr: ufw status beschreibt den Host und DOCKER-USER die Container. Nichts wird versehentlich veröffentlicht. Der nächste von Ihnen eingegebene docker run -p-Befehl exponiert genau das, was Sie beabsichtigt haben.
FAQ
Warum kann ich meinen Docker-Container erreichen, obwohl UFW den Port blockiert?
Docker veröffentlicht den Port mit einer DNAT-Regel in seiner Kette DOCKER der Tabelle nat. Diese Kette wird von PREROUTING aus erreicht und schreibt das Ziel des Pakets auf die Adresse des Containers um, bevor eine Filterung stattfindet. Das Paket durchläuft anschließend den Pfad FORWARD. Die UFW-Regeln befinden sich in INPUT, einer Kette, die das Paket nie erreicht. Die Firewall wird daher nicht abgefragt. Ihre deny-Regeln haben auf veröffentlichte Container-Ports keinen Einfluss.
Wie kann ich die von Docker veröffentlichten Ports mit UFW blockieren?
Mit UFW selbst ist das nicht möglich, weil sich die Regeln in der falschen Kette befinden. Beenden Sie entweder die Veröffentlichung des Ports, indem Sie ihn als 127.0.0.1:8080:80 veröffentlichen. Das ist ab Docker Engine 28.0.0 möglich und erlaubt nur dem Host den Zugriff. Alternativ können Sie die Kette DOCKER-USER mit einer iptables-Regel filtern, die den ursprünglichen Zielport über conntrack abgleicht. Speichern Sie diese Regel in /etc/ufw/after.rules, damit sie Reboots und ufw reload übersteht.
Sollte ich "iptables": false in Docker's daemon.json setzen?
Nein. Diese Einstellung entfernt den Großteil der Docker-Firewall- und NAT-Regeln. Dadurch wird deutlich mehr als nur die Umgehung von UFW beeinträchtigt. Container verlieren den ausgehenden Internetzugriff, weil die Masquerade-Regel fehlt. Docker weist außerdem darauf hin, dass Container-Ports ohne Ersatzregeln für Hosts im lokalen Netzwerk erreichbar werden. Verwenden Sie stattdessen Loopback-Publishing und die Kette DOCKER-USER. Damit lässt sich die Erreichbarkeit begrenzen, ohne das Container-Netzwerk zu beschädigen.
Umgeht Docker UFW auch bei IPv6?
Ab Docker Engine 27 ist die Verwaltung von ip6tables standardmäßig aktiviert. Ein Port, der in einem IPv6-fähigen Docker-Netzwerk veröffentlicht wird, wird daher wie bei IPv4 an UFW vorbei umgeschrieben. Im standardmäßigen iptables-Backend von Docker benötigt er außerdem dieselbe DOCKER-USER-Regel mit dem entsprechenden Gegenstück ip6tables. Im experimentellen nftables-Backend ab Engine 29 gibt es keine Kette DOCKER-USER. Die Filterung muss dort in Ihrer eigenen nftables-Tabelle erfolgen. In Netzwerken ohne IPv6 lauscht der Prozess docker-proxy auf [::]. Dieser Datenverkehr durchläuft INPUT und kann dort von UFW gefiltert werden, wenn UFW IPv6 verwaltet. Eine Veröffentlichung auf 127.0.0.1 vermeidet beide Fälle, weil auf IPv6 überhaupt nichts lauscht.