CoursesAdvanced container securityContainer network hardening

Container network hardening

Default-deny egress, no inter-container comms, block metadata.

Advanced15 min · lesson 18 of 25

In 2019 someone walked off with data on 100 million people from Capital One without breaking into a single machine the hard way. The bug was a server-side request forgery (SSRF, where you trick an application into making a web request for you, to an address you pick). The address picked was 169.254.169.254, the cloud metadata service, and it handed back the instance's IAM credentials (Identity and Access Management, the cloud's system of temporary keys that decide what an account is allowed to do). No container escape. No exploit chain. The network did all the work. A hardened image sitting on a wide-open network is still a soft target, because Docker's default bridge lets every container reach every other container and the whole internet. That is the shape an attacker hopes for after getting a foothold: flat ground to move sideways across, and an open door to pull in more tools and carry stolen data out.

The default bridge is one big open-plan office

A network namespace works like a sealed room with its own network card, so a container sees its own interfaces and nothing else. Docker's default bridge then wires all those rooms onto one shared switch, the way a cheap office hub puts every desk on the same wire. Inter-container communication (ICC, the bridge setting that decides whether containers on it may talk to each other) is on out of the box. So is the route to the internet. A process that breaks out of your application and lands a shell can scan its neighbors and reach your database on the first try, with nothing configured on its side. Prove the exposure before you fix it, and read what the setting actually says.

detect-flat-network
# two throwaway containers land on Docker's default bridge
$ docker run -d --name a alpine sleep 1d && docker run -d --name b alpine sleep 1d
$ B_IP=$(docker inspect -f '{{.NetworkSettings.IPAddress}}' b)
$ docker exec a ping -c1 -W1 "$B_IP"
64 bytes from 172.17.0.3: seq=0 ttl=64 time=0.072 ms # 'a' reached 'b' with no config
# detection: is inter-container comms left on?
$ docker network inspect bridge -f '{{index .Options "com.docker.network.bridge.enable_icc"}}'
true # every container on this bridge can reach every other one

Two fixes, both blunt, both cheap. Turn inter-container communication off. Then stop putting workloads on the shared default bridge at all, and give each group of services a user-defined network that wires together only the services which genuinely need each other. Here is the same ping once both containers sit on a bridge with ICC disabled.

fix-icc-false
# a user-defined bridge with inter-container comms turned OFF
$ docker network create -o com.docker.network.bridge.enable_icc=false segwall
$ docker run -d --name a --network segwall alpine sleep 1d
$ docker run -d --name b --network segwall alpine sleep 1d
$ B_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' b)
$ docker exec a ping -c1 -W2 "$B_IP"; echo "exit=$?"
PING 172.20.0.3 (172.20.0.3): 56 data bytes
--- 172.20.0.3 ping statistics ---
1 packets transmitted, 0 packets received, 100% packet loss # dropped, no lateral path
exit=1

Unplug the workloads that never needed plugging in

Plenty of jobs never speak to anyone. A nightly report. A batch run. A step that transforms a file and exits. For those, pull the cable out with --network none. The container still gets a network namespace, but there is nothing inside it except loopback, so there is no route to a neighbor and no route to the internet. Access you never grant cannot be abused. When containers do need each other yet have no business seeing the outside world, reach for an internal network instead. Interior doors open, exterior doors bricked over: peers reach each other fine, the network carries no default route out, and anything aimed at the internet fails on the spot.

none-and-internal
# a batch job that needs no network at all
$ docker run --rm --network none alpine ping -c1 -W1 1.1.1.1; echo "exit=$?"
ping: sendto: Network unreachable # no route off the box; only loopback exists
exit=1
# an --internal network: peers can talk, but there is no route to the outside
$ docker network create --internal backend
$ docker run --rm --network backend curlimages/curl -sS -m3 https://1.1.1.1; echo "exit=$?"
curl: (7) Failed to connect to 1.1.1.1 port 443 after 1 ms: Network unreachable
exit=7 # egress blocked at the network level, before any firewall rule runs

169.254.169.254 hands out credentials to anyone who asks

On a cloud host, that link-local address is the Instance Metadata Service (IMDS, a tiny web server the cloud provider runs on every virtual machine so the machine can look up its own identity). It serves that identity along with the node's IAM credentials over plain HTTP, with no password, no token and no authentication of any kind. The whole design rests on one assumption: only code running on the box can reach it. Containers break that assumption quietly, because your container rides the host's route to the address. So a compromised container, or an SSRF bug in the app inside it, can assume the node's cloud role and step straight into your account. It is one of the highest-impact routes from container to cloud breach, and it closes with a single firewall rule. On AWS, move to IMDSv2 and set the metadata hop limit to 1, so the extra network hop a container adds is enough to bounce the request. At the host level, drop the traffic outright.

block-metadata
# a compromised container hits IMDS with no auth (AWS shown; GCP/Azure are the same IP)
$ docker run --rm curlimages/curl -sS -m3 \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
web-node-role # the role name leaks; temporary keys are one more request away
# fix: drop it in DOCKER-USER, the chain Docker evaluates before its own rules
$ sudo iptables -I DOCKER-USER -d 169.254.169.254 -j DROP
$ docker run --rm curlimages/curl -sS -m3 http://169.254.169.254/latest/meta-data/; echo "exit=$?"
curl: (28) Connection timed out after 3001 milliseconds
exit=28 # metadata now unreachable from every container on the host

Egress is the direction everyone forgets

Most teams spend their firewall effort on traffic coming in and leave everything going out wide open. Outbound is how stolen data leaves, and how the attacker's next tool arrives. Deny it by default, then allow only the destinations a service really calls: its database, one API, and DNS (Domain Name System, the lookup service that turns names into IP addresses, on port 53). Write those rules in the DOCKER-USER chain, because Docker evaluates that chain before its own NAT rules (Network Address Translation, the address rewriting that makes published ports work) and never overwrites what you put there. While the terminal is open, audit what your containers publish. A port bound to 0.0.0.0 answers from anywhere the host answers from, so bind your 'local' services to the loopback address and keep them on the box.

lock-egress
# audit what containers actually publish (0.0.0.0 = reachable off-host)
$ docker ps --format '{{.Names}}\t{{.Ports}}'
api 0.0.0.0:5432->5432/tcp # Postgres exposed to the whole network!
# fix 1: bind to loopback so the port is host-only
$ docker run -d -p 127.0.0.1:5432:5432 --name db postgres:16
# fix 2: default-deny egress. DOCKER-USER ships with a built-in RETURN at the end,
# so INSERT above it (-I with a line number) or your rules never run.
$ sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$ sudo iptables -I DOCKER-USER 2 -p udp --dport 53 -j ACCEPT
$ sudo iptables -I DOCKER-USER 3 -d 10.6.0.0/16 -j ACCEPT
$ sudo iptables -I DOCKER-USER 4 -j DROP
$ docker run --rm curlimages/curl -sS -m3 https://exfil.example.net; echo "exit=$?"
curl: (28) Connection timed out after 3001 milliseconds
exit=28 # nothing leaves except the destinations you named
Docker walks straight past UFW and firewalld
Publishing a port makes Docker write its own iptables rules, and Docker's chain gets consulted before host firewalls like UFW (Uncomplicated Firewall) and firewalld ever get a look. So docker run -p 5432:5432 postgres opens that database to 0.0.0.0 even while ufw status swears the port is denied, because Docker has already rewritten the packet's destination by the time UFW would have seen it. People get burned by this constantly and carry on believing the firewall covers them. Two fixes that hold: bind published ports to the loopback address (-p 127.0.0.1:5432:5432) so nothing off the host can reach them, or put every allow and deny decision in the DOCKER-USER chain, the one chain Docker leaves to you and checks first.
A segmented container network vs. one flat bridge
Internet (untrusted)
Attacker
scans for open published ports
Exfil endpoint
blocked by default-deny egress
Host firewall — DOCKER-USER chain
DROP 169.254.169.254
metadata sealed from all containers
Default-deny egress
allow only DNS + DB subnet
Frontend bridge (icc=false)
web
published on 127.0.0.1 only
No lateral path
peers can't ping each other
backend network (--internal)
postgres:16
reachable only by web
redis
no route to the internet at all

Default bridge networking gives you one flat room. Containers can usually reach each other and the world outside, cloud metadata at 169.254.169.254 included. That is how an SSRF bug becomes credential theft with no escape involved.

Aim for the opposite. Custom networks with internal set to true wherever egress serves no purpose, explicit publish flags instead of broad bindings, and a host firewall or network policy that denies by default. Cut off the metadata hop for any workload that has no reason to mint cloud credentials.

Treat every network edge the way you treat a capability. Shared networks and container-to-container links deserve the same least-privilege scrutiny, because each edge is an assumption an attacker will eventually test.

What keeps this working in production is verification after every change window. Re-run docker network inspect for the ICC setting, re-run the metadata request, re-check what the containers publish, and paste both the command and its output into the ticket. If a reading drifted, the change does not get closed.

Drift is normal, and it is quiet. Someone adds a container to the default bridge because creating a network took an extra minute. A rule gets flushed on reboot because nobody persisted it. A debugging session leaves a port bound to 0.0.0.0 over the weekend. None of that shows up in application logs, which is why the check has to be a command with output rather than a memory of how you left things.

When you do have to open a path, open the narrowest one the workload can live with. One destination subnet rather than the internet. Loopback rather than every interface. One purpose-built network rather than the shared bridge. That habit compounds across every host and every pipeline you run.

Try this

Build an internal network, put two containers on it, and watch both halves of the behavior: traffic to the internet fails, while name resolution between peers on that network keeps working exactly as designed.

terminal
$ docker network create --internal locked
$ docker run -d --name a --network locked alpine sleep 300
$ docker run --rm --network locked alpine ping -c1 -W2 1.1.1.1 2>&1 | tail -2
ping: ... Network unreachable
$ docker run --rm --network locked alpine ping -c1 a
PING a (172.18.0.2): 56 data bytes
... 1 packets transmitted, 1 packets received ...
$ docker rm -f a; docker network rm locked

Takeaway

A flattened container network reinvents the old intranet mistake, where getting inside the perimeter means getting everything. Deny egress by default, park sensitive workloads on internal networks, and keep the metadata service away from anything that has no business holding a cloud role.

Quick check
01Your web container and your database container share one bridge you created with docker network create --internal db-net, so the database has no route to the internet. An attacker gets code execution in the web container. What is still within reach?
Correct — --internal takes away the default route to the outside and nothing else. Stopping container-to-container reach needs inter-container comms off (icc=false) or separate networks, plus egress rules for whatever you do let leave.
Incorrect — No. --internal governs routing to the outside world. Peers on the same internal network keep talking freely until ICC is disabled.
Incorrect — No. 169.254.169.254 sits outside the network, so a network with no route out cannot reach it either. On ordinary networks you close it with an explicit DOCKER-USER rule.
Incorrect — No. A name may still resolve, but with no default route the connection to any external IP dies with 'Network unreachable'.
02A team runs docker run -d -p 5432:5432 postgres:16 on a host where ufw status lists port 5432 as denied, and the database still answers from other machines on the network. Why does the UFW rule fail to protect it?
Incorrect — UFW filters any port you tell it to. The problem here is which rules get evaluated first.
Incorrect — The container answers on the published 5432. There is no hidden second port.
Incorrect — UFW does enforce what it lists. Docker's chain is checked ahead of it.
Correct — Docker's NAT sits above host firewalls like UFW and firewalld, so the port lands on 0.0.0.0 whatever the UFW rule says.
03You want default-deny egress, so you run iptables -A DOCKER-USER -j DROP to append the rule. Containers carry on reaching the internet. What went wrong?
Incorrect — DROP works fine here. The trouble is where the rule sits in the chain.
Correct — Appending puts the DROP past the terminating RETURN. Use -I with a line number so your rule is reached at all.
Incorrect — DOCKER-USER is the chain Docker evaluates on your behalf, and it very much applies.
Incorrect — DOCKER-USER is the supported place for these decisions and is checked before Docker's own rules.

Related