In Chapter 1 a stranger served a gambling scam on a domain of mine by claiming a deleted S3 bucket name my DNS still pointed at. That chapter is the anatomy: how the attack works once it's happened. This one is the field work: how I figured out, in the first frightened hour, that I hadn't actually been "hacked" — and then how I turned that single incident into a recurring 15-minute habit that audits every domain I own.
Because the honest truth is that the moment you see something wrong on your own domain, your brain screams one word — hacked — and that word sends you sprinting in exactly the wrong direction. You start changing passwords and scanning your laptop for malware while the real problem sits untouched in a DNS record. The cure for the panic isn't calm; it's a method. A boring, repeatable, outside-in method that answers the only question that matters first: is this a break-in, or a leftover signpost?
The mindset: work outside-in
Here's the single idea that turns panic into a procedure. When a domain misbehaves, investigate it in the order that the request itself travels — from the most public fact to the most private one:
Figure 1 — The order of operations. Almost every "my domain's hacked!" story is solved in the first three boxes, using only public information that costs nothing to read. You verify the cheap, external facts before you ever touch a password.
Why this order? Two reasons. First, the public layers are free and non-destructive to read — whois, dig, and curl change nothing; you can run them a hundred times. Second, most domain horror stories never reach the account layer at all. They're dangling pointers, lapsed renewals, or stale caches — misconfigurations, not break-ins. If you start by frantically rotating credentials, you've done work, raised your heart rate, and learned nothing about the actual fault. Outside-in means you've usually found the answer before you'd otherwise have finished typing your password.
Let's walk the layers with the real commands.
Step 1 — Who controls the domain at the registry?
The registry is the ground truth for ownership. Before anything about content matters, confirm the domain itself is still yours and still points where you expect.
whois yoursite.com(If your TLD has moved to RDAP — the modern HTTPS+JSON replacement for WHOIS — the equivalent lookup and how to read its fields are covered in the Internet Infrastructure series. Same questions, cleaner output.)
Read four lines and only four lines:
- Registrar — is it still the company you bought the domain from? If a domain was transferred out from under you, this changes. Mine hadn't.
- Expiry date — did it lapse? An expired domain is its own catastrophe (more on that in the catalog below). A domain that "suddenly shows someone else's site" is very often simply a domain that expired and got re-registered by a drop-catcher — check this before you assume malice.
- Name servers — do you recognise them? These are the servers that answer DNS for the domain. If they're not your provider's (not your Cloudflare / Route 53 / registrar nameservers), someone re-delegated your domain — a serious finding. Mine were exactly the Cloudflare nameservers I'd set.
- Domain status — the EPP status codes.
clientTransferProhibitedmeans a transfer lock is on (good). A bareokmeans no lock — anyone who gets into your registrar account could walk the domain out the door. Note it as a hardening item even if nothing's wrong today. - Updated date — when did the registry record last change? If that timestamp is recent and you didn't touch anything, that's a thread to pull.
Step 2 — Where does DNS actually point?
The registry says who answers for the domain. Now ask what those answers are. This is dig, the DNS query tool.
dig +short NS yoursite.com
dig +short A yoursite.com
dig +short CNAME yoursite.com
dig +short CNAME www.yoursite.comYou're confirming two things: the nameservers match what the registry claimed (Step 1), and the actual records — the A (an IP) or CNAME (an alias to another hostname) — point where you expect. In my incident, the apex CNAME pointed at an S3 website endpoint I'd long since stopped using, and the www CNAME pointed at your-account.coursehost.com, a course platform. Two records, two third parties, both suspects (Chapter 1 explains why every third-party pointer is a takeover candidate).
The trap that wasted my first ten minutes
Here is a detail no tutorial warns you about, and it sent me chasing a ghost. My laptop runs a fake-IP proxy (the Clash / Surge style, common with VPN setups): instead of resolving real addresses, it hands the OS a synthetic placeholder IP from the 198.18.0.0/15 range and does the real DNS resolution itself, remotely. So my local dig cheerfully returned:
198.18.1.42— a meaningless number that exists nowhere on the public internet. For ten minutes I tried to make sense of an IP that was a lie my own machine was telling me.
Step 3 — What is actually being served, and by whom?
DNS tells you the address. Now read the response — and crucially, the headers, which announce which platform is answering. This is where the backend identifies itself, often by accident.
curl -sI http://yoursite.com/ # -s silent, -I headers only
curl -s http://yoursite.com/ | head -c 2000 # the first 2 KB of the bodyThe headers are a fingerprint. In my case they read:
HTTP/1.1 200 OK
Server: AmazonS3
x-amz-request-id: ...
x-amz-id-2: ...Server: AmazonS3 plus the x-amz-* request IDs is S3 saying, out loud, "an Amazon S3 bucket is serving this." Combined with a CNAME to an S3 website endpoint from Step 2, the picture was complete: my domain was being served straight from a bucket. The body — slot-machine graphics in a language I don't read — confirmed it wasn't my bucket.
You can even ask S3 which region the live bucket sits in, which tells you where the attacker created it:
curl -sI http://yoursite.com.s3.amazonaws.com/ | grep -i x-amz-bucket-region
# x-amz-bucket-region: <region> ← the bucket EXISTS, in *someone's* accountDifferent platforms wave different flags. Learn to read the common ones, because the Server header (and a few friends) usually tells you the whole story:
Server: AmazonS3+x-amz-request-id→ Amazon S3. The bucket name is globally claimable (Chapter 1).Server: GitHub.com+x-github-request-id→ GitHub Pages. A body reading "There isn't a GitHub Pages site here" means it's dangling.Server: Vercel/x-vercel-id→ Vercel. A "domain not configured" page signals a dangling pointer.Server: cloudflare+cf-ray→ Cloudflare (proxied). The real origin is hidden behind this — you'll have to look deeper to find what's actually being served.x-amz-cf-id+Via: … CloudFront→ AWS CloudFront. The origin behind the CDN is the real target.- A
404whose body containsNoSuchBucket→ S3 with no bucket. The exact takeover fingerprint from Chapter 1 — a live domain pointing at nothing.
Step 4 — Is this MY resource, or an attacker's?
Three external commands have told you the what. Now, and only now, do you log into an account — to answer one question: does the thing my DNS points at belong to me, or to a stranger?
- Cloud console (the resource itself). Open the relevant console and look for the named resource. S3 bucket names are global, so the bucket list is a single list across all regions — is there a bucket named
yoursite.comin my account? In my case: no. My DNS pointed at a bucket I did not own. That's the whole case, right there. - DNS provider audit log. Cloudflare (and most providers) keep an audit log: who created or edited each record, when, and from what IP. Scan it for changes you don't recognise. Mine showed only my own edits, made from my own addresses, in tidy batches.
- Cloud activity/event history. AWS CloudTrail (or your provider's equivalent) records account-level events. Scan for the alarming ones:
UpdateDomainNameservers, new IAM users, console logins from unfamiliar locations, access-key creation. I had none. - Certificate Transparency. Every TLS certificate ever issued for your hostname is in a public, append-only log. Search
crt.shfor your domain: did someone issue a certificate foryoursite.comthat you didn't request? That would mean an attacker proved control of the hostname to a certificate authority — a strong compromise signal. (It's also how attackers discover your subdomains in the first place — assume every subdomain you've ever created is publicly known.)
The crucial reframe: each item above isn't "go panic in the console." It's a specific yes/no question with a specific place to look. You're not searching for trouble in general; you're checking named facts one at a time.
Step 5 — The verdict: dangling record, or real compromise?
Now you make the call that determines everything you do next. Lay your findings against this tree:
Figure 2 — The fork in the road. The left branch is annoying but cheap: one DNS edit, no credentials touched. The right branch is a different incident entirely.
Here's the exact reasoning that settled my case — notice that none of it required certainty about the attacker, only certainty about me:
- The scam went live months before any account change. I could date the spam from the
Last-Modifiedheader on its HTML; it predated every nameserver or account event in my logs. An attack that needs no change to my accounts is, by definition, not an account compromise. - The only recent account change benefited nobody but me. A later nameserver move had pulled my domains into my own DNS provider — something an attacker gains exactly nothing from doing. (It was a routine migration; the migration is what copied the bad record forward, but it wasn't the breach.)
- The pattern was self-evidently my own batch work. Several domains moved within minutes of each other — the signature of one person running one migration, not an intruder.
Three facts, all about my own activity, all pointing the same way: dangling record. No compromise. The fix was the one-line DNS edit from Chapter 1, not a fire drill.
The mirror image is just as clear. Had Step 4 turned up records I never created, IAM users I didn't add, or logins from countries I've never been to, I'd have flipped to the right branch instantly: rotate every credential, enforce MFA/passkeys everywhere, open a support case with the provider, and treat anything that touched the domain as exposed. Same investigation, opposite playbook — and you can only choose correctly because you did the outside-in work first.
Mini-exercise: run the three free commands on a domain you own
You don't need an incident to practise. Pick one domain and read its three public signals end to end:
D=yoursite.com
whois "$D" | grep -iE 'registrar|expir|name server|status|updated'
dig @1.1.1.1 +short NS "$D"; dig @1.1.1.1 +short A "$D"; dig @1.1.1.1 +short CNAME "$D"
curl -sI --max-time 10 "http://$D/" | head -5Now answer, out loud: Who controls it? Where does it point? Who's serving it? If any answer is "I'm not sure," you've found something to look into — which is the entire point of doing this before there's an emergency.
The full map: every variant you're really auditing against
My incident was the cloud-storage flavour. But the shape is universal — a pointer outlives its target, and the target's name or address can be claimed by a stranger. Only the claimable thing changes. Once you can diagnose one, you owe it to every domain you own to know the whole family, because your audit (next section) checks for all of them, not just S3. For each class below: what a stranger can claim, how you'd spot it, and the one-line defense.
- Cloud storage (my case). Claimable: S3 / GCS / Azure bucket names — global, released the instant you delete. Spot:
NoSuchBucket(or the equivalent) on a live domain. Defense: serve via a CDN with a random origin name, and keep the bucket as long as any record points at it. - PaaS / static host. Claimable: app/site names on GitHub Pages, Heroku, Netlify, Vercel, Azure, Surge, Firebase. Spot: "No such app" / "site not found" platform pages. Defense: use the platform's domain verification so nobody else can attach your hostname.
- SaaS CNAME (my
wwwrecord!). Claimable: a custom-domain slot on Shopify, Zendesk, Webflow, a course host, a status page, and dozens more. Spot: the SaaS shows "domain not configured." Defense: the day you cancel any SaaS, grep your zones for its hostname and delete the record. - Recycled cloud IP. Claimable: an
Arecord pointing at an EC2/VM IP you released — cloud IPs get reassigned to other customers. Spot: the IP now answers as someone else's box. Defense: deleteArecords when you terminate servers; keep Elastic IPs you hold, or sit behind a load balancer. - NS delegation (the scariest). Claimable: a subdomain delegated by
NSto a DNS zone you then deleted. Spot:dig NS sub.yoursite.comreturns a provider that no longer has the zone. Defense: delete the delegation before deleting the hosted zone, and treat everyNSrecord as high-risk. - Expired domain. Claimable: your own domain — or a domain your code depends on (a
<script src>, an npm maintainer's email, a webhook URL). Spot: WHOIS shows it lapsed or was re-registered. Defense: auto-renew + a valid card + a transfer lock, and audit the external URLs your apps load. - Email spoofing. Claimable: the absence of SPF/DKIM/DMARC, or a dangling
MX. Spot: anyone can sendFrom: you@yoursite.com, or password resets divert to an attacker. Defense: publishv=spf1 -all+ ap=rejectDMARC on every domain, even ones that send no mail. - Registrar / account hijack. Claimable: your registrar / DNS / cloud login itself. Spot: records, transfers, or NS changes you didn't make. Defense: unique passwords + TOTP/passkey (not SMS) on the registrar, the DNS provider, and the email that can reset them; registry lock for crown-jewel domains.
- Adjacent: typosquat & CT mining. Claimable: look-alike domains (
yours1te.com), and your subdomains as leaked through Certificate Transparency logs. Spot: a clone targeting your users; scanners hitting subdomains you'd forgotten. Defense: monitor look-alikes once a project gets popular, and assume every subdomain you've ever made is public.
The 15-minute quarterly audit
This is the part that turns a one-time scare into permanent safety. None of it is advanced; all of it would have made my incident impossible. Run it quarterly across every domain you own.
The per-domain sweep
#!/usr/bin/env bash
# dns-audit.sh — run quarterly for every domain you own
DOMAINS="yoursite.com oldproject.com" # your real list
for d in $DOMAINS; do
echo "==================== $d ===================="
# 1. Registry health: expiry, lock, expected nameservers?
whois "$d" | grep -iE 'expir|status|name server'
# 2. What do apex and www point to? (external resolver — bypass any VPN/proxy)
for h in "$d" "www.$d"; do
echo "--- $h"
dig @1.1.1.1 +short CNAME "$h"
dig @1.1.1.1 +short A "$h"
done
# 3. Does it serve what YOU expect?
curl -sI --max-time 10 "http://$d/" | head -4
curl -sI --max-time 10 "https://$d/" | head -4
doneThen, for every external pointer the sweep prints, ask the same three questions — the heart of the whole exercise:
- Do I still own the resource at the target (the bucket, app, SaaS account, server, IP)?
- If I deleted it tomorrow, could a stranger claim the same name or IP?
- Is the platform domain-verified, so a stranger can't attach my hostname even if they try?
Any "no / yes / no" is a finding. Fix it today, not someday.
The whole-account sweep (Cloudflare)
Walking domains one at a time is fine for a handful. If your DNS lives on Cloudflare and you have many zones, list every unproxied CNAME/A record across every zone in one shot, with a read-only token (scope: Zone → DNS → Read only — never put a write token in a script):
TOKEN=... # a READ-ONLY Cloudflare API token
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones?per_page=50" |
jq -r '.result[] | "\(.id) \(.name)"' |
while read -r zid zname; do
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$zid/dns_records?per_page=200" |
jq -r --arg z "$zname" '.result[]
| select(.proxied == false and (.type == "CNAME" or .type == "A"))
| "\($z)\t\(.type)\t\(.name)\t→ \(.content)"'
doneThat prints one line per "naked" pointer — exactly the records that can dangle. Eyeball each one against the catalog table above. The first time I ran this across my zones it took a few minutes and surfaced not just the S3 record that started everything, but two more suspects I'd completely forgotten: the www → your-account.coursehost.com SaaS CNAME (class #3) and an A record on a side project, oldproject.com, still pointing at a cloud IP I'd long since released (class #4). One scan, three findings, none of which I'd have noticed by waiting.
Mini-exercise: build the smallest monitor that would have caught me
The audit is periodic; an attack is continuous. The cheapest gap-closer is a scheduled check that fetches each at-risk host and alerts on a takeover fingerprint (NoSuchBucket, "There isn't a GitHub Pages site here", "No such app", "domain not configured"). A Cloudflare Cron Trigger + Worker is a perfect home for it. Sketch the smallest version that would have caught my incident on day one — a list of hosts, a fetch, a substring match, a notification. What's the fewest lines that turns a two-month blind spot into a same-day alert?
What the incident actually taught me
The technical fix was one DNS edit. The lessons were bigger than the bug.
- "Hacked" usually means "misconfigured." Trace the public path — registry → nameservers → records → content — before you assume a breach. The scary explanation is almost never the right one, and the calm method finds the real one faster.
- Teardown is a security operation. Every tutorial teaches you to deploy the S3 site; none teach you to decommission it. The vulnerability was born the day I deleted a bucket and left its record behind. Retiring a service deserves the same care as launching one — DNS record first, resource second.
- Migrations copy your mistakes faithfully. When I moved DNS providers, the importer preserved a two-year-old landmine perfectly. A migration is the ideal moment to audit every record; treat the import-review screen as a security checkpoint, not a formality. (And — per Chapter 1 — re-verify any named resource an AI agent recalls from memory; a deleted bucket lingers in an agent's notes long after it's gone from the cloud.)
- Attackers are bots, not masterminds. Nobody targeted me. A scanner resolving millions of domains matched a fingerprint and a script claimed a bucket. Your obscure side-project domain is exactly as exposed as a Fortune 500's — the bot doesn't care whose it is.
- Detection was the real failure. The fix took two minutes; noticing took roughly two months. Cheap monitoring beats heroic response. Note the sting in the tail: the scam page returned HTTP 200, so a naive uptime check would have shown a cheerful green tick the entire time. Assert on page content, not just status codes.
- Side projects multiply attack surface. A few dozen domains is a few dozen sets of records to keep honest. Every domain you buy is a small recurring chore — budget for the audit, or don't buy the domain.
Challenges
- Run the real investigation. Pick a domain you own and complete all five outside-in steps, writing the verdict (dangling vs compromised) and the single fact that decided it. If you can't reach a confident verdict, which step's evidence was missing?
- Catch the VPN trap on purpose. If you use a proxy/VPN, compare a bare
digagainstdig @1.1.1.1for the same domain. Do they agree? If not, explain in one sentence what your machine was actually telling you — and why trusting it would have misled an investigation. - Find your own dangling pointer. Run the whole-account Cloudflare sweep (or the per-domain script). For every unproxied
CNAME/Ait prints, answer the three ownership questions. Did anything come back "no / yes / no"? - Audit the email blind spot. Pick a domain you don't send mail from and check whether it publishes SPF (
dig +short TXT yoursite.com | grep spf) and DMARC (dig +short TXT _dmarc.yoursite.com). If either is missing, write the exactv=spf1 -allandp=rejectrecords that would stop anyone spoofing it — that's catalog class #7, closed. - Design the content-aware monitor. Extend the mini-exercise into a spec: which hosts, what fingerprints, what cadence, where the alert lands — and crucially, why it must assert on body content rather than HTTP status. Reference the 200-OK scam page in your justification.
Key Points
- Investigate outside-in: registry → nameservers → records → content → accounts → your machine. The first three layers are free, non-destructive, and solve most cases before you ever touch a credential.
whois/RDAP,dig,curl -Iare the whole core toolkit. Registrar/expiry/NS/status, then the actual records, then the response headers that fingerprint the backend.- A VPN or proxy can make
diglie with synthetic198.18.x.xaddresses. Always confirm against an external resolver (dig @1.1.1.1). - The verdict hinges on facts about you, not the attacker. Dangling record = registrar/NS/IAM all yours but DNS points at a resource you no longer own → one DNS edit. Real compromise = records/users/logins/certs you didn't create → rotate everything, enforce MFA, open a case.
- The pattern generalises to nine classes — cloud storage, PaaS, SaaS CNAME, recycled IP, NS delegation, expired domains, email spoofing, registrar hijack, typosquat/CT mining. NS delegation and email are the common blind spots.
- A 15-minute quarterly audit prevents all of it. Sweep every zone, ask three ownership questions of every external pointer, and publish SPF/DMARC even on domains with no email.
- Detection, not response, is the hard part. The fix is minutes; the gap is months. Monitor continuously, and assert on page content — a takeover can return a perfectly healthy
200 OK.
Diagnosis and auditing both share a single discipline: know exactly what every record depends on, and never trust a name or address you don't actually control. That discipline carries straight into the next chapter, where we stop looking at DNS and step inside the page itself — how untrusted content becomes executable script (cross-site scripting), why a secret pasted into frontend code is already public, and how to reason about the trust boundary between your code and everything it loads. Same thread, one layer deeper: the attacker only ever needs the one thing you forgot you were trusting.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper