Security Fundamentals · lesson 4 of 8
Beginner
4Intermediate
4Also part of Security Fundamentals
12- + Permission Bits, Ownership, and Why passwd Needs setuid from Linux
- + Database Security from SQL
- + ACL: a User Restricted to One Key Pattern and One Command from Redis
- + Kafka Security: SASL Authentication and a Real ACL Denial from Kafka
- + IAM and Least Privilege from Cloud Interviews
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + Docker Security from Docker
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + Hashes and HMACs: What Each One Actually Proves from Cryptography
- + Broken Access Control and IDOR: Fix It in Code, Pin It with a Test from Web Security (OWASP Top 10, fix-side)
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
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
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
- Catch a debugging leftover. Start
python -m http.server 8000inside the running, fixedappcontainer, the kind of thing someone forgets after a debugging session. Doessurface_check.pynotice?
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.
- Name the owner. An
UNEXPECTEDline is more useful when it says who opened the port. Extend the check to read the process name and PID fromss -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.
- 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.