In February 2024 Guardio Labs published a report on a spam operation they called SubdoMailing. One of their examples was mail sent from marthastewart.msn.com. It passed SPF. It passed DMARC. It was sent from a server in Kyiv that Microsoft had nothing to do with.
The reason was one DNS record. Around 2001, MSN ran a sweepstakes with Martha Stewart. The subdomain marthastewart.msn.com had a CNAME pointing at an outside domain, msnmarthastewartsweeps.com. The promo ended, the outside domain expired, and nobody removed the CNAME. Per Guardio's write-up, nobody registered that domain again for 21 years, until September 2022, when someone bought it through Namecheap. Whoever owns the target of a CNAME controls what the subdomain says, including its SPF record. So they published an SPF record that authorized their own mail servers, and from then on their mail "came from" msn.com. Guardio counted over 8,000 domains abused with this trick or a close cousin of it (more below), including VMware, McAfee, eBay, CBS and Cornell.
I checked today (Oct 28, 2026). The MSN record now points at Microsoft's own edge network, so that one is fixed. And msnmarthastewartsweeps.com is unregistered again. You could buy it right now. It just doesn't matter anymore because nothing points at it.
The reason I'm writing this for indie hackers and not for big companies: we are worse at this than MSN. We buy a domain per idea. We spin up waitlist., docs., status., beta. and old. subdomains on free hosts, then kill the project and delete the Vercel or Heroku app. The DNS record is in a different dashboard, so it stays.
This post is the 15-minute check I'd run on every domain you still own, plus two small scripts. Everything here is read-only. It doesn't attack anything, it just looks up your own DNS.
Three ways a subdomain ends up pointing at someone else
1. A CNAME to a domain you no longer own. This is the MSN case and it's the worst one, because no platform sits in the middle. If the target domain is unregistered, anyone can buy it for the price of a normal domain and gets full control of your subdomain: web content, a valid TLS certificate, and email (SPF/MX on the target domain).
Indie version: blog.mainproduct.com CNAME to myoldblogidea.com, and you let myoldblogidea.com lapse last spring because it was $15 a year for nothing.
2. A CNAME to a deleted resource on a shared host. You point docs.yourapp.com at yourapp-docs.herokuapp.com or an S3 bucket, then delete the app or bucket. Whether someone else can now claim that name depends on the provider. The community list can-i-take-over-xyz on GitHub (EdOverflow/can-i-take-over-xyz) tracks this. When I parsed its table today it had 76 services: 36 marked Vulnerable, 26 Not vulnerable, 14 Edge case. AWS S3, Surge.sh, Ghost and Bitbucket are on the Vulnerable side. GitHub Pages, Heroku, Vercel, Netlify, Shopify and Webflow are Edge case, which I read as "depends on how it was set up, and the provider has added some checks". Fly.io and Firebase are listed as Not vulnerable.
The list says itself it's community-maintained with no guarantee of correctness, so read "Edge case" as "don't rely on it", not "safe".
3. An SPF include (or MX, or NS) that points at something dead. Guardio calls this one SPF-Takeover. Their example was swatch.com, whose SPF record had a:directtoaccess.com in it, an old marketing-service domain that had been abandoned. a: means "any IP that domain's A record points to may send as us". Buy the abandoned domain, point it at your server, and you're an authorized sender for the main domain. No CNAME needed. (Swatch's record no longer has it, I checked.) Same idea with an MX record pointing at a mail host on a dead domain, or a subdomain delegated with NS records to a DNS provider where you deleted the zone (DigitalOcean is on the can-i-take-over-xyz list for exactly that).
What someone actually gets
Microsoft's Azure docs have a decent page on dangling DNS ("Prevent dangling DNS entries and avoid subdomain takeover"), and its risk list applies to any host:
- They can get a valid TLS certificate for your subdomain. The padlock means nothing here.
-
Cookies scoped to your parent domain (
Domain=yourapp.com) get sent to every subdomain, including the hijacked one. If your session cookie is set that way, a taken-overold.yourapp.comreceives it with every request. - Email that passes SPF/DMARC under your name, like the MSN case. Your customers see your domain.
- Phishing pages on a URL your users have reason to trust.
The cookie one is worth 2 minutes on its own. Open devtools on your app, look at the session cookie. If it has no Domain attribute it's host-only and sibling subdomains don't get it. If it shows .yourapp.com, every subdomain you ever created is part of your login security. A __Host- prefix on the cookie name forces host-only (the browser rejects it if a Domain attribute is set), so that's the cheap fix if you don't need cross-subdomain sessions.
Step 1: list every hostname you have
You need two lists, because each one misses things.
Your DNS provider's export. Cloudflare, Namecheap, Route 53 and most others let you export the zone as a text file. That's what actually exists right now.
Certificate transparency logs. Every public TLS cert gets logged, so crt.sh shows names you had certs for, including ones you forgot. This catches the beta2. you made at 1am in 2024:
curl -s "https://crt.sh/?q=%25.yourapp.com&output=json" \
| jq -r '.[].name_value' | tr 'A-Z' 'a-z' | sed 's/^\*\.//' | sort -u > hosts.txt
echo yourapp.com >> hosts.txt
crt.sh is slow, about 20 seconds for a small domain when I tried it, and sometimes times out. Just run it again. Then paste in every name from the DNS export too and sort -u hosts.txt -o hosts.txt.
Now the part people skip: also make a list of every domain you own or used to own, with its renewal date. Not just the live product. The dead idea domains are exactly the ones that can turn into case 1, if anything still points at them. I'd keep this in the same sheet as a one-evening bill audit for a side project: every domain, renewal date, renewal price, and a keep-or-drop decision. The new rule I'd add to that sheet: a domain can only be dropped after you've searched your other zones for records pointing at it.
Step 2: check every name by hand once
For one name, three commands tell you most of it:
dig +short CNAME docs.yourapp.com # where does it point?
dig docs.yourapp.com A +noall +comments | grep status # NOERROR or NXDOMAIN?
curl -sk https://docs.yourapp.com/ | head -c 300 # what does it serve?
If the CNAME is there but the status is NXDOMAIN, the target doesn't exist. That's dangling. Then check whether the target's domain is even registered. RDAP is the newer whois with a proper HTTP API, and rdap.org redirects you to the right registry:
curl -sL -o /dev/null -w '%{http_code}\n' https://rdap.org/domain/msnmarthastewartsweeps.com
# 404
404 means no registration record. Anyone can buy it. For .com that came back from Verisign's RDAP server when I ran it today.
Step 3: check all of them with a script
Doing that by hand for 40 names gets old, so here's the script. It needs only dig and curl.
#!/usr/bin/env bash
# usage: ./check-dangling.sh hosts.txt (one hostname per line)
# needs: dig, curl. Read-only: DNS lookups, one RDAP lookup, one GET per host.
# Fingerprints from github.com/EdOverflow/can-i-take-over-xyz (a match is a hint, not proof)
FP='There isn.t a GitHub Pages site here|Site not found · GitHub Pages|No such app|NoSuchBucket|DEPLOYMENT_NOT_FOUND|Not Found - Request ID|project not found|Repository not found|Sorry, this shop is currently unavailable'
apex() { echo "$1" | sed 's/\.$//' | awk -F. '{print $(NF-1)"."$NF}'; } # naive: wrong for .co.uk etc
while read -r host; do
[ -z "$host" ] && continue
status=$(dig "$host" A +noall +comments | grep -o 'status: [A-Z]*' | cut -d' ' -f2)
last=$(dig +short "$host" A | grep '\.$' | tail -n1) # last CNAME in the chain, if any
if [ -n "$last" ] && [ "$status" = "NXDOMAIN" ]; then
code=$(curl -sL -o /dev/null -w '%{http_code}' "https://rdap.org/domain/$(apex "$last")")
if [ "$code" = "404" ]; then
echo "CRITICAL $host -> $last ($(apex "$last") is not registered, anyone can buy it)"
else
echo "DANGLING $host -> $last (target does not resolve, check if the provider lets others claim it)"
fi
continue
fi
if [ "$status" = "NXDOMAIN" ]; then echo "GONE $host (no record at all, fine)"; continue; fi
# -k on purpose: a dangling host often has the wrong cert, we only want the error page text
body=$(curl -sk --max-time 10 "https://$host/" || curl -s --max-time 10 "http://$host/")
hit=$(echo "$body" | grep -oE "$FP" | head -n1)
if [ -n "$hit" ]; then
echo "CHECK $host -> ${last:-direct} (page says: \"$hit\")"
else
echo "OK $host -> ${last:-direct}"
fi
done < "$1"
To test it without pointing it at anyone else's domain, I set up a small fake zone locally. old-promo.lab.test is a CNAME to the real, currently unregistered msnmarthastewartsweeps.com (a replay of the MSN case), status.lab.test is a CNAME to an Azure web app name that doesn't exist, and www.lab.test points at the fixed MSN record. I also fed it the provider default hostnames for apps and buckets that don't exist, which is what a CNAME target looks like after you delete the app. Real output:
OK www.lab.test -> a-0003.a-msedge.net.
CRITICAL old-promo.lab.test -> msnmarthastewartsweeps.com. (msnmarthastewartsweeps.com is not registered, anyone can buy it)
DANGLING status.lab.test -> zq8x7-none-404.azurewebsites.net. (target does not resolve, check if the provider lets others claim it)
CHECK zq8x7-none-404.vercel.app -> direct (page says: "DEPLOYMENT_NOT_FOUND")
CHECK zq8x7-none-404.herokuapp.com -> ie02.ingress.herokuapp.com. (page says: "No such app")
CHECK zq8x7-none-404-bucket.s3.amazonaws.com -> s3-w.us-east-1.amazonaws.com. (page says: "NoSuchBucket")
CHECK zq8x7-none-404.github.io -> direct (page says: "Site not found · GitHub Pages")
GONE not-a-real-name.lab.test (no record at all, fine)
How to read it:
- CRITICAL: fix today. Delete the record, or buy the domain back yourself if something still needs it.
- DANGLING: the CNAME target is gone. For Azure, S3 and the other Vulnerable-list providers, assume someone can claim it. Delete the record.
- CHECK: the name resolves to a provider that says "nothing here". Note Heroku and S3 resolve fine even for apps that don't exist, so NXDOMAIN alone won't catch them, that's why the script fetches the page. A CHECK doesn't prove takeover is possible. It means you have a record pointing at nothing, and you should delete it anyway.
- GONE / OK: fine.
I also ran it on my own launch sites today: 8 domains, 31 unique hostnames from crt.sh (I didn't add zone exports for this run, so it's the cert-log view only). Nothing came back CRITICAL, DANGLING or CHECK. 18 of the 31 were GONE, old names that had a cert at some point and whose records were already deleted. That's the outcome you want, and it's also a reminder of how many names pile up without you noticing.
Known gaps, so you don't trust it more than it deserves: the apex() function takes the last two labels, so something.co.uk will be checked as co.uk. Fingerprints change when providers redesign their error pages. It doesn't look at A records pointing at raw cloud IPs you released (that's a real risk too, an IP you gave back can be handed to someone else). And a "registered" RDAP answer means somebody owns the domain, not that you do.
Step 4: check your SPF includes
Case 3 is easy to miss because it lives in a TXT record nobody reads. This one lists every include:, redirect=, a: and mx: domain and flags the ones with no registration:
#!/usr/bin/env bash
# usage: ./spf-includes.sh example.com
# lists every include:, redirect=, a: and mx: domain in the SPF record and flags ones nobody owns
dig +short TXT "$1" | grep -i 'v=spf1' | grep -oiE '(include:|redirect=|\ba:|\bmx:)[^ "]+' | sed -E 's/^(include:|redirect=|a:|mx:)//I' |
while read -r inc; do
apex=$(echo "$inc" | awk -F. '{print $(NF-1)"."$NF}')
code=$(curl -sL -o /dev/null -w '%{http_code}' "https://rdap.org/domain/$apex")
if [ "$code" = "404" ]; then echo "UNREGISTERED $inc <- anyone who buys $apex can send mail as $1"
else echo "registered $inc"; fi
done
On the test zone, where I put the two dead domains from Guardio's report next to a normal Google include:
registered _spf.google.com
UNREGISTERED directtoaccess.com <- anyone who buys directtoaccess.com can send mail as lab.test
UNREGISTERED msnmarthastewartsweeps.com <- anyone who buys msnmarthastewartsweeps.com can send mail as lab.test
Both are unregistered again today, years after the report. Domains like this keep coming back on the market, which is why a record you cleaned up is the only real fix. And a "registered" result only tells you that somebody owns the domain, not that you do.
Also look at the "registered" ones and ask if you still use that tool. Every include for a newsletter tool you cancelled is a sender you're still vouching for, and it eats into SPF's 10-lookup limit. I went through that limit in more detail when counting SPF lookups before a launch email. Run the same check on subdomains you send from (mail., news.), not just the root.
Step 5: fix it, in the right order
- Delete the DNS record first, then the resource. Microsoft's guidance literally says to put "Remove DNS entry" on the list of required checks when decommissioning. If you delete the Heroku app first and get distracted, you've made a dangling record. Flip the order and the worst case is a broken page for a minute.
-
Verify your domain with the host where it's supported. GitHub Pages has custom domain verification: you add a TXT record at
_github-pages-challenge-USERNAME.yourapp.comand then only your account's repos can use that domain and its immediate subdomains. GitHub's docs say takeovers can happen when you delete the repo, downgrade your plan, or anything else that unlinks the domain while DNS still points there. Azure App Service has the same idea with anasuid.TXT record. Check what your host offers. -
No wildcard records (
*.yourapp.com) pointing at a shared host. GitHub's docs put it bluntly: wildcards "put you at an immediate risk of domain takeovers, even if you verify the domain", because verifyingexample.comprotectsa.example.combut notb.a.example.com. - Before you let a domain lapse, search for it. Grep every zone export you have for the domain name. Any CNAME, MX, NS or SPF include that mentions it gets removed before the renewal date, not after.
- If you find a CRITICAL that's been there a while, treat it as maybe-used. Check the subdomain's current content and SPF/MX on the target, and if your session cookie was scoped to the parent domain, rotate sessions.
The 15-minute version
If you only do part of this:
- Export your DNS zones and run
check-dangling.shon them plus the crt.sh list. - Run
spf-includes.shon every domain that sends mail. - Look at your session cookie's Domain attribute.
- Add "delete the DNS record" as the first line of your project shutdown checklist.
The MSN record sat there for more than two decades and the problem only started when someone bought one cheap domain. Most of us have a few more dead domains than MSN had sweepstakes.
So a narrow question for people running a few products: when you kill a side project, what happens to the domain? Do you keep renewing it forever just in case, or let it go, and if you let it go, did you check that nothing still pointed at it first?
Top comments (1)
To your question: I let them go, but only after a grep of every zone for the name. Your MSN example is exactly why. The renewal stops costing $15 the day something still points at it.
One gap in the crt.sh step: it only finds subdomains that once got a certificate. An old
promo.ormail.CNAME that never served HTTPS won't be in CT logs at all, and those are often the forgotten ones.Passive DNS fills that in. The WhoisFreaks Subdomains API (Whoisfreaks.com/products/subdomains-api) combines CT logs with passive DNS and returns inactive and historical subdomains with
first_seen,last_seenandinactive_from. Feeding that list into yourcheck-dangling.shcatches records you no longer remember creating.