Tutorials Web Security Series Chapter 2

Is My Domain Hacked? The Field Guide — Investigate a Suspected Takeover, Then Audit Every Domain You Own

SecurityChapter 2 of the Web Security Series28 minJune 10, 2026Intermediate

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:

Loading diagram…

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 readwhois, 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:

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.com

You'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 body

The 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* account

Different platforms wave different flags. Learn to read the common ones, because the Server header (and a few friends) usually tells you the whole story:

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?

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:

Loading diagram…

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:

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 -5

Now 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.

  1. 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.
  2. 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.
  3. SaaS CNAME (my www record!). 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.
  4. Recycled cloud IP. Claimable: an A record 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: delete A records when you terminate servers; keep Elastic IPs you hold, or sit behind a load balancer.
  5. NS delegation (the scariest). Claimable: a subdomain delegated by NS to a DNS zone you then deleted. Spot: dig NS sub.yoursite.com returns a provider that no longer has the zone. Defense: delete the delegation before deleting the hosted zone, and treat every NS record as high-risk.
  6. 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.
  7. Email spoofing. Claimable: the absence of SPF/DKIM/DMARC, or a dangling MX. Spot: anyone can send From: you@yoursite.com, or password resets divert to an attacker. Defense: publish v=spf1 -all + a p=reject DMARC on every domain, even ones that send no mail.
  8. 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.
  9. 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
done

Then, for every external pointer the sweep prints, ask the same three questions — the heart of the whole exercise:

  1. Do I still own the resource at the target (the bucket, app, SaaS account, server, IP)?
  2. If I deleted it tomorrow, could a stranger claim the same name or IP?
  3. 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)"'
done

That 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.

Challenges

  1. 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?
  2. Catch the VPN trap on purpose. If you use a proxy/VPN, compare a bare dig against dig @1.1.1.1 for 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.
  3. Find your own dangling pointer. Run the whole-account Cloudflare sweep (or the per-domain script). For every unproxied CNAME/A it prints, answer the three ownership questions. Did anything come back "no / yes / no"?
  4. 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 exact v=spf1 -all and p=reject records that would stop anyone spoofing it — that's catalog class #7, closed.
  5. 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

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.

Ch 1: Subdomain takeover — the deleted-bucket hijackCh 3: How logins get broken — credential stuffing & session hijacking
InfrastructureInternet Infrastructure SeriesPractical internet infrastructure concepts: DNS, RDAP, WHOIS, IP allocation, ASNs, and registries.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.CloudflareCloudflare Feature FocusFocused Cloudflare tutorials for Workers, R2, Stream, Durable Objects, and edge deployment.

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
5 free articles remainingSubscribe for unlimited access