puck tools — test your egress policy against real C2 traffic →

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 tcpdump in 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.1 inside 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.