Im Rahmen eines unserer 4+1-Projekte hatten wir einige Docker-Container aufgesetzt – z.B. für Jenkins, eine PostgreSQL-Datenbank, eine MongoDB... Geht ja alles schön einfach mit Docker.
Damit man auf die Dienste bequem zugreifen kann (und die Dienste sich untereinander schön sehen können), publiziert man üblicherweise die container-internen Ports einfach auf den Docker-Host.
Und damit wird's gefährlich.
Zumindest wenn der Docker-Host zugleich der Host ist, der über das böse, weite Internet zu erreichen ist.
Bei den ersten Gehversuchen mit Docker ist man glücklicherweise ganz gut vom Internet abgeschirmt, denn die Docker-Engine läuft meist in einer Virtuellen Maschine (VM) innerhalb des eigenen Host-Betriebssystems auf dem Notebook. Im Falle von Windows oder MacOS kann die Docker-Engine gar nicht nativ betrieben werden. Das Setup aus Host, VM, Docker-Engine und Docker-Container sieht dann in etwa so aus:
Will man den Port des Dienstes im Docker-Container, sagen wir 8080, über die Docker-Engine der VM anbieten, dann muss er beim Starten des Docker-Containers mit dem Parameter -p "gepublished" werden, z.B. so:
> docker run -p 8082:8080
Für das Anbieten des Ports ist ein Docker-Tool im Userland der VM zuständig, das sich docker-proxy nennt. Für jeden mittels -p angegebenen Port wird ein solcher Proxy gestartet:
> docker run -p 8082:080 -p 50000:50000
> docker ps
221d1cf107af [...] 0.0.0.0:8082->8080/tcp, 0.0.0.0:50000->50000/tcp
> ps -ef | grep docker-proxy
root 21708 21461 0 Mai22 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 \
-host-port 50000 -container-ip 172.17.0.2 -container-port 50000
root 21719 21461 0 Mai22 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 \
-host-port 8082 -container-ip 172.17.0.2 -container-port 8080
Das sieht bislang alles ganz harmlos aus. Wenn man genauer hinschaut, bemerkt man jedoch, dass die Host-IP, an die der Proxy den Host-Port bindet, 0.0.0.0 lautet. Das ist IPv4 für "alle Adressen" – also alle Adressen an allen Interfaces. Für unser Standard-Szenario mit einer VM für die Docker-Engine wären das die Interfaces vboxnet1 und lo (loopback). Und damit kann man von seinem eigenen Host schön über vboxnet1 mit Port 8082 auf den Dienst zugreifen. Der Docker-Proxy leitet die Requests nämlich an die IP des Docker-Containers und Port 8080 weiter. Das Routing dorthin führt mit Hilfe der VM und der Docker-Engine über das Interface docker0, das zu eth0 korrespondierende virtuelle Bridge-Interface veth184471 und schließlich zu eth0 mit Port 8080 im Container.
Hier passiert also ziemlich viel Netzwerk-Magie unter der Haube. Da ist man froh, dass alles ohne viel Zutun von alleine funktioniert.
Jetzt wechseln wir das Szenario und lassen unseren eigenen Rechner selbst zum Docker-Host werden. Das liegt nahe, wenn der eigene Rechner bereits ein Linux laufen hat. Oder ein kleiner Server unter dem Tisch ist. Oder ein kleiner Server im Internet.
Damit verschwindet eine "Zwiebelschale" aus unserem Szenario, die Virtual Machine, und mit ihr auch ein Netzwerk-Interface, nämlich vboxnet1. Das Docker-Netzwerk-Interface docker0 existiert nun unmittelbar auf dem Host-Betriebssystem.
Die eingesparte "Zwiebelschale" hatte im ursprünglichen Szenario aber eine wichtige Schutzfunktion für uns Docker-Naivlinge: Sie schirmt das böse Internet (auf Interface eth0 des Hosts) vom lokalen Docker-Netz (auf vboxnet1 und docker0) ab, in dem jeder alles sehen darf.
Ohne diese "Schutzschicht" wird der unklug eingesetzte Paramter -p beim Starten eines Containers zum Problem. Der docker-proxy wird auch in diesem Szenario für jeden publizierten Port direkt auf dem Host-Betriebssystem gestartet. Ohne Angabe einer IP-Adresse beim Parameter -p wird wieder IP 0.0.0.0 angenommen und docker-proxy öffnet den Host-Port an allen Interfaces auf dem Host-Betriebssystem: am Loopback-Device lo (nicht schlimm), am VPN-Tunnel-Device tun0 (auch nicht schlimm, vermutlich sogar recht klug) und am Ethernet-Device eth0 (das mit dem Internet dahinter).
Damit wird es ungemütlich, denn womöglich hatten wir gar nicht vor, dass z.B. der Jenkins über Port 8082 auch von außen sichtbar ist. Denn wenn wir uns gefragt hätten, ob wir das wollen, hätten wir mit Sicherheit "Nein" gesagt.
Wir haben uns das aber nicht gefragt, sondern einfach übernommen, was sonst eben so gemacht wird: docker -p 8082:8080 Zack! Fertig. Port verfügbar. Irgendwie. Irgendwo. Geht erstmal.
Immerhin sind wir mal so vorsichtig und schauen, ob denn ein Dienst (an eth0) angeboten wird:
> netstat -tlpn
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 17183/nginx: master
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 17183/nginx: master
tcp6 0 0 :::8082 0.0.0.0:* LISTEN 21719/docker-proxy
tcp6 0 0 :::50000 0.0.0.0:* LISTEN 21708/docker-proxy
Mhm, irgendwie scheinen die Ports 8082 und 50000 für IPv6 auf allen Interfaces zu lauschen. Aber wir haben gar kein IPv6 am Laufen, vom Provider keinen IPv6-Präfix bekommen. Kann so gefährlich also nicht sein.
Machen wir zur Vorsicht mal noch einen Portscan von außen:
other_machine> nmap myhost.example.com -p 8082,50000
Starting Nmap 7.12 ( https://nmap.org ) at 2017-05-22 15:34 CEST
Nmap scan report for myhost.example.com
Host is up (0.027s latency).
PORT STATE SERVICE
8082/tcp open blackice-alerts
50000/tcp open ibm-db2
Ohauerha! Beide Ports sind von außen erreichbar. (Dass da nichts von Jenkins sondern nur von blackice-alerts und der ibm-db2 die Rede ist, soll uns nicht verwirren. Das sind lediglich die Bezeichnungen von Diensten, die diese Ports selbst als ihren Standard definiert haben. Hinter beiden Ports läuft trotzdem unser Jenkins, was man mit dem Browser auch einfach nachprüfen kann.)
Aber wie kann das sein? Die beiden Ports lauschen doch angeblich nur für IPv6 an allen Devices!
Das Geheimnis hinter Ports, die nur unter IPv6 offen zu sein scheinen und trotzdem auch über IPv4 zu erreichen sind, nennt sich "IPv4-mapped IPv6-Adresses". Das ist eine Fähigkeit von IPv6, den Umstieg von IPv4 zu erleichtern. Standardmäßig ist dieses Feature auf vielen Linux-Distributionen angeknipst; so auch auf unserer Maschine.
Und was bedeutet das nun?
Das bedeutet, dass ein Dienst, der nur IPv6 sprechen möchte, trotzdem auf einer IPv4-Adresse angesprochen werden kann. Dabei wird diese IPv4-Adresse, z.B. 192.168.42.23 auf die IPv6-Adresse ::ffff:c0a8:2a17 gemapped. (Dabei ist c0a8:2a17 lediglich die Hexadezimal-Darstellung von 192.168.42.23.)
Wenn nun also unser docker-proxy auf 0.0.0.0 also allen IP-Adressen lauschen soll, dann tut er das gründlich und zwar auch im IPv6-Adressraum (in dem 0.0.0.0 einfach als :: angegeben ist). Und wenn er in IPv6 an :: lauscht, dann kann er damit dank den IPv4-mapped-Addresses auch gleich den IPv4-Bereich abfrühstücken ohne den explizit erwähnen zu müssen.
Genau diese Bequemlichkeit leistet sich Linux an der Stelle und gibt im Kommando netstat die offenen Ports lediglich für IPv6 an und nicht zusätzlich für IPv4. Achtet man nur auf die IPv4-Angaben, weil IPv6 eh auf dem Server nicht genutzt wird, dann übersieht man jede Menge offener Ports! Wir lernen also schonmal aus der Geschichte: Stets IPv4 und IPv6 beachten, egal, ob nur eines von beiden aktiv zu sein scheint.
Prima. Nun wissen wir also, dass wir ungewollt Ports unserers Docker-Containers der weiten Welt anbieten. Und wie schränken wir das jetzt ein?
Indem wir beim Publishen der Ports vom Docker-Container auch die gewünschte Host-IP mit angeben.
Statt
> docker run -p 8082:8080 -p 50000:50000
rufen wir nun also
> docker run -p 127.0.0.1:8082:8080 -p 127.0.0.1:50000:50000
auf.
Das sorgt dafür, dass der docker-proxy mit dem Parameter host-ip 127.0.0.1
(statt 0.0.0.0
) gestartet wird. Das bedeutet nichts anderes, als dass der Port 8082 nur am Interface mit der IP 127.0.0.1 geöffnet wird. Dieses Interface ist das (harmlose) Loopback-Device.
Zusätzlich könnte man den Port des Docker-Containers auch an die IP 10.5.0.15 für das VPN-Tunnel-Interface tun0 binden. Dann wäre auch ein Remote-Zugriff auf den Container über eine sichere VPN-Verbindung möglich.
> docker ps
221d1cf107af [...] 127.0.0.1:8082->8080/tcp, 127.0.0.1:50000->50000/tcp
> ps -ef | grep docker-proxy
root 21708 21461 0 Mai22 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 127.0.0.1 \
-host-port 50000 -container-ip 172.17.0.3 -container-port 50000
root 21719 21461 0 Mai22 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 127.0.0.1 \
-host-port 8082 -container-ip 172.17.0.3 -container-port 8080
Kontrollieren wir alles noch mit netstat ...
> netstat -tlpn
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 17183/nginx: master
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 17183/nginx: master
tcp 0 0 127.0.0.1:8082 0.0.0.0:* LISTEN 21719/docker-proxy
tcp 0 0 127.0.0.1:50000 0.0.0.0:* LISTEN 21708/docker-proxy
... und nmap von einer anderen Maschine aus ...
other_machine> nmap myhost.example.com -p 8082,50000
Starting Nmap 7.12 ( https://nmap.org ) at 2017-05-22 15:34 CEST
Nmap scan report for myhost.example.com
Host is up (0.027s latency).
PORT STATE SERVICE
8082/tcp closed blackice-alerts
50000/tcp closed ibm-db2
Alles wieder sicher :-)
Was lernen wir daraus? Für Docker gilt wie für jede andere Software, Tools, Dienste usw. auch: Wenn man damit auf einem Host im Internet herumspielen möchte, dann darf man nicht mehr einfach herumspielen. Man muss sich Gedanken über die Sicherheit machen, über angebotene Ports, Authentifizierung, Verschlüsselung etc. Man kann dann gern auch Ports nach außen anbieten, aber man darf es nicht aus Versehen oder aus Gedankenlosigkeit oder Unwissenheit tun.
Wem das für's bloße Experimentieren mit Docker zu viel dröge Admin-Arbeit ist, der sollte besser nicht auf einem Host im Internet experimentieren. Auf seinem lokalen Rechner oder hinter der Firewall des Firmennetzes, gut abgeschirmt vom Internet, ist er besser aufgehoben. Das mag ungewollte Einschränkungen mit sich bringen. Aber man kann Bequemlichkeit und Sicherheit meist nicht gleichzeitig haben.
Und wer nun glaubt, dass das alles nur akademische Betrachtungen sind, der kann ja mal eine Mongo-DB von der Stange in einem Docker-Container auf einem kleinen Host im Internet laufen lassen. Aber nicht wundern, wenn das BSI dann freundlich per Mail auf den Unfug hinweist ;-)