Skip to content
Go back

Home Lab Threat Model: What's Exposed

By KingPin 15 min read
Home Lab Threat Model: What's Exposed
Contents

You Opened Port 32400 Two Years Ago and Forgot

It’s 2 AM. Your auth log shows 400 failed SSH logins from six countries. You didn’t know SSH was reachable from outside. You also forgot you opened Plex for a friend in 2024, that your router has a UPnP table you never looked at, and that Docker doesn’t ask your firewall for permission.

Most home lab security advice starts with tools: install a WAF, deploy an IDS, buy a firewall appliance. That’s backwards. Before you defend anything, you need a list of what the internet can actually reach. That list is almost always longer than you think, and the surprises are boring ones.

This article does two things. First, an outside-in audit: look at your network the way a scanner does, with a working command for each exposure source. Second, a small threat model: who really attacks a home lab, what they want, and what to do about each asset. The verdict is short. Expose a reverse proxy on 443 or nothing at all, and reach everything else over a mesh VPN.

Who Actually Attacks a Home Lab

Not a nation state. Not the person who hates your Minecraft server. Three groups account for nearly everything:

  1. Mass scanners. Bots sweep the whole IPv4 space on common ports, constantly. Services like Shodan and Censys do it for search indexes. Others do it for resale or for the next group. Your IP gets probed within hours of going online, and nobody picked you.
  2. Credential stuffing and brute-force bots. They hit SSH, RDP, web logins, and anything with a password field. They try leaked passwords and defaults. Your goal is to not have a login page they can reach.
  3. Botnet operators hunting known CVEs. They scan for a specific unpatched version of a specific product (a NAS, a router, a media server, a dashboard) and exploit it automatically. The prize is your bandwidth and CPU, plus a foothold to attack the next target from your IP.

What they want is boring: compute for mining, bandwidth for DDoS and proxying, a place to host malware, stored credentials, or ransomware leverage against your backups. They don’t care that it’s your lab. That’s good news. Automated attackers only try what’s reachable, so shrinking the reachable surface beats nearly every clever defense.

Step 1: Scan Yourself From the Outside

Scanning your own IP from inside your LAN tells you nothing about what the internet sees. NAT hairpinning, router firewall rules, and IPv6 all behave differently from outside. Use a VPS, or tether your laptop to your phone’s hotspot (a different network entirely).

First find your public address from inside the lab:

Terminal window
curl -4 ifconfig.me
# 203.0.113.10 (documentation address, yours will differ)

Then, from the outside machine, scan every TCP port:

Terminal window
nmap -Pn -p- --min-rate 1000 203.0.113.10

-Pn skips host discovery, since many routers drop ping and nmap would otherwise report the host as down. -p- covers all 65535 ports, because the interesting listeners are rarely on port 80. A full scan takes a few minutes. Add a quick UDP pass, which is slow, so limit it:

Terminal window
sudo nmap -Pn -sU --top-ports 50 203.0.113.10

Only scan addresses you own. Your own IP is fine. Your neighbor’s is not.

Next, see what the public indexes already know. Shodan has a CLI:

Terminal window
pip install shodan
shodan init YOUR_API_KEY
shodan host 203.0.113.10

shodan host <ip> prints what Shodan has recorded for that address: open ports, banners, and the organization that owns the range. If you’re unsure of the exact output on your version, shodan host --help shows it. Censys has a similar search on its website. If either shows a port your own nmap scan missed, that port was open earlier, and someone captured it. Treat old banners as a hint about what was exposed, then go find out why it stopped.

Whatever this scan finds is your real baseline. Everything below explains why that list contains ports you never opened on purpose.

Step 2: UPnP and NAT-PMP Open Ports Without Asking

UPnP lets any device on your LAN ask the router to forward a port to itself. Game consoles do it. Torrent clients do it. Plex does it if you let it. A compromised IoT camera can do it too. No password, no prompt, no notification.

List what’s currently mapped, from a machine inside your LAN, with miniupnpc:

Terminal window
sudo apt install miniupnpc # or your distro's equivalent
upnpc -l

-l lists the port redirections on the router’s Internet Gateway Device. Look at the external ports, protocols, and the internal address each one points to. If you see a mapping for a device you don’t recognize, or an old Plex mapping for a server you rebuilt, that’s an open door. -d <external port> <protocol> deletes a mapping, but the real fix is in the router settings: turn UPnP off. Then forward the few ports you need by hand, so every exposed port is a decision you made. Also, upnpc speaks UPnP only. Mappings made through NAT-PMP or PCP may not show up in its list, so check the router’s own port-mapping page as well.

Your outside nmap scan should match your manual forwards exactly. If it shows more, UPnP did it.

Step 3: Docker Ports Walk Around Your Firewall

This one catches experienced people. You run UFW. You set ufw default deny incoming. You start a container with -p 8080:80. Port 8080 is now reachable from the internet, and ufw status says it isn’t.

The mechanism is routing, not a bug. Docker’s published ports use DNAT in the nat table. A packet to port 8080 gets its destination rewritten to the container’s address in PREROUTING, then it gets routed to the container instead of to the host. It never reaches the host’s INPUT chain, where UFW’s rules live. Docker’s own documentation says it plainly: because Docker routes container traffic in the nat table, packets are diverted before they reach the firewall rules ufw manages.

Published ports also bind to every address by default, IPv4 and IPv6. The Docker docs state that the daemon publishes ports to all host addresses, so -p 8080:80 is reachable from outside the host.

Audit it:

Terminal window
docker ps --format '{{.Names}}\t{{.Ports}}' | grep -E '0\.0\.0\.0|\[::\]|:::'

Every line in that output is listening on all interfaces. Now fix it, in order of preference.

Fix 1: bind to loopback. If a reverse proxy on the same host fronts the container, the container needs no public port at all:

compose.yaml
services:
app:
image: ghcr.io/example/app:latest
ports:
- "127.0.0.1:8080:80"
- "[::1]:8080:80"

The docs show the same pattern: docker run -p 127.0.0.1:8080:80 -p '[::1]:8080:80' nginx. Only the host can reach the port. If your reverse proxy is another container on the same Docker network, drop ports: entirely and let it talk to the container by name.

Fix 2: filter in the DOCKER-USER chain. Docker documents DOCKER-USER as a placeholder for your rules, processed before Docker’s own DOCKER-FORWARD and DOCKER chains. Docker’s own example drops everything not from one source address:

Terminal window
sudo iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.2 -j DROP
sudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT

Replace eth0 with your external interface. Insert the ESTABLISHED rule second so it lands on top. Without it, replies to your containers’ own outbound connections get dropped too. Three more details matter. Packets reaching DOCKER-USER have already been through DNAT, so you see the container’s internal address and port, not the original ones. To match the original destination you need the conntrack extension with --ctorigdst, and Docker warns it can cost performance. Also, rules you add with iptables don’t persist across reboots on their own, so save them with your distro’s mechanism. And these rules cover IPv4 only: repeat them with ip6tables for the [::] side of every published port.

A note on Docker 28. Docker 28.0 changed the defaults: remote hosts can no longer reach a container’s internal IP directly. Before that, a machine on the same LAN segment could reach a container IP directly, even for ports you never published. The docs note that releases older than 28.0.0 let hosts on the same L2 segment reach ports published to localhost, and that unpublished ports are now blocked by default. Upgrade, but remember this hardening only blocks direct access to container IPs and loopback-published ports. A published port on 0.0.0.0 is still public by design.

Docker also supports an nftables backend via the firewall-backend daemon option. The docs still label it experimental, so check the current page before you switch a production host.

Step 4: IPv6 Skips Your Port Forwarding Mindset

With IPv4, NAT accidentally acts like a firewall. Devices sit on private addresses, and nothing reaches them unless you forward a port. IPv6 has no such crutch. Every device can get a globally routable address, and the only thing between it and the internet is your router’s IPv6 firewall.

Check what your machines have:

Terminal window
ip -6 addr show scope global
# inet6 2001:db8:1234:5678::10/64 scope global

If you see a global address, that host is addressable from anywhere. Whether it’s reachable depends on the router. Most consumer routers default to blocking unsolicited inbound IPv6, but not all, and some firmware has had bugs. Don’t assume. From your outside machine, scan the v6 address:

Terminal window
nmap -6 -Pn -p- 2001:db8:1234:5678::10

Two common surprises. A Docker port published on 0.0.0.0 is also published on [::], so your IPv4 audit has an IPv6 twin. And a server you forgot about, with SSH listening on all interfaces, is now reachable by IPv6 if your router permits it, regardless of your IPv4 forwards. For the full dual-stack picture, see IPv6 for the home lab.

Step 5: Certificate Transparency Publishes Your Subdomains

You got a Let’s Encrypt certificate for jellyfin.example.com. That certificate went into public Certificate Transparency logs, which anyone can search. Let’s Encrypt’s FAQ says certificates are submitted to CT logs after validation. Attackers watch those logs for new hostnames, and a fresh vault.example.com entry gets probed within minutes.

See what you’ve published:

Terminal window
curl -s "https://crt.sh/?q=%25.example.com&output=json" | jq -r '.[].name_value' | sort -u

%25 is a URL-encoded %, the wildcard for crt.sh. The name_value field can hold several names separated by newlines, which sort -u handles. crt.sh is a free service and returns a 502 now and then under load, so retry before concluding anything.

The mitigation is a wildcard certificate. A certificate for *.example.com appears in the logs as that single wildcard name, not as every host behind it. Let’s Encrypt issues wildcards, and they require the DNS-01 challenge. The tradeoff is real: a DNS-01 setup needs an API token for your DNS provider, and that token can change your records, so scope it tightly.

A wildcard hides names. The services behind them stay reachable. Someone guessing grafana.example.com still finds it if it’s reachable. Treat hidden names as a speed bump. Obscurity isn’t a control.

Step 6: “No Open Ports” Doesn’t Mean “Not Exposed”

Cloudflare Tunnel and Tailscale Funnel both let you publish a service without forwarding a port. cloudflared opens an outbound connection to Cloudflare, so you can block all inbound traffic at the router. Cloudflare’s docs describe this as connecting resources without a publicly routable IP address. That’s a real improvement. Scanners still sweep your home IP, but nothing answers, and your router has no forwarded port to attack.

But the application is still on the internet. Anyone who knows or guesses the hostname reaches your login page, with the same CVEs and the same weak password. The tunnel removes the forwarded port. The app’s own login is still the only lock.

Tailscale draws the distinction clearly. tailscale serve shares a service with devices in your tailnet. tailscale funnel routes traffic from the broader internet to the service, for anyone, no Tailscale account required. Funnel listens only on ports 443, 8443, and 10000. Check what you’ve turned on:

Terminal window
tailscale funnel status
tailscale serve status

If you tunnel a service, put authentication in front of it that doesn’t depend on the app: Cloudflare Access, an OIDC proxy, or client certificates. Without that layer, the exposure moved from your router to Cloudflare’s edge, and it is still there.

Step 7: Outbound Counts Too

Exposure isn’t only inbound. Your services phone out to update servers, telemetry endpoints, license checks, and webhook URLs. A compromised container with unrestricted egress can reach a command-and-control server, send your data out, or join a botnet without any inbound port at all.

Check what a host is talking to right now:

Terminal window
sudo ss -tnp state established | grep -v '127.0.0.1'

You don’t need a full egress firewall on day one. Start with DNS logging at your resolver (Pi-hole or AdGuard Home both work) and look for domains you don’t recognize. Isolated networks for IoT devices and untrusted containers are the next step up.

The Threat Model, in One Table

Fill this out for your own lab. The point is to name each exposure, the likely attacker, and a specific mitigation. The rows below cover the usual suspects.

AssetExposureLikely attackerMitigation
SSH on the router or a serverPort 22 forwarded or IPv6 reachableBrute-force botsKeys only, no password auth, or remove the forward and use a VPN
Plex, Jellyfin, media serverForwarded port or UPnP mappingCVE-hunting botnets, scannersReverse proxy with TLS, keep updated, or VPN only
Admin UIs (Portainer, Proxmox, router)Published Docker port or open to LAN-wide accessCredential stuffing, CVE exploitsNever publish. Reach it over WireGuard or Tailscale
Docker containers-p on all interfaces, bypassing UFWAny scannerBind to 127.0.0.1, filter in DOCKER-USER
Subdomain listCT logs, passive DNSRecon botsWildcard cert, don’t rely on hidden names
IPv6 hostsGlobal addresses, router firewall defaultScanners with IPv6 hitlistsVerify router blocks inbound, scan from outside
Tunnelled appsPublic hostname via Cloudflare or FunnelCredential stuffing, CVE huntersAuth in front of the app (Access, OIDC)
Backups and NASSMB or NFS on a forwarded or v6 addressRansomware operatorsNever expose. Offline or immutable copies

Notice the pattern. Every mitigation is either “close it”, “put a VPN in front”, or “put authentication in front”. None requires a commercial product.

The SumGuy Take

Most home labs should expose one of two things: nothing, or a single reverse proxy on 443. Everything else, including SSH, dashboards, databases, and admin tools, belongs behind a mesh VPN. WireGuard or Tailscale gives you remote access to everything behind it. Your phone becomes a node on the network, and nothing else needs a public address.

If you do need to share a service with other people, run it behind a reverse proxy with TLS, put real authentication in front, keep it patched, and watch the logs. One front door gets monitored. Twelve doors get forgotten.

Run the audit this week. Scan from a phone hotspot, check upnpc -l, grep your docker ps output, and look at your crt.sh results. The first scan usually finds at least one thing you forgot, and fixing it takes five minutes.

Common Questions

Is it safe to port forward Plex?

Plex port forwarding is acceptable if you keep Plex updated and accept the risk. A forwarded port on 32400 is visible to scanners within hours. A safer setup puts Plex behind a reverse proxy on 443 with TLS, or restricts remote access to a VPN. Forward nothing else with it.

Does Cloudflare Tunnel hide my home IP?

Cloudflare Tunnel keeps your home IP out of the DNS record, because traffic reaches Cloudflare’s network first and your origin connects outward. Your IP can still leak through other paths: direct connections, email headers, or other services on the same IP. The tunnel also leaves the application itself publicly reachable.

Do I need a firewall if I’m behind NAT?

Yes, you still need one. NAT blocks unsolicited inbound IPv4 traffic only by accident, and it doesn’t help with UPnP mappings, forwarded ports, or IPv6, where devices get global addresses. Run a host firewall on every server and verify the router’s IPv6 inbound policy with a scan from outside.

Why does Docker ignore my UFW rules?

Docker publishes ports with DNAT rules in the nat table, so packets are rewritten and routed to the container before UFW’s INPUT rules run. UFW never sees them. Bind published ports to 127.0.0.1 or filter traffic in the DOCKER-USER chain instead.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Sizing 7 Days to Die and Other RAM Hogs
Next Post
Kyverno: K8s Policy Without Rego

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts