- How it started
- Part 1: The update that didn’t go to plan
- Part 2: One launch flag, one port check
- Part 3: Eleven containers, five open doors
- Part 4: Stop filtering, look at everything
- Part 5: SQL Server, the container with no safety net
- Before and after
- What this has to do with AWS (DVA-C02)
- What this has to do with Black Hat Python
- Lessons learned
- What’s next
How it started
I sat down on a Sunday morning to update one program. Three hours later I had closed more than a dozen network ports, backed up a database I’d forgotten about, and read three SQL Server crash reports.
Nothing here was an attack. It was the slower kind of risk: services I installed for learning, months ago, still running and still listening to my whole network. This is a dated record of how one launch command led to a full audit of my Debian lab machine, with every command I used and why.
The short version: the most useful security command I ran all day was ss -tlnp.
Part 1: The update that didn’t go to plan
Unsloth Studio is a local tool for running and fine-tuning large language models. It uses llama.cpp as its engine for running GGUF model files. I ran:
unsloth studio update
The Python side updated cleanly, from version 2026.4.4 to 2026.9.14. The llama.cpp step failed with two HTTP Error 404: Not Found errors.
Reading the log line by line showed why. The installer picked a prebuilt package from Unsloth’s own fork of llama.cpp, then tried to download matching source code from the upstream project using the fork’s tag name. That tag doesn’t exist upstream, so GitHub returned 404 for both the primary and fallback URLs. It was an installer bug, not anything on my machine.
Three things the installer did right are worth noticing:
- It staged before replacing. The new version was built in a separate staging folder and checked against a tiny 1 MB test model before it could replace the working one.
- It rolled back on failure. The log said
existing install restored. - It fell back to building from source. It compiled llama.cpp specifically for my GPU, which the log called
sm_61.
That GPU is an NVIDIA GT 1030 with 2 GB of video memory, a Pascal-generation card with compute capability 6.1. Current prebuilt CUDA packages skip cards that old, which is why the installer chose a CPU-only download first. Building from source got GPU support back. The card is enough to run small quantized models, but too old for Unsloth’s fine-tuning, which needs a newer GPU.
The build finished, and the installer printed its suggested launch command. That command is where the real story starts.
Part 2: One launch flag, one port check
The installer suggested:
unsloth studio -H 0.0.0.0 -p 8888
-H 0.0.0.0 tells a server to listen on every network interface, not just the machine itself. Any device on the same Wi-Fi could reach it. The safer choice for a tool used only at the keyboard is 127.0.0.1, which means “this machine only.” I launched it that way.
The browser came up on port 8890, not 8888. Servers usually move to another port when the one they wanted is taken, so something already held 8888. One command shows every listening port and who owns it:
sudo ss -tlnp | grep -E '8888|8890'
sslists sockets. The flags mean tCP, listening, numeric addresses and process names.grep -E '8888|8890'keeps lines containing either port.
The result:
LISTEN 0.0.0.0:8888 users:(("docker-proxy",pid=4921,...))
LISTEN 127.0.0.1:8890 users:(("unsloth",pid=241718,...))
LISTEN [::]:8888 users:(("docker-proxy",pid=4928,...))
The new Studio was correctly local-only. Port 8888 belonged to Docker and was open on every interface, over both IPv4 (0.0.0.0) and IPv6 ([::]).
One lesson from this step: the address in your browser bar doesn’t tell you how a server is bound. A server listening on 0.0.0.0 answers on 127.0.0.1 too. You check the server side, with ss.
Part 3: Eleven containers, five open doors
docker ps listed eleven running containers. The one on 8888 was an Unsloth Docker image from six months earlier, which my new native install had replaced without my noticing.
The PORTS column tells you how each container is reachable:
0.0.0.0:8080->8080/tcpmeans host port 8080 forwards into the container and is open to the network.127.0.0.1:17600->17493/tcpmeans the same forwarding, but localhost only. This is the safe pattern.3000/tcpwith no arrow means the port is only reachable by other containers.
Sorting the eleven by that column gave a short worry list:
| Port | Container | Why it mattered |
|---|---|---|
| 5080 | Agent Zero | An AI agent that runs shell commands for whoever reaches its page |
| 8888, 8001 | Old Unsloth image | Jupyter on 8888 is a code-execution interface; the whole thing was superseded |
| 1433 | SQL Server | One of the most-scanned ports anywhere |
| 8080 | Open WebUI | Has its own login, but still reachable by every device |
| 2026 | DeerFlow | Front door to a research agent that can run tools |
My router blocks the internet from these ports, so this wasn’t an emergency. The realistic risk is the other devices on a home network: a phone with malware, a smart TV, a guest’s laptop. Malware’s first move is to scan the local network for doors like these.
The next check covered two questions at once: does anything share the host’s network directly, and will stopped containers come back on their own?
docker inspect -f '{{.Name}} restart={{.HostConfig.RestartPolicy.Name}} net={{.HostConfig.NetworkMode}}' $(docker ps -q)
Nothing used host networking. The restart policies were the useful surprise, because the two common ones behave differently when you stop a container by hand:
unless-stoppedremembers that you stopped it and stays stopped after a reboot.alwayscomes back every time Docker starts, even if you stopped it.
So the two always containers got switched off first, then everything except SQL Server was stopped. Nothing was deleted, and any of them can return with docker start <name>.
docker update --restart=no open-webui portainer
docker stop open-webui voicebox unsloth-studio deer-flow-nginx deer-flow-gateway \
deer-flow-langgraph deer-flow-frontend agent-zero ollama portainer
Part 4: Stop filtering, look at everything
Until now I had been searching for specific ports. A search for Oracle’s ports, grep -E '1521|5500', returned a hit on port 55000 instead, because grep matches text anywhere in a line. The tighter pattern anchors the port between a colon and a word boundary:
sudo ss -tlnp | grep -E ':(1521|5500)\b'
That accidental match turned up a Python process listening on all interfaces. Tracing it took two commands:
ps -fp <PID> # full command line, owner, start time
sudo ls -l /proc/<PID>/cwd # the folder the process runs in
It was the API for my Wazuh SIEM, running as its own service account and requiring login. Not a threat, but I would have missed it entirely with narrow searches. So I dropped the filter and read the whole list:
sudo ss -tlnp
The rule for reading it is the Local Address column. 127.0.0.1 and [::1] are local only. 0.0.0.0, [::] and * are open to the network. About half the entries were already local only. Two of the network-facing ones needed fixing right away.
A second, forgotten Unsloth
Port 8889 held another native Unsloth Studio, bound to 0.0.0.0. I had launched it once with the suggested command earlier that morning. It took 8889 because Docker held 8888, and I never closed it.
kill <PID>
kill sends SIGTERM, a polite request to shut down. No output means success.
Ollama with no login at all
Ollama’s API has no authentication. Anyone who can reach port 11434 can run, download or delete models. Its systemd service had an override file setting OLLAMA_HOST=0.0.0.0:11434, which I had added months ago so a Docker container could reach it. I replaced the file’s contents:
sudo nano /etc/systemd/system/ollama.service.d/override.conf
[Service]
Environment="OLLAMA_HOST=127.0.0.1:11434"
sudo systemctl daemon-reload
sudo systemctl restart ollama
ss -tlnp | grep 11434 # now 127.0.0.1:11434
My first check printed nothing, which looked like a failure. systemctl status ollama showed it running fine. I had pasted all three commands at once, and ss ran before Ollama finished opening its port. Rerunning it a minute later showed the right address.
Lab services: on when studying, off when not
Apache, Cockpit, Samba and NFS’s rpcbind were all listening on the network. I use every one of them to learn, just not every day. The answer wasn’t removing them, it was changing their default:
sudo systemctl disable --now apache2
sudo systemctl disable --now cockpit.socket
sudo systemctl disable --now smbd
sudo systemctl disable --now rpcbind.service rpcbind.socket
disable --now stops a service immediately and keeps it from starting at boot. On study days, sudo systemctl start apache2 brings it back for the session. Cockpit and rpcbind use socket activation, where systemd itself holds the port open, so disabling the .socket unit is what actually closes the port.
Part 5: SQL Server, the container with no safety net
SQL Server was the last container exposed. Fixing a Docker port binding means recreating the container, and recreating it deletes whatever is stored inside. So first I checked where its data lived:
docker inspect -f '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' mssql
The output was empty. No volumes. A year of SQL Server data existed only inside that one container. One more caution: never paste a full docker inspect of this container anywhere, because its environment section shows the admin password in plain text.
The backup
docker stop mssql
mkdir -p ~/mssql-backup
docker cp mssql:/var/opt/mssql ~/mssql-backup/
chmod 700 ~/mssql-backup
Stopping first keeps the database files consistent. chmod 700 matters because the copy includes SQL Server’s secrets folder. Since the container’s restart policy is unless-stopped, stopping it also made it on-demand.
Where did 1.86 GB come from?
The copy was 1.86 GB, but the data folder held only 135 MB, all of it SQL Server’s built-in system databases. du found the rest:
du -h --max-depth=1 ~/mssql-backup/mssql/log | sort -h
About 1.5 GB was crash dumps: memory snapshots from three crashes over eleven months.
Did anyone try to log in?
SQL Server records failed logins in its error log by default. The file command confirmed the logs were plain text, so grep could count them directly:
grep -c "Login failed" ~/mssql-backup/mssql/log/errorlog*
Zero in all seven logs. The first line of the oldest log showed they covered only the last two weeks, since SQL Server keeps seven and rotates the rest away. It’s a clean result, but an honest one has to say the earlier months can’t be checked.
Why it crashed
The dump files themselves only describe how the dump was made. The reason was in the container’s saved console output:
docker logs mssql 2>&1 | grep -A 12 "fatal error"
-A 12 shows the 12 lines after each match, which is where the details are. SQL Server on Linux runs on a compatibility layer called SQLPAL, so the errors use Windows-style codes:
- October 2025:
Status: 0xc0000022, which is STATUS_ACCESS_DENIED, with “Unable to set persistent hive root.” On its first day, SQL Server couldn’t write to its own data folder. A classic permissions problem. - March 2026: a
RETAIL ASSERT, an internal sanity check that failed. The dump took 46 minutes to write, which points to heavy load on the machine at the time.
Neither crash looks like an attack. With the analysis done, I deleted the three memory dumps and kept the small text summaries. Dumps can contain whatever was in memory, including passwords. The backup went from 1.86 GB to 269 MB.
Before and after
Thirteen network-facing ports closed or restricted, nothing uninstalled, and every change reversible with one command.
| Service | Port(s) | Before | After | To use it again |
|---|---|---|---|---|
| Agent Zero | 5080 | Open to network | Stopped | docker start agent-zero |
| Old Unsloth image | 8888, 8001 | Open to network | Stopped, superseded | Not needed |
| Open WebUI | 8080 | Open, auto-restart always | Stopped, no auto-restart | docker start open-webui |
| DeerFlow | 2026 | Open to network | Stopped | docker start its four containers |
| Duplicate Unsloth | 8889 | Open to network | Killed | Not needed |
| Ollama | 11434 | Open, no login | Localhost only | Already running |
| SQL Server | 1433 | Open, no volume, no backup | Stopped, backed up | docker start mssql |
| Apache | 80 | Open, starts at boot | Off by default | sudo systemctl start apache2 |
| Cockpit | 9090 | Open, starts at boot | Off by default | sudo systemctl start cockpit.socket |
| Samba | 139, 445 | Open, starts at boot | Off by default | sudo systemctl start smbd |
| rpcbind (NFS) | 111 | Open, starts at boot | Off by default | sudo systemctl start rpcbind.socket |
The verification is the same command that started it all. These two should print nothing:
sudo ss -tlnp | grep -E ':(80|9090|139|445|111|1433|5080|8080|8888|8889|2026)\b'
ss -tlnp | grep ':11434\b' | grep -v 127.0.0.1
What this has to do with AWS (DVA-C02)
Almost every step had a direct cloud equivalent. Doing it by hand on one Linux box made the AWS versions click.
| What I did at home | AWS equivalent | The exam idea |
|---|---|---|
Changed 0.0.0.0 to 127.0.0.1 | Removing a 0.0.0.0/0 inbound rule from a security group | Least privilege: open only what’s needed, only to who needs it |
Checked binding with ss instead of trusting the browser | Checking the security group, not just the endpoint you connected through | Verify on the server side |
| Unsloth staged, validated, then rolled back | Blue/green deployments and automatic rollback in CodeDeploy and Elastic Beanstalk | Health checks gate every release |
Docker -p host:container port mappings | Port mappings in an ECS task definition | Container ports and network access are configured separately |
| SQL Server open on 1433 | An RDS instance marked “Publicly accessible” | Databases belong in private subnets |
Admin password visible in docker inspect | Secrets referenced from Secrets Manager or SSM Parameter Store in a task definition | Never bake secrets into configuration |
| No volume, so data lived in the container | Fargate task storage is ephemeral; use EFS or EBS | Separate compute from state |
| Backup before changing anything | An RDS snapshot before modifying an instance | Back up first, then change |
Counting “Login failed” with grep -c | A CloudWatch Logs metric filter plus an alarm | Turn log lines into signals |
| Services off unless I’m studying | Stopping dev EC2 instances overnight | Unused resources are cost and risk |
| Ollama with no authentication | An API Gateway endpoint with no authorizer | Every API needs an auth layer |
The biggest takeaway: AWS defaults to closed. A new security group allows no inbound traffic, and a new S3 bucket blocks public access. Linux services often default to open. Most of this audit was bringing my machine closer to the AWS default.
What this has to do with Black Hat Python
The book teaches the attacker’s tools. This audit was the same work from the defender’s chair.
- The 404 errors:
HTTP Error 404: Not Foundis the exact message format of Python’surllib.error.HTTPError, the library behind the book’s web chapters. The installer’s try-primary, try-fallback, try-older-version logic is the same retry pattern you’d write into a scraper. - Binding to
0.0.0.0: The TCP server in Chapter 2 binds an address the same way. A listener on all interfaces is what an attacker wants for a backdoor, and what you don’t want for a tool with no login. docker-proxy: It’s a real-world version of the TCP proxy you build in Chapter 2. It accepts on one socket and relays bytes to another. Spotting it insstells you a container sits behind that port.- Port scanning in reverse: Ports 111, 139 and 445 are among the first things a scanner reports, because RPC and SMB have long exploit histories. Port 1433 is a staple of database reconnaissance, and open Ollama servers on 11434 are now actively hunted because they’re free compute.
- Port to PID to command line to folder:
ss, thenps -fp, then/proc/<PID>/cwdis how you identify an unknown listener on a box you’re assessing. I did it on my own. - Log forensics:
grep | sort | uniq -c | sort -rnis the defender’s answer to the book’s password brute-forcers. A brute-force tool leaves hundreds of identical failure lines, and this pipeline finds them in seconds. - Memory dumps: A crash dump is a snapshot of process memory, the same raw material attackers pull secrets from. That’s why the dumps were deleted once they’d served their purpose.
Lessons learned
- Read the launch command before you paste it. One
-H 0.0.0.0started all of this. - Run
sudo ss -tlnpwith no filter. Narrow searches find what you expect; the full list finds what you forgot. - An empty result needs a second look. Empty
grepoutput can mean “no matches” or “nothing to search.” Check the date range of your logs before trusting a clean result. - A lab can keep everything and still be tidy. On-demand services give you the learning without the standing exposure.
- Check where a container keeps its data before you touch it. No volume means the data dies with the container.
- Preview before you delete.
ls -dbeforerm -rshowed eight files where I expected four.
What’s next
Part 2 will cover remote access and kernel-level hardening, plus rebuilding SQL Server properly: a current image, a volume owned by the right user, a memory limit, and a localhost-only port. That rebuild should also fix the permissions problem behind its first crash.
If you run a home lab, try sudo ss -tlnp tonight. I’d be curious what you find.