Skip to main content
Security Fundamentals · lesson 4 of 8
Security Fundamentals beginner Lesson 4 of 8

Attack Surface: Every Listening Port Is a Door

List a container's listening ports with ss, see an admin port reachable on 0.0.0.0 from a second container, rebind it to 127.0.0.1, and check it in code.

By SkillByExample · Published

security attack-surface ss docker hardening

Every socket in the LISTEN state is a door into the system. Its security depends on the code behind it, and you can’t have a bug in a service nobody can reach. This lesson takes inventory of one container’s doors, shows one left open by mistake, closes it, and then turns the inventory into a check a pipeline can run. Everything runs in your own Docker containers on a private network. Nothing else is touched. Tested with Docker Engine 29.8.2, Python 3.12.15 (python:3.12-slim) and ss from iproute2 6.15.0. If Docker is new to you, read Introduction to Docker first.

Three services, three bind addresses

The application starts a public API, an admin panel and a metrics endpoint. The admin panel’s bind address comes from the command line, and it defaults to every interface.

# services.py
import socket, sys, threading, time

ADMIN_HOST = sys.argv[1] if len(sys.argv) > 1 else "0.0.0.0"
LISTENERS = [("0.0.0.0", 8080, "public api"),
             (ADMIN_HOST, 9090, "admin panel"),
             ("127.0.0.1", 9100, "metrics")]

def serve(host, port, name):
    srv = socket.create_server((host, port))
    while True:
        conn, _ = srv.accept()
        conn.sendall(f"{name}\n".encode())
        conn.close()

for host, port, name in LISTENERS:
    threading.Thread(target=serve, args=(host, port, name), daemon=True).start()
print("admin panel bound to", ADMIN_HOST, flush=True)
time.sleep(3600)

The python:3.12-slim image has no ss, so add iproute2 in a two-line image, put both containers on a private network, and start the app with its default settings:

FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /work
docker build -t surface-lab .
docker network create surface-lab
docker run -d --name app --network surface-lab -v "$PWD":/work surface-lab python services.py

Take the inventory with ss

ss -tlnp lists TCP sockets (-t) that are listening (-l), with numeric addresses (-n) and the owning process (-p).

$ docker exec app ss -tlnp
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN 0      128          0.0.0.0:9090       0.0.0.0:*    users:(("python",pid=1,fd=4))
LISTEN 0      128          0.0.0.0:8080       0.0.0.0:*    users:(("python",pid=1,fd=3))
LISTEN 0      128        127.0.0.1:9100       0.0.0.0:*    users:(("python",pid=1,fd=5))
LISTEN 0      4096      127.0.0.11:37207      0.0.0.0:*

Read the Local Address column. 0.0.0.0 means every interface, so the API and the admin panel accept connections from anywhere that can route to this container. 127.0.0.1 is loopback, so metrics only accept connections from inside the container. The fourth line isn’t ours. It’s Docker’s embedded DNS resolver, which every container on a user-defined network gets at 127.0.0.11. It has no process inside the container, and its port changes on every run. An inventory has to account for listeners you didn’t start yourself.

Confirm what a neighbour can actually reach

ss shows what’s bound. To prove what’s reachable, connect from a second container on the same network, the position of any other compromised workload.

# probe.py
import socket, sys

host = sys.argv[1]
for port in (8080, 9090, 9100):
    try:
        with socket.create_connection((host, port), timeout=2) as s:
            print(f"{host}:{port} open    -> {s.recv(100).decode().strip()}")
    except OSError as e:
        print(f"{host}:{port} blocked -> {e.strerror or e}")
$ docker run --rm --network surface-lab -v "$PWD":/work -w /work python:3.12-slim python probe.py app
app:8080 open    -> public api
app:9090 open    -> admin panel
app:9100 blocked -> Connection refused

The admin panel answers a container that has no business talking to it. Nothing was wrong with the admin code itself. The exposure came from one default argument. Metrics, bound to loopback, gives Connection refused, which means the packet arrived but nothing on that interface was listening. This probe checks three named ports on a container you own. Point it only at systems you run.

The fix: bind internal services to loopback

Restart the app with the admin panel bound to 127.0.0.1, then probe again from both sides.

docker rm -f app
docker run -d --name app --network surface-lab -v "$PWD":/work surface-lab python services.py 127.0.0.1
$ docker run --rm --network surface-lab -v "$PWD":/work -w /work python:3.12-slim python probe.py app
app:8080 open    -> public api
app:9090 blocked -> Connection refused
app:9100 blocked -> Connection refused
$ docker exec app python probe.py 127.0.0.1
127.0.0.1:8080 open    -> public api
127.0.0.1:9090 open    -> admin panel
127.0.0.1:9100 open    -> metrics

From the neighbour, the admin panel now looks exactly like metrics: refused. From inside the container it still works, so local tools and docker exec sessions lose nothing. The surface went from two network-facing doors to one, and the door that remains is the one you meant to open. If an operator needs the admin panel from outside, put it behind something that authenticates them, such as an SSH tunnel or a reverse proxy with login. Don’t widen the bind.

Turn the inventory into a check

A manual ss review gets forgotten after the next deploy. This script allowlists the listeners that are meant to face the network and exits non-zero on anything else, so CI or a health check can fail on it.

# surface_check.py
import ipaddress, subprocess, sys

ALLOWED = {"0.0.0.0:8080"}          # the only listener meant to face the network
out = subprocess.run(["ss", "-tlnH"], capture_output=True, text=True).stdout
unexpected = 0
for line in out.splitlines():
    local = line.split()[3]          # e.g. 0.0.0.0:9090 or 127.0.0.1:9100
    host = local.rsplit(":", 1)[0].strip("[]")
    if host != "*" and ipaddress.ip_address(host).is_loopback:
        print(f"{'local':<10} {local}")
        continue
    ok = local in ALLOWED
    unexpected += not ok
    print(f"{'allowed' if ok else 'UNEXPECTED':<10} {local}")
sys.exit(1 if unexpected else 0)

Run against the first, exposed container, and then against the fixed one:

$ docker exec app python surface_check.py; echo "exit=$?"
UNEXPECTED 0.0.0.0:9090
allowed    0.0.0.0:8080
local      127.0.0.1:9100
local      127.0.0.11:37207
exit=1
$ docker exec app python surface_check.py; echo "exit=$?"
local      127.0.0.1:9090
local      127.0.0.1:9100
local      127.0.0.11:35247
allowed    0.0.0.0:8080
exit=0

The check fails on the exposed admin port and passes once it’s rebound. ipaddress.ip_address(...).is_loopback covers the whole 127.0.0.0/8 range, which is why Docker’s resolver at 127.0.0.11 is classed as local instead of raising a false alarm. A plain string comparison with "127.0.0.1" would have flagged it on every run, and a check that always fails gets ignored. -H drops the header row so every line is a socket. When you’re finished, clean up with docker rm -f app and docker network rm surface-lab.

Practice

  1. Catch a debugging leftover. Start python -m http.server 8000 inside the running, fixed app container, the kind of thing someone forgets after a debugging session. Does surface_check.py notice?
Solution
$ docker exec -d app python -m http.server 8000
$ docker exec app python surface_check.py; echo "exit=$?"
local      127.0.0.1:9090
local      127.0.0.1:9100
local      127.0.0.11:35247
allowed    0.0.0.0:8080
UNEXPECTED 0.0.0.0:8000
exit=1

It does. http.server binds to every interface by default, so a quick “let me look at these files” exposes the whole working directory to the network. The allowlist catches it because it lists what should be there instead of guessing what shouldn’t.

  1. Name the owner. An UNEXPECTED line is more useful when it says who opened the port. Extend the check to read the process name and PID from ss -tlnpH, and its full command line from /proc.
Solution
# surface_owner.py
import ipaddress, re, subprocess

ALLOWED = {"0.0.0.0:8080"}
out = subprocess.run(["ss", "-tlnpH"], capture_output=True, text=True).stdout
for line in out.splitlines():
    local = line.split()[3]
    host = local.rsplit(":", 1)[0].strip("[]")
    if host == "*" or not ipaddress.ip_address(host).is_loopback:
        if local not in ALLOWED:
            m = re.search(r'users:\(\("([^"]+)",pid=(\d+)', line)
            owner = f"{m[1]} pid {m[2]}" if m else "unknown"
            cmd = open(f"/proc/{m[2]}/cmdline").read().replace("\0", " ") if m else ""
            print(f"UNEXPECTED {local} owned by {owner}: {cmd.strip()}")
$ docker exec app python surface_owner.py
UNEXPECTED 0.0.0.0:8000 owned by python pid 34: python -m http.server 8000

/proc/<pid>/cmdline separates arguments with NUL bytes, hence the replace. The process name alone would be python, which matches the real service too. The command line is what tells the two apart.

  1. Keep the tool, close the door. Stop the leftover server, start it again bound to loopback only, and prove that the check passes and the neighbour is refused.
Solution

kill isn’t installed in the slim image, so stop the process from Python:

$ docker exec app python -c "import os, signal; os.kill(34, signal.SIGTERM)"
$ docker exec -d app python -m http.server --bind 127.0.0.1 8000
$ docker exec app python surface_check.py; echo "exit=$?"
local      127.0.0.1:9090
local      127.0.0.1:9100
local      127.0.0.1:8000
local      127.0.0.11:35247
allowed    0.0.0.0:8080
exit=0
$ docker run --rm --network surface-lab python:3.12-slim python -c "import socket; socket.create_connection(('app', 8000), timeout=2)"
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/usr/local/lib/python3.12/socket.py", line 865, in create_connection
    raise exceptions[0]
  File "/usr/local/lib/python3.12/socket.py", line 850, in create_connection
    sock.connect(sa)
ConnectionRefusedError: [Errno 111] Connection refused

--bind 127.0.0.1 keeps the tool working from inside the container and removes it from the attack surface. Use the PID that your own surface_owner.py run printed.

Frequently Asked Questions

What is an attack surface?
An attack surface is the sum of every way an attacker could interact with a system: listening network ports, web endpoints, file uploads, user accounts, installed packages, and so on. Each one is a place where a flaw can be reached. Reducing the attack surface means removing or restricting entry points that are not needed, so there is less to defend.
What is the difference between binding to 0.0.0.0 and 127.0.0.1?
A server bound to 0.0.0.0 accepts connections on every network interface of the machine, so anything that can route to it can connect. A server bound to 127.0.0.1 only accepts connections from the same machine, over the loopback interface. Admin panels, metrics endpoints and debugging servers that only local tools use should bind to 127.0.0.1.
How do I list the open ports on a Linux machine or container?
Run ss -tlnp. The flags mean TCP, listening sockets only, numeric addresses and ports, and show the owning process. The Local Address column shows what each socket is bound to: 0.0.0.0 or * means every interface, and 127.0.0.1 means loopback only. In a slim container image you may need to install the iproute2 package first.
Why is there a listener on 127.0.0.11 inside my Docker container?
On a user-defined Docker network, Docker runs an embedded DNS resolver for the container at 127.0.0.11, so container names resolve to addresses. It appears in ss output without an owning process inside the container, and its port changes between runs. It is loopback only, so it is not reachable from other containers, but a surface check must not mistake it for one of your services.
Does a firewall make binding to 0.0.0.0 safe?
A firewall is a second layer, not a replacement. If the firewall rule is removed, misordered, or never applied in a new environment, a service bound to every interface is exposed immediately. Binding internal services to loopback means they stay unreachable even when the firewall fails, which is the defense in depth principle.