Attack surface · apt autoremove
apt autoremove: “orphaned” does not mean “unused”
Published · HardStacked · 9 min
apt autoremove removes what no installed package requires. That is not the same thing as what nobody uses.
Your scripts, your venvs, the units you wrote and the app you deployed by hand create no apt dependency. In apt’s eyes, what they rely on is an orphan.
Every hardening guide ends with the same line: clean up with apt autoremove. I am not going to tell you to skip it. I ran it, it did exactly what it is designed to do, and 26 minutes later my screen was black.
This article is both halves of that afternoon: the real security gain that makes the cleanup worth doing, and the one property of apt autoremove the guides leave out. Everything below comes from one machine and one afternoon, 2026-07-05, with the apt logs to back it.
01. First, look at what your machine is listening on
These commands are provided as is, without warranty. Test on a non critical machine or a snapshot first.
Do not take the security gain on my word. Ask your own machine:
$ sudo ss -tulnp
what mine printed at 14:11 that afternoon
udp UNCONN 0 0 224.0.0.251:5353 0.0.0.0:* users:(("chromium",pid=4667,fd=50))
udp UNCONN 0 0 224.0.0.251:5353 0.0.0.0:* users:(("chromium",pid=4667,fd=48))
udp UNCONN 0 0 0.0.0.0:5353 0.0.0.0:* users:(("avahi-daemon",pid=860,fd=12))
udp UNCONN 0 0 0.0.0.0:59966 0.0.0.0:* users:(("avahi-daemon",pid=860,fd=14))
udp UNCONN 0 0 *:5353 *:* users:(("avahi-daemon",pid=860,fd=13))
udp UNCONN 0 0 *:51733 *:* users:(("avahi-daemon",pid=860,fd=15))
tcp LISTEN 0 4096 127.0.0.1:631 0.0.0.0:* users:(("cupsd",pid=1127,fd=7))
tcp LISTEN 0 4096 [::1]:631 [::]:* users:(("cupsd",pid=1127,fd=6))
Now, if the server you just ran that on is freshly provisioned, chances are you saw almost nothing: sshd, a local DNS stub on 127.0.0.53, done. Good. Enjoy it, because that is the only day it stays true. The machine in this story had lived. Yours will have lived too, six months from now: an admin panel installed once and forgotten, a container publishing a port on every interface, a provisioning script that left an agent behind, a snapd nobody asked for. The point is not that your machine looks like mine today. It is that you now know how to look.
02. What that output actually says
Four different stories in my eight lines, and only one of them is about an open door.
Start with what you can dismiss. The two chromium lines on 224.0.0.251:5353 are a browser that happened to be open, subscribed to the standard multicast group for local discovery. That is what a running desktop application looks like in ss output. Not a finding. Half of reading ss is knowing what not to panic about.
avahi is the real exposure: four sockets, on 0.0.0.0 and on *, IPv4 and IPv6. Every interface, listening for a discovery service nobody asked for. Those are the doors.
cupsd looks scary and is not: 127.0.0.1 and [::1] are loopback. Only the machine itself could reach that port. Not a network door. The printing stack was still a real finding, but for another reason: its companion cups-browsed was running as root, enabled at every boot, and its whole job is talking to the network to discover printers. The risk was not the port. It was the process.
And wpa_supplicant does not even appear, because it has no socket to show. It was simply active and enabled on a machine that is fully wired: a wireless negotiator with nothing to negotiate, alive at every boot.
Three forms of attack surface in a single capture. Exposed to the network. Privileged but local. Useless but alive. Let me be precise about the word: none of this is a vulnerability, there is no CVE to report. But each one is code that runs, some of it as root, where nobody asked for it. A door nobody remembers opening is a door nobody is watching. And nobody installed any of this on purpose. It is the distribution’s default.
03. Close the doors
Find which package owns each service, then remove it:
$ systemctl status cups-browsed
$ dpkg -S /usr/sbin/cupsd
Look at what the removal would take with it, before you remove anything:
$ sudo apt-get purge --dry-run cups-daemon cups-browsed avahi-daemon wpasupplicant
expected output
Read this list. If it names something you did not expect, stop here.
bash
$ sudo apt-get purge cups-daemon cups-browsed avahi-daemon wpasupplicant
One nuance from my own logs, where this was two distinct gestures 44 minutes apart. At 14:11 the services were masked: systemctl mask closes the door immediately and undoes with a single command. At 14:55 the packages were purged: the code itself left the disk. If you hesitate, mask first. Purging can wait until you have confirmed nothing broke.
Then measure again, because a claim without a measurement is a slogan:
On my machine, at the end of that afternoon, the answer was: nothing listening at all, except one UDP socket I had put there myself, deliberately, and could name. SUID binaries went from 28 to 22 in the same pass. Fewer processes running as root, fewer open doors, less code facing the network. That is the whole argument for doing this.
04. The trap: what “orphaned” actually means
At 14:55 I ran the standard cleanup. 70 orphaned packages, 68.8 MB freed. At 15:21, first reboot: black screen.
Full disclosure: this machine is a Raspberry Pi I use at my desk, not a VPS. The mechanism is identical on a server; only the victim changes. On my Pi it was the display stack. On your server it will be a Python module your app imports, or a tool your backup script shells out to.
And I had prepared. Before purging anything, I had run apt-mark manual on 51 packages I knew the desktop needed. The compositor and its lock screen were not in the 51. Purging the distribution’s meta packages earlier that afternoon had silently orphaned them, and autoremove took them along with the rest. The kernel booted perfectly fine at 15:21. There was simply no compositor left to draw a single pixel.
The pure server version happened in the same command, silently. python3-flask and werkzeug had been installed long ago and were marked automatic. A dashboard I run imports both. No Debian package declared a dependency on them, so autoremove purged them, and the dashboard died without a sound. No black screen to warn me on that one. I found it later.
This is not memory reconstructing a tidy story. The apt journal kept the receipt:
apt journal
trimmed; the real line lists all 70 packages
Commandline: apt-get autoremove --purge -y
Purge: labwc, xwayland, swaylock, python3-flask, python3-werkzeug, libnss-mdns, hplip-data, ...
End-Date: 2026-07-05 14:57:02
The display stack and the dashboard’s runtime, gone in the same command. That single log entry is the whole thesis of this article.
One more mechanism, because it is the one that makes this hard to see coming: when you purge a meta package, every package it pulled in becomes an orphan at once. Nothing leaves at that moment. It leaves at the next autoremove, possibly hours later, when nothing on your terminal connects the two events.
A post-mortem note, because the first story I told myself was wrong. My initial explanation was that removing cups broke the boot. The apt logs killed that theory: the kernel had booted normally, the display stack was just gone. A third symptom from the same afternoon, a corrupted SQLite cache, turned out to have nothing to do with the purge. Three simultaneous symptoms do not make one cause.
05. The guardrail
Three commands, in order, before any autoremove on a machine you care about. Look before you leap:
$ sudo apt-get autoremove --purge --dry-run
Then the test that stops everything. Grep the dry run for what keeps your machine alive. Build the list yourself: your SSH stack, your app’s runtime, your network tools.
$ sudo apt-get autoremove --purge --dry-run | grep -E 'ssh|python3|network|pam'
One single line of output means you do not run the autoremove. Not run it and see. You stop, and you protect what you actually use:
$ sudo apt-mark manual <package> [<package> ...]
apt-mark manual tells apt: I require this, even if no package does. It is the missing link between what apt knows and what you know. Mark first, purge second, autoremove last.
06. If it is already too late
If it is already too late
- Your screen is black. The machine underneath is almost certainly fine. Switch to a console (Ctrl+Alt+F2) or SSH in, and reinstall the stack without dragging the whole recommendation tree back with it.
- A service or app died instead. The apt journal lists exactly what left, and at what time. One trap inside the trap: apt rotates this log, so an incident from a few weeks back is no longer in the current file.
- You cannot say which packages your machine needs to stay alive. That is not a command problem anymore.
bash
$ sudo apt-get install --no-install-recommends <your packages>
$ sudo apt-mark manual <the same packages>
zgrep reads the current file and the compressed archives with the same pattern. That is exactly how I pulled the entry quoted above:
$ zgrep -h -A4 'Commandline.*autoremove' /var/log/apt/history.log*
Reinstall what it names, apt-mark manual on each, restart your service. Then verify the same way you verified the gain, with sudo ss -tulnp. The ports you closed should still be closed, and everything you kept should be up.
And the third case: you ran the dry run and you realize you cannot say which of those packages your machine needs to stay alive. It means nobody has mapped what actually runs on that server, and autoremove is just the first place it shows. Mapping that is the work I sell, so the recommendation is not neutral and I will not pretend it is. But the dry run above is free, and it will tell you honestly whether you need anyone at all.
Measured before, measured after
The machine in this story went from four exposed sockets and 28 SUID binaries to one deliberate socket and 22, in a single afternoon, including the hour I spent walking out of my own trap. That is what hardening looks like in practice: measured before, measured after, and a guardrail written down for next time. If you want that done to your VPS, black screen not included, that is exactly what HardVps is.
Something vanishing from a machine with no obvious culprit is a family of its own. The same shape turns up when a process is killed and the kernel log stays empty.