Skip to content
shellhacks.dev
Security

How to Understand and Reduce Your Attack Surface Fast

Reduce the attack surface of a Linux server: list listening ports, remove unused packages and services, close the firewall and keep patches current.

6 min read
Summarize in AI
Illustration for "How to Understand and Reduce Your Attack Surface Fast": a terminal session, gears, progress bars.

You cannot defend what you do not know is running. A fresh server often has more installed and listening than its owner thinks: a mail daemon, a database bound to all interfaces, an old test service. Reducing the attack surface is the least glamorous and most effective security work, and it starts with an inventory. It applies the principle in least privilege to the whole machine. The commands assume Debian 13 or Ubuntu 24.04.

Inventory what listens

Shell
sudo ss -tulpn
sudo ss -tulpn | awk 'NR>1 && $5 !~ /^(127\.|\[::1\])/ {print $1, $5, $7}'
Text
Netid State  Local Address:Port  Process
tcp   LISTEN 0.0.0.0:22          users:(("sshd",pid=801))
tcp   LISTEN 0.0.0.0:3306        users:(("mysqld",pid=1120))
tcp   LISTEN 127.0.0.1:6379      users:(("redis-server",pid=1033))
udp   UNCONN 0.0.0.0:5353        users:(("avahi-daemon",pid=612))

Anything on 0.0.0.0 or [::] accepts connections from every interface. Here MySQL listens publicly, which is a likely mistake: bind it to 127.0.0.1 or a private address, or block it in the firewall. Avahi (mDNS) on a server is rarely needed. Redis on loopback only is fine. Run this check from outside too, with nmap -p- server, since a cloud firewall changes what is reachable.

Remove what you do not use

Shell
systemctl list-unit-files --state=enabled --no-pager | head -30
sudo systemctl disable --now avahi-daemon cups-browsed bluetooth
sudo apt purge avahi-daemon cups-browsed
apt list --installed 2>/dev/null | wc -l
sudo apt autoremove --purge

Disable first to see whether anything breaks, then remove the package. A package that is not installed cannot have a vulnerability on your server. Compilers, debuggers and network tools on a production machine help an attacker as much as an administrator, so keep production images minimal and install tools in a separate debugging container when needed. The same idea drives distroless container images, see scanning images.

Close the firewall

Shell
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 203.0.113.0/24 to any port 22 proto tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status numbered

Default-deny turns a mistake (a service started on a new port) into a closed port rather than an exposed one. Restrict management ports, SSH and databases, to known networks or a VPN. Remember that Docker publishes ports by editing iptables directly and can bypass ufw: bind published ports to loopback (-p 127.0.0.1:8080:8080) or manage the DOCKER-USER chain. Egress filtering is optional and limits what malware can reach.

Reduce privileges and exposure

Shell
sudo systemd-analyze security --no-pager | head -15
sudo awk -F: '$2 == "" {print $1}' /etc/shadow
sudo grep -E '^(PermitRootLogin|PasswordAuthentication)' /etc/ssh/sshd_config
find / -xdev -perm -4000 -type f 2>/dev/null

Run services as dedicated users with the sandboxing from namespaces and seccomp, disable password logins and root SSH (hardening sshd), and review setuid binaries: each one is a possible escalation path. Remove accounts that are no longer used and lock empty passwords. Shared credentials multiply the exposure.

Keep it patched

Shell
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
apt list --upgradable 2>/dev/null | head

Most compromises use known vulnerabilities that were patched months earlier. Automatic security updates (with a plan for reboots when the kernel changes) close that window; see auditing packages for vulnerabilities. Reduce time-to-patch for internet-facing software first.

Thinking in exposure, not in vulnerabilities

A vulnerability list asks which flaws exist in the software you run. Attack surface asks which software an outsider can reach at all, and the second question is easier to answer and more stable over time. A flaw in a service that listens only on loopback needs a second compromise to be reachable, while the same flaw in a service on a public address is a problem the day it is published. Reducing exposure therefore lowers risk from vulnerabilities nobody has found yet, which is the only kind a patch schedule cannot help with.

The surface has layers. The outermost is the network: which addresses and ports respond. Next is the software behind them: the daemon, its modules and the libraries it loads. Then the identities that can log in, and the credentials they hold. Last is the data and privileges reachable after a successful login. Work from the outside in, because each closed port removes everything behind it from consideration.

Doing the inventory properly

The ss listing shows what listens on the host, and it is blind to what the network lets through. A scan from a separate machine shows the real picture, including ports forwarded by a cloud security group or a container runtime. Run both and compare: a port that appears in the scan but not in the host listing is being forwarded from somewhere else, and one that appears on the host but not in the scan is blocked upstream, which is fine today and a hidden dependency tomorrow if someone changes the rule.

Include UDP and IPv6. Many hosts have an IPv4 firewall rule set carefully reviewed and an IPv6 stack with no rules at all, so a service blocked on one address family answers on the other. Check with ss -6 and scan the machine's IPv6 address as well. Also look at outbound connections with ss -tnp while the machine is idle; a long-lived connection to an address nobody recognises is worth a conversation.

Containers and shared hosts

A container runtime changes the inventory. Each published port is a new listener, and the process inside runs with whatever user and capabilities the image defines. Review images for a non-root user, a read-only root file system where possible, and the removal of shells and package managers from the final stage. On a host that runs containers, the container daemon's own socket is as sensitive as root: any process that can write to it can start a privileged container, so never mount it into an application container without a very specific reason.

Shared hosts have another concern, which is that one tenant's weak service becomes everyone's problem. If a host runs several applications, put each under its own user with its own directories and a systemd sandbox, so a compromise of one cannot read the others' configuration. The sandbox options, such as private temporary directories, read-only system paths and a restricted set of system calls, cost a few lines in a unit file and remove whole classes of post-exploitation moves.

Keeping the surface small over time

Surfaces grow by accretion: a debugging port left open, a package installed to diagnose an outage, an account created for a contractor. Counter this with a schedule. A monthly job that records the listening ports, the enabled units and the list of accounts, and compares them with the previous month, turns drift into a short diff that a person reviews. Anything new should have a reason in the change history, and anything without one is a candidate for removal. The goal is to make adding exposure require a decision and removing it cost nothing.

Where this does not apply

Reducing surface is not a one-time task; each new feature adds a port or a dependency, so repeat the inventory after changes and on a schedule. It does not protect against flaws in the services you need to keep. Defence in depth still applies: monitoring, backups and an incident plan, as in responding to a compromise. See the CIS Benchmarks for detailed baselines and the ss manual page.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.