How Exposed Is Your Homelab? A Hands-On External Attack-Surface Audit of a Self-Hosted Stack
Five agencies published a joint PLC advisory. Its reconnaissance playbook works against a homelab with a public IP just as well as a PLC rack. A first-person external audit of the Malwlab surface: enumeration checklist, before/after delta, and controls left behind to catch anyone scanning back.
On 2026-08-19, five U.S. agencies (NSA, CISA, FBI, DOE, EPA) published a joint advisory, PP-26-3374, about an active threat to Siemens S7 PLCs. The target in that advisory is industrial, and you may not run a PLC. The reconnaissance section still matters to anyone with a public IP. The actors in the advisory use internet scanning services, Censys and ZoomEye, to find exposed devices, then pair what the scans return with AI-generated exploit scripts and default credentials. That is T1596.005 (search open technical databases) plus T1694 (insecure, often default, credentials) in the advisory's own ATT&CK mapping. The same playbook works against a homelab that exposes a router dashboard, a git server, or an inference API the way it works against a PLC rack.
The practical consequence: if you are scannable, you should be the one doing the scanning. This lab note is a first-person audit of the Malwlab homelab's public attack surface, run from outside the network, plus the reduction playbook I applied and the detection controls I left behind to find out if anyone is scanning back.
The audit covered the public IP 85.24.x.x (redacted to the first two octets in this post) and the domains malwlab.se, git.malwlab.se, registry.git.malwlab.se, and blog.malwlab.se. Full unredacted results live in the internal audit report; this post reproduces the findings that are safe to publish, with the IP shortened the same way a defender would see them in a threat feed.
What I set out to measure
The test I wanted to run: from the perspective of an internet stranger with no credentials and no prior knowledge, what can be seen, reached, or probed on the Malwlab public surface, and how does that surface change after a deliberate reduction pass?
Success criteria were three things:
- A complete, reproducible enumeration checklist (every command, every source) that any operator can rerun against their own box.
- A before/after comparison showing the concrete delta, measured the same way both times.
- A detection setup that tells me if my own IP starts getting scanned, without me having to notice on my own.
Environment
| Component | Detail |
|---|---|
| Edge device | Ubiquiti UDM Pro, acting as the public endpoint and reverse proxy for the homelab |
| Public IPv4 | 85.24.x.x (redacted; single address, no IPv6 announced) |
| Domains | malwlab.se, git.malwlab.se, registry.git.malwlab.se all A-record to the public IP. blog.malwlab.se and www.malwlab.se sit behind Cloudflare (188.114.96.x / 188.114.97.x) |
| DNS | Cloudflare nameservers (april.ns.cloudflare.com, miles.ns.cloudflare.com) |
| Google Workspace MX for the whole domain | |
| Git | Gitea, reverse-proxied through the UDM Pro nginx |
| Blog | Ghost on Caddy, behind Cloudflare |
| Scan vantage point | Outside the homelab network, using the operator's residential egress as the vantage point plus public OSINT services (Shodan InternetDB, crt.sh, bgp.tools, DNS-over-HTTPS) |
One scoping note: this is an external surface audit. I did not log into the router and read the firewall config. Every finding below is something an anonymous stranger could observe from the internet. That is both the point and the limitation: it tells you what the surface looks like, not what the interior is doing.
Enumeration methodology: the checklist
The whole audit is reproducible from this list. Every step is a command or a lookup you can run against your own public IP in about an hour, no special tooling required beyond what is below.
- Resolve the domain set.
dig +short A malwlab.se,dig +short A git.malwlab.se,dig +short A registry.git.malwlab.se,dig +short AAAA malwlab.se,dig +short MX malwlab.se. Result for Malwlab: three subdomains A-record to the same public IP, no AAAA records anywhere, all MX to Google. The absence of AAAA is not a vulnerability, but it tells an attacker the stack is IPv4-only, which narrows their tooling to IPv4 scanners. - Pull the TLS certificate straight off the IP.
echo | openssl s_client -connect 85.24.x.x:443 2>/dev/null | openssl x509 -noout -subject -issuer -dates -ext subjectAltName. This is the single most informative one-line probe in the whole checklist. You get the certificate without touching the service, and the certificate often tells you which device is answering. - Probe the obvious ports directly, with raw and HTTP clients.
nmap -Pn -sV -p 22,80,443,8080,8443 85.24.x.x, pluscurl -sI https://85.24.x.xandcurl -sI http://85.24.x.xto see what the HTTP layer returns. This tells you what is actually listening versus what is only reachable through a hostname. - Query the certificate transparency logs.
https://crt.sh/?q=%25malwlab.seor the APIhttps://crt.sh/?q=malwlab.se&output=json. CT logs show every certificate a public CA has ever issued for the domain. If you see certs you did not request, that is an immediate incident. (Caveat from this audit: crt.sh returned HTTP 502 for every attempt on the day of the scan, so I could not get CT data for*malwlab.seat that time. The openssl check in step 2 is the fallback when the CT aggregator is down.) - Check the scanner databases for what the rest of the internet has already seen. Shodan InternetDB:
https://internetdb.shodan.io/85.24.x.x. Also worth a manual Shodan/Censys/ZoomEye search on the IP. If your IP is already indexed with banners and CVE links, an attacker does not need to run their own scan. If it is not indexed, you still have a clean slate, which is a finding in its own right. - Check BGP and subdomain enumerators for what the domain set looks like from the routing layer.
https://bgp.tools/prefix/85.24.128.0/17(the /17 the public IP sits in) plus a subdomain brute-force if you have one. This surfaces subdomains you may have forgotten to set up, or that a reverse-DNS or certificate leak exposes. - Read the email authentication records.
dig +short TXT malwlab.se(SPF),dig +short TXT _dmarc.malwlab.se(DMARC),dig +short TXT default._domainkey.malwlab.se(DKIM),dig +short CAA malwlab.se. Email posture is part of the external surface: a domain with no DMARC and a neutral SPF is spoofable, and a spoofable domain is a phishing surface you own. - Capture the response headers of every public web service.
curl -sI https://blog.malwlab.se,curl -sI https://git.malwlab.se, etc. Server headers, HSTS, CSP, and theviachain tell you the proxy stack and, sometimes, the origin software.
That is the full list. No paid tooling is required for any of it.
What the scan found (before state)
Findings in order of severity. IP redacted to the first two octets throughout.
The UDM Pro self-signed certificate is on the public internet. openssl s_client against 85.24.x.x:443 returns a certificate with subject CN=unifi.local, self-issued, valid 2026-03-13 to 2028-06-15. The SANs are DNS:unifi.local, DNS:localhost, DNS:[::1], IP:127.0.0.1, IP:FE80::1. None of those match malwlab.se, git.malwlab.se, or the public IP. Any client hitting those hostnames on 443 gets a cert that cannot possibly be valid for the name they typed.
The UDM Pro management dashboard is directly reachable. malwlab.se:443 and the raw IP both serve the UniFi OS management interface over that same self-signed cert, with server: nginx and TLSv1.3. The router that controls the whole homelab's network is one URL away from an anonymous browser. There is no second auth layer in front of it; the UniFi login is the only gate.
Gitea is publicly reachable with a live login form. git.malwlab.se returns the Gitea login page at /web/login, and /api/v1/repos/search responds (auth required). The service is behind the UDM Pro nginx reverse proxy, but from the outside it looks like a normal, internet-facing git instance. That makes it a credential brute-force target, and the login form being publicly visible is what makes it discoverable to scanning services of exactly the kind PP-26-3374 describes.
Port 8080 answers with a Tomcat-style 400. A raw HTTP probe to 85.24.x.x:8080 returns a Tomcat 400 Bad Request response, which means a Java application server is listening and reachable even though it does not complete a normal HTTP/1.1 handshake on a raw connection. I have not yet identified which service owns that listener. That is an open item, not a closed finding, and it is flagged as such in the follow-up list below.
registry.git.malwlab.se reveals infrastructure layout for free. The subdomain A-records to the public IP, but the registry API itself is not exposed externally. What it does do is confirm to any observer that a Docker registry exists in the stack and is routed through the same reverse proxy, with the same self-signed cert. Subdomains are cheaper reconnaissance than ports, and this one hands an attacker a map fragment without requiring a single login.
Email authentication is weak across the board. SPF is v=spf1 mx a ?all, the neutral modifier, which does not reject spoofed mail. DKIM is absent at default._domainkey.malwlab.se. DMARC is absent at _dmarc.malwlab.se. CAA is not set, so any public CA can issue for the domain. The net effect: the @malwlab.se address space is spoofable, and there is no DNS record telling receivers how to treat spoofed mail.
What the scan did not find, which is also a finding.
- Shodan InternetDB returns
{"detail": "No information available"}for85.24.x.x. The IP is not indexed by Shodan's crawler as of the audit date. An attacker starting from Shodan sees nothing, which means the surface is not pre-digested for them, but it also means nothing is telling me if it changes. - SSH (22) is refused externally. No Telnet, no FTP, no self-hosted SMTP, no open resolver or zone transfer. The closed surface is smaller than the exposed surface, which is the good half of this audit.
blog.malwlab.seis well configured for a public content service: it terminates at the Cloudflare edge (server: cloudflare), with HSTS (max-age=31536000; includeSubDomains), a CSP withframe-ancestors 'self',referrer-policy: strict-origin-when-cross-origin, andx-content-type-options: nosniff. Thevia: 1.1 Caddyheader leaks the origin proxy, which is a minor fingerprint but not a credential path.
The reduction playbook
Five changes, in the order I would apply them to a similar stack. Each one is a single decision about where a piece of the stack belongs.
1. Pull the UDM Pro dashboard off the public internet and put it behind VPN or tailnet. The router management UI is the one service on this stack that has no business being reachable from an anonymous browser. The fix is to remove the port-forward/reverse-proxy rule that exposes 443 for the dashboard and reach it only through the tailnet (Headscale or Tailscale). I covered the tradeoffs of self-hosting the control plane, including what you give up and what you gain, in Headscale vs Tailscale: Self-Hosting Your Mesh VPN Control Plane. If you need a fallback for when the tailnet is down, a Cloudflare Access-protected path is the next-best option, not a second public login.
2. Decide for each service: tailnet-only, or Cloudflare tunnel with Access. Not every service needs the same treatment. The split I use:
- Anything that is interactive, stateful, or management-adjacent (router dashboards, git push/pull you do yourself) goes tailnet-only. No public hostname at all, which also means no subdomain to leak.
- Anything that needs to be reachable by non-operators (a public blog, a web app you share, an API a third party calls) goes behind a Cloudflare Tunnel with a Cloudflare Access policy in front. The origin IP never appears in DNS, and the Access layer adds MFA and rate limiting at the edge.
- Gitea specifically: if it must stay externally reachable, put a Cloudflare Access rule on
git.malwlab.sewith MFA, and keep the Gitea-side protections on top (account MFA, 2FA-required access tokens). The Access layer is the first gate, not the only one.
3. Audit default and weak credentials in the docker-compose .env files. This is the documented failure pattern in self-hosted deployments, and it is the same T1694 vector the advisory calls out for PLCs: devices and services reachable from the internet running default or minimally configured authentication. The check is mechanical. For every service in the stack, open the compose file and the linked .env, and ask three questions: is the password a default from the software's quickstart? Is it the same value as any other service on the box? Is it committed to a git remote that is not private? Any yes is a finding. This audit did not enumerate every .env on the box, so I am stating the check rather than claiming it was run to exhaustion.
4. Suppress banners and server headers at the proxy. The UDM Pro nginx and the origin services both leak server and, in Gitea's case, application fingerprints in responses. Strip or override the Server header at the reverse proxy, remove X-Powered-By where the application emits it, and make the proxy the uniform face of the stack. The point is that a scanner hitting the box gets one generic response instead of a software inventory, so the Censys/ZoomEye-style fingerprinting in the advisory has less to work with.
5. Put an auth layer at the reverse proxy, in front of the login, not just on the login. The Gitea login form was publicly visible before the reduction pass. Adding Cloudflare Access in front of git.malwlab.se means the login form itself is no longer reachable by an anonymous request, which removes the brute-force surface and the discovery signal at the same time. The same pattern applies to any other login surface: the proxy is the first thing an unauthenticated request should hit, and it should ask for credentials before the application gets a chance to.
Before/after: the reduction comparison
The before state is measured. The after state is the rescan I run after the reduction pass lands, using the identical checklist from the methodology section. The table below is the comparison as of this draft: the before column is the 2026-08-24 audit, and the after column is the expected result of the playbook above, to be filled in from the rescan and committed to the internal report before publication.
| Probe | Before (2026-08-24) | After (expected, pending rescan) |
|---|---|---|
openssl s_client on public IP :443 |
UDM Pro self-signed cert, CN=unifi.local, no public SAN |
Dashboard not publicly reachable; cert not presented to anonymous clients |
malwlab.se:443 |
UniFi OS management dashboard, publicly reachable | Unreachable without tailnet / Cloudflare Access |
git.malwlab.se |
Gitea login form public, /api/v1/repos/search reachable |
Cloudflare Access gate in front; login not reachable anonymously |
registry.git.malwlab.se |
Resolves to public IP, routes through UDM proxy | Subdomain removed or no longer publicly resolvable |
| Port 8080 | Tomcat-style 400 response, service unidentified |
Closed, or moved behind the same Access layer (open item) |
| SPF | v=spf1 mx a ?all (neutral) |
v=spf1 mx a -all (reject) |
| DKIM | Absent | Published from Google Workspace |
| DMARC | Absent | v=DMARC1; p=quarantine; rua=mailto:...; pct=100 |
| Shodan InternetDB | No information available |
Still expected to be unindexed, but now with an alert set up (see detection) |
| Blog headers | Cloudflare, HSTS, CSP present | Unchanged; already correct |
I am not publishing the after column as measured. The reduction pass is in progress, and the rescan has not been run as of this draft. Publishing an "after" state that is projected rather than measured would be exactly the kind of overclaim this format exists to avoid. When the rescan runs, the after column gets real output and the internal report gets the diff.
Detection: catching someone else scanning you back
The reduction playbook makes the surface smaller. Detection is what tells you if the surface you did leave is getting touched. Four controls, in order of how directly they answer the question "is someone scanning me":
Canary ports. Pick a handful of ports that no legitimate service uses (I would not pick 22, 80, 443, 8080, or 8443, because those are exactly what a scanner tries first). Open them at the perimeter with a service that logs every connection and immediately alerts, or point them at a honeypot container. A connection to a canary port is a high-signal event: no legitimate client should ever touch it. The alert payload is the source IP, timestamp, and the banner the scanner sent, which is usually enough to identify the tooling.
Honeypot endpoints. A single decoy service on a port that looks plausible (a fake SSH banner, a fake admin panel login) is a better trap than a bare canary port, because it captures what the attacker does after the connection, not just that they connected. Keep the honeypot isolated from the real stack; its only job is to record.
Reverse proxy access logs. The UDM Pro nginx and the Cloudflare logs are already recording every request that hits the public surface. The reconnaissance signature to watch for is the same one the advisory lists for PLCs, adapted to web: sequential port or path scanning, repeated connection attempts with varying parameters, and enumeration of API endpoints (in this stack, anything probing /api/v1/ on the git hostname). I ran a 30-day analysis of the reverse proxy attack traffic on this same box, and the volume of low-quality scanning is higher than most operators expect. See Homelab Reverse Proxy: Analyzing 1.16 Million Attack Requests in 30 Days for the methodology and the data.
Scanner database alerts. Shodan and Censys both let you set an alert on a specific IP or domain. Set one on 85.24.x.x and on each public subdomain. The value is not the scan itself (you just did one, in the checklist above); the value is the notification the day the indexing status changes from "no information available" to "here is a banner we captured." That is the moment an attacker has a head start, and you want to know it the same week.
Two related posts cover the detection side for the two most common homelab AI exposure cases specifically: Detection Guidance: Auditing Your Homelab for Exposed MCP Servers walks through finding and confirming an exposed MCP endpoint, and Ollama on the Open Internet: 300,000 Exposed AI Inference Servers Under Active Attack covers the Ollama-specific exposure and what active attack against those instances looks like. If you run either of those on your stack, those two posts are the detection checklist to run alongside the canary setup above.
Follow-up items, stated plainly
- Port 8080 is still unidentified. The Tomcat-style 400 is real and reachable. I need to determine which container or service owns that listener and decide whether it stays, moves behind Access, or closes. Until then it is an open finding, not a resolved one.
- The after-state rescan has not run. The comparison table's after column is projected, not measured. It gets real output before publication.
- crt.sh was unavailable (HTTP 502) on the audit date, so CT-log coverage for
*malwlab.seis incomplete for this run. The openssl direct-certificate check covers the current cert; the CT check is worth rerunning once the aggregator is up to confirm no unexpected issuances. - The
.envcredential audit (playbook step 3) is a checklist in this post, not a completed sweep of every compose file. It is on the follow-up list and will be noted as completed in the internal report once it has been.
Sources
- Live external scan report for the Malwlab public surface, 2026-08-24. Internal artifact:
attack-surface-report.md(Task 0, board attachment). IP redacted to the first two octets in this post; the internal report carries the full address for the operator. - PP-26-3374, "Defending Against an Active Threat to Siemens S7 Series PLCs," joint advisory, NSA/CISA/FBI/DOE/EPA, 2026-08-19, TLP:CLEAR. Used for the scanning-trend citation (Censys/ZoomEye reconnaissance, AI-generated exploit scripts, default-credential access) and the ATT&CK mapping (T1596.005, T1587.004, T1588.007, T1694, T0834, T0849, T0893, T0821). Source: https://media.defense.gov/2026/Aug/18/2003983494/-1/-1/0/CSA_Active_Threat_to_Siemens_S7_Series_PLCs.PDF