Container networking
Sometimes you just want to mess with networking. Docker is great for easily setting up network environments to test different tools. There are so many different options.
Three modes
There are three different network modes with docker. Let’s look at what each one does.
--network none gives loopback and nothing else. Nothing in, nothing out:
$ docker run --rm --network none builder ip -br a
lo UNKNOWN 127.0.0.1/8 ::1/128
This is the mode for running something you do not trust.
Bridge is the default. The container gets a veth pair into a virtual switch on the host and a private address:
$ docker run --rm builder ip -br a
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0@if74 UP 172.17.0.2/16
Outbound traffic is NATed behind your host’s address. The container can reach the internet and other containers on the same bridge. Nothing on your LAN can reach it unless you publish a port.
--network host removes the network namespace entirely. The container uses
the host’s stack, with the host’s interfaces and addresses:
$ docker run --rm --network host builder ip -br a
lo UNKNOWN 127.0.0.1/8 ::1/128
ens18 UP 192.168.7.186/24 metric 100 fe80::be24:11ff:fe52:8201/64
docker0 UP 172.17.0.1/16 fe80::9488:10ff:fe5d:9eee/64
That is the host’s real LAN address. This is the mode you want when a container is a substitute for a locally installed tool: scanning the LAN, sniffing an interface, running a listener that a target has to reach directly.
On Linux, --network host is the host. On Docker Desktop, it is the Linux VM’s
stack, which is not your hosts’s LAN interface, so LAN scans from a docker
desktop container do not behave the way you expect.
Publishing a port
-p 8080:80 maps a host port to a container port. By default it listens on any.
$ docker run -d --name pub -p 8080:80 nginx:alpine
$ ss -ltn | grep 8080
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:*
LISTEN 0 4096 [::]:8080 [::]:*
All interfaces. That vulnerable target you spun up to practise against is now
reachable by anything on your network. Docker also writes its own
iptables rules, which on many setups means a host firewall like ufw does not
filter published ports the way you assumed it does.
Bind it explicitly when it is only for you:
$ docker run -d --name pub2 -p 127.0.0.1:8081:80 nginx:alpine
$ ss -ltn | grep 8081
LISTEN 0 4096 127.0.0.1:8081 0.0.0.0:*
Your own networks, and free DNS
Create a network and you get a subnet you chose and, more usefully, name resolution between the containers on it:
$ docker network create --subnet 10.13.37.0/24 recon
$ docker run -d --name web --network recon nginx:alpine
$ docker run --rm --network recon attackbox sh -c 'getent hosts web; curl -s -o /dev/null -w "web %{http_code}\n" http://web/'
10.13.37.2 web
web 200
web resolved because Docker runs an embedded DNS server for user-defined
networks that answers with container names and aliases. This is why lab
compose files can refer to juice-shop rather than an address that changes on
every restart.
The default bridge does not do that. From a container on it, the same name goes nowhere useful:
$ docker run --rm attackbox sh -c 'getent hosts web'
127.0.53.53 web
$ docker run --rm attackbox curl -m 5 -sS http://web/
curl: (7) Failed to connect to web:80 after 3 ms: Could not connect to server
127.0.53.53 is the name collision address, Docker’s way of saying “that name
means something here, but not on your network”, so you get a fast connection
failure rather than a DNS timeout. The lesson: always put related containers on
a network you created.
A container can be on several networks at once, which is the whole basis of the segmented lab we build next: a machine bridging two subnets is a machine worth compromising.
Wearing another container’s stack
This is sometimes called a sidecar. --network container:<name> puts a new
container in an existing container’s network namespace. Same interfaces, same
addresses, same loopback:
$ docker run --rm --network container:web attackbox ip -br a
lo UNKNOWN 127.0.0.1/8 ::1/128
eth0@if127 UP 10.13.37.2/24
That is web’s address, in a container that has our tools in it. Three uses
follow directly:
- Sniff another container’s traffic. Run
tcpdumpin a container that shares the target’s stack and you see everything it sends and receives, without installing anything in the target. - Reach services bound to loopback. A process listening on
127.0.0.1inside a container is unreachable from any other container, except this one, where its loopback is your loopback. - Borrow network placement. If a container sits on a network you are not on, sharing its stack puts you there. Useful in a lab, and worth recognising as a technique when it is used against a host you are defending.
The same idea, one namespace further, is how you end up on the host itself. That is lesson 8.
Reading the map
Two commands for working out what exists:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
f5fb625502a3 bridge bridge local
2ec9652013e4 host host local
c3e7261750b3 none null local
$ docker network inspect recon --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
web 10.13.37.2/24
docker network inspect is the fastest way to answer “what is on this network
and at what address”, and on a compose lab it saves you a scan.
One more piece of geography. On a bridge network, the .1 address is the host,
because that is the gateway:
$ docker run --rm --network recon attackbox sh -c 'ip route | head -1'
default via 10.13.37.1 dev eth0
Anything listening on your host is accessible from every container you run that has a network. Keep that in mind before you start a container with something you don’t trust.
Next we put all of this together into a lab with two subnets and something to pivot through.