Lookalike Hunting
Find typosquats, lookalike domains, fraudulent registrations, credential-theft infrastructure, and phishing kits reused across domains.
On this page (21)
- Quick triage
- Generate registered typosquats in one call
- Lookalike triage — resolve **and** score in one pass
- Scored verdict for a single suspect domain
- Surface scans
- Brand-name substring search
- Reduce lookalikes to their registrable apex
- Cluster expansion
- Co-hosting — find the rest of the phishing kit
- Shared registrant — pivot through WHOIS email
- Shared nameserver — the durable cluster signal
- Certificate Transparency — catch lookalikes the moment they get a cert
- Threat-feed status — already tracked?
- Phishing kits
- Which phishing kits leave the most distinctive path?
- Which domains serve one phishing kit?
- Which kits are spread across the most separate domains?
- Advanced
- Track the kit across rotating domains via TLS fingerprint
- Backlink check — who references your real domain
- Run it from an AI agent
Lookalike Hunting Documentation
You're protecting a brand against domain abuse: typosquats, homoglyphs, lookalike infrastructure, and phishing kits that clone your login page. The job is repetitive and adversarial. Attackers register hundreds of permutations, rotate IPs, and reuse the same kit across brands. Hand-rolling STARTS WITH/CONTAINS patterns catches a fraction of it and tells you nothing about whether a hit is live or dangerous. These recipes take you to the registered lookalikes, which of them are live and dangerous, the cluster around a confirmed hit, and the phishing kits that leave the same URL path on every domain they are deployed to — as a handful of copy-paste Cypher queries. Every example runs against https://graph.whisper.security/api/query; the deepest ones need an API key — sign in to get one.
Run it live: Typosquat Scanner · Brand-impersonation TLD sweep · Build the takedown evidence package — each is a guided flow you can run on your own domain, with the Cypher behind every step visible.
New to the graph? Start with Getting Started and the Procedures overview. The full label/edge model is in the Graph Schema.
Key concepts: Typosquatting · Domain generation algorithm · Co-hosted domains · Certificate Transparency.
Quick triage
Generate registered typosquats in one call
What the graph does: whisper.variants() runs your domain through many generation algorithms — character omission, repetition, transposition, keyboard-adjacent replacement and insertion, homoglyphs, bitsquatting, TLD swaps, and others — and by default returns only the variants that exist as nodes (i.e. are registered). That covers the homoglyph and bitsquat variants nobody writes out by hand.
// Registered lookalikes of paypal.com, highest-confidence first
CALL whisper.variants("paypal.com")
YIELD variant, method, exists, confidence, confidenceLabel
WHERE exists
RETURN variant, method, confidence, confidenceLabel
ORDER BY confidence DESC
LIMIT 25
Sample output (captured 2026-09-02):
[
{"variant": "paypa1.com", "method": "HOMOGLYPH", "confidence": 1.0, "confidenceLabel": "high"},
{"variant": "paypai.com", "method": "HOMOGLYPH", "confidence": 1.0, "confidenceLabel": "high"},
{"variant": "paypan.com", "method": "BITSQUATTING", "confidence": 1.0, "confidenceLabel": "high"}
]
exists: truemeans registered, not malicious. A parked typosquat and a live phishing page both register. The next two recipes separate them. Passfalseas the filter argument to also see generated-but-unregistered variants worth defensively registering. From an AI agent, the Typosquat Scanner runs this whole section as onerun_workflowcall.
Read
coveragebeforeband. Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean"; no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. means unknown, which is a different thing again; malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. and ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. mean there is evidence, whatever the band says. Full contract: Coverage — what we looked at.
Lookalike triage — resolve and score in one pass
What the graph does: chain the generator straight into DNS resolution and the reconciled verdict that every threat-listed node carries. One traversal answers which lookalikes are live, where they point, and which are already known-bad.
// Registered variants → where they resolve → reconciled verdict, all at once
CALL whisper.variants("paypal.com")
YIELD variant, method, exists
WHERE exists
WITH variant, method LIMIT 50
MATCH (h:HOSTNAME {name: variant})-[:RESOLVES_TO]->(ip:IPV4)
RETURN variant, method, ip.name AS resolves_to,
ip.verdictLevel AS ip_verdict, ip.verdictBlocking AS blocking
ORDER BY blocking DESC, ip_verdict DESC
LIMIT 25
Sample output (captured 2026-09-02):
[
{"variant": "payapl.com", "method": "TRANSPOSITION", "resolves_to": "212.92.105.212", "ip_verdict": "NONE", "blocking": false},
{"variant": "payapl.com", "method": "TRANSPOSITION", "resolves_to": "212.92.105.213", "ip_verdict": "NONE", "blocking": false},
{"variant": "pyapal.com", "method": "TRANSPOSITION", "resolves_to": "64.190.63.222", "ip_verdict": "NONE", "blocking": false}
]
A
blocking: truerow is takedown-ready evidence. TheNONE/LOWrows still matter — a homoglyph that resolves to a fresh host with no feed history is often ahead of feed coverage, and the sample above is a whole set of live lookalikes nothing has listed yet. For a per-domain evidence chain (which feeds, what weight, first/last seen), pass any hit throughexplain()below. How verdicts are reconciled from feeds is covered in Threat Feeds & Categories.
Scored verdict for a single suspect domain
What the graph does: explain() returns the score and the contributing feeds, factors, and timestamps — the inspectable evidence chain that makes a takedown ticket defensible.
// Full evidence chain for a reported lookalike
CALL explain("paypal-acc.com")
YIELD indicator, found, score, level, explanation, factors, sources
RETURN indicator, found, score, level, explanation, factors, sources
The result carries score, level, an explanation, a factors array showing the scoring arithmetic, and a sources array naming each feed with its weight and first/last-seen timestamps. YIELD those columns by name rather than taking the bare call — explain() also returns fields that stay empty for most indicators, and a blank column in a takedown ticket reads like missing data.
Works on a hostname, IP, ASN, or CIDR — the indicator type is auto-detected. An empty/
NONEresult is unknown, not clean — brand-new phishing domains routinely beat the feeds. Combine with the co-hosting and shared-nameserver pivots below to build the case from infrastructure when feeds are silent.
Surface scans
Brand-name substring search
What the graph does: a prefix-anchored sweep, one risky TLD at a time (.top, .cfd, and friends), catching the lookalikes whisper.variants() doesn't generate.
Anchor the brand keyword with STARTS WITH — that uses the name prefix index — and pin the namespace with ENDS WITH ".top". A bare CONTAINS over every hostname scans a billion-node label and times out.
// Brand keyword at the start of a name, inside one risky TLD
MATCH (h:HOSTNAME)
WHERE h.name STARTS WITH "paypal" AND h.name ENDS WITH ".top"
RETURN h.name AS domain
ORDER BY h.name
LIMIT 50
Each hit is a candidate impersonation — pivot it through
explain()for a verdict. Anchor onSTARTS WITH(notCONTAINS) so the sweep hits the prefix index instead of scanning; don't sort by a score column (most rows carry none, and the sort forces a scan); always keep theLIMIT. To catch the keyword mid-name too (login-paypal.top), run the Brand-impersonation TLD sweep workflow, which combines several anchored passes, or useCALL whisper.search("paypal", {mode: "prefix", types: ["HOSTNAME"]})for a bounded prefix hunt with a typed result.
Reduce lookalikes to their registrable apex
What the graph does: a sweep returns login.paypal-secure.cdn.example and paypal-secure.cdn.example as separate rows. whisper.psl.tldPlusOne() collapses any hostname to its registrable apex using the live PSL, so your watchlist counts unique registrations, not noisy subdomains.
// Collapse sweep hits to unique registrable apexes
MATCH (h:HOSTNAME)
WHERE h.name STARTS WITH "paypal" AND h.name ENDS WITH ".top"
WITH h.name AS host LIMIT 200
CALL whisper.psl.tldPlusOne(host) YIELD apex
RETURN DISTINCT apex
ORDER BY apex
LIMIT 30
Feed the deduped apex list back through
whisper.variants()andexplain()— fewer queries, no double-counting subdomains of one phishing site.
Cluster expansion
Co-hosting — find the rest of the phishing kit
What the graph does: co-tenancy is a single hop. Pivot off a reported domain's IP to every other host on it — phishing kits are frequently deployed in batches on one box. RESOLVES_TO points HOSTNAME→IP, so reach co-tenants via the reverse hop.
// Every other domain sharing an IP with a threat-listed lookalike
MATCH (h1:HOSTNAME {name: "paypal-acc.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH h1, ip LIMIT 10
MATCH (ip)<-[:RESOLVES_TO]-(h2:HOSTNAME)
WHERE h2 <> h1
RETURN ip.name AS shared_ip, h2.name AS related_domain
LIMIT 30
Cross-check
related_domainagainst your full brand watchlist — one kit deployment often spoofs several brands at once. Bound the IP fan-out withWITH ... LIMITso a CDN-fronted host doesn't explode the result set. To chain a co-hosted cluster into actor techniques, see Actor & ATT&CK layer.
Shared registrant — pivot through WHOIS email
What the graph does: HAS_EMAIL links a domain to its WHOIS contact email — one node per address, whatever format the registrar published it in — so reversing the edge finds every domain that registrant touched. A redacted registrant leaves no email to pivot on.
// Domains sharing a WHOIS registrant email with a known-bad lookalike
MATCH (seed:HOSTNAME {name: "paypal-acc.com"})-[:HAS_EMAIL]->(e:EMAIL)
WITH e LIMIT 5
MATCH (e)<-[:HAS_EMAIL]-(other:HOSTNAME)
WHERE other.name <> "paypal-acc.com"
RETURN e.name AS registrant_email, other.name AS related_domain
LIMIT 30
One leaked or reused registrant address can unmask a whole campaign. Chain
REGISTERED_BY→ORGANIZATIONfor the org-level pivot (andSAME_ORG_ASto fold spelling variants of one company together), orHAS_PHONEwhen the email is privacy-protected. For the registration-age signal (a fresh createDate on a privacy registrar is the classic burner tell), usewhisper.history.whois()— it needs an API key — or run the burner-domain detector flow.
Shared nameserver — the durable cluster signal
What the graph does: NAMESERVER_FOR points server→domain, so traverse it backwards to find a domain's nameservers, then forward again to every sibling they serve. Operators rotate IPs and registrars far more often than DNS providers, making this one of the most durable links between campaign domains.
// Domains served by the same nameservers as a known-abusive domain
MATCH (seed:HOSTNAME {name: "paypal-acc.com"})<-[:NAMESERVER_FOR]-(ns:HOSTNAME)
WITH ns LIMIT 10
MATCH (ns)-[:NAMESERVER_FOR]->(other:HOSTNAME)
WHERE other.name <> "paypal-acc.com"
RETURN ns.name AS nameserver, other.name AS related_domain
LIMIT 30
Read the nameserver before you read the siblings. A boutique operator's own nameservers cluster tightly; a bulk DNS provider's serve every customer it has, so the same query returns a book of unrelated domains. Narrow those hits by intersecting them with the registrant and co-hosting pivots above.
Certificate Transparency — catch lookalikes the moment they get a cert
What the graph does: SEEN_IN_CT links a hostname to its Certificate Transparency observations. A phishing site needs a valid cert for a convincing HTTPS padlock — CT often sees the lookalike before DNS or any feed does, so check every suspect from your variant list.
// CT observations for a suspect lookalike
MATCH (h:HOSTNAME {name: "koinbase.com"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.fqdn AS observed_name, ct.certCount AS certs,
ct.wildcard AS wildcard, ct.firstSeen AS first_seen, ct.lastSeen AS last_seen
ORDER BY ct.lastSeen DESC
LIMIT 20
Sample output (captured 2026-09-03):
[
{"observed_name": "*.koinbase.com", "certs": 2, "wildcard": true, "first_seen": 1787880596150, "last_seen": 1787880618601},
{"observed_name": "koinbase.com", "certs": 2, "wildcard": false, "first_seen": 1787880596150, "last_seen": 1787880618601}
]
firstSeen/lastSeenare epoch-millis timestamps, so a freshly issued cert on a brand-new lookalike is a strong early signal — even before the page goes live. Awildcard: truerow is worth its own note: the cert already covers every subdomain, so the operator can stand uplogin.,account.and friends without a second issuance. To find the lookalikes in your variant list that have pulled a certificate, chain the generator into this hop:CALL whisper.variants("microsoft.com") YIELD variant, exists WHERE exists WITH variant LIMIT 200 MATCH (h:HOSTNAME {name: variant})-[:SEEN_IN_CT]->(ct) RETURN variant, count(ct).
Empty result: Certificate Transparency coverage is partial. github.com has none. paypal.com has none. A zero-row result here means Whisper holds no CT observation for that host. It never means the host has a clean certificate history. If certificate history is load-bearing for your decision, query a CT log directly — crt.sh or the Google CT API — and come back with the hostnames you find.
Threat-feed status — already tracked?
What the graph does: check whether a suspect is already listed across the 134 feeds / 32 categories. LISTED_IN is a synthesized edge — write it as one explicit hop; don't bare-scan FEED_SOURCE.
// Which feeds already track this lookalike?
MATCH (h:HOSTNAME {name: "paypal-acc.com"})-[:LISTED_IN]->(f:FEED_SOURCE)
RETURN h.name AS domain, collect(f.displayName) AS feeds
LIMIT 5
Sample output (captured 2026-09-02):
[{"domain": "paypal-acc.com", "feeds": ["Hagezi TIF Full"]}]
Empty doesn't mean clean — it means no feed has caught up yet. That's exactly when the infrastructure pivots above (co-hosting, registrant, nameserver, CT) carry the case.
FEED_SOURCE.nameis the slug (hagezi-tif-full) anddisplayNamethe human name; pivot each feed throughBELONGS_TOto aCATEGORY(c.idisphishing,malware,tor, …) when the incident summary needs the category rather than the source.
Phishing kits
A phishing kit leaves the same URL structure on every host it is deployed to, so one distinctive path is a campaign fingerprint you can expand into the whole set of domains serving it. The graph stores those fingerprints as URL nodes (kind = "url-kit-fingerprint") with the path, how rare its segments are, and how many hosts and separate apexes carry it; LINKS_TO runs from the URL node to each host serving the path. This runs the lookalike loop in reverse: from the kit to the domains, instead of from the brand to the variants.
Which phishing kits leave the most distinctive path?
You want the campaigns, not the URLs. A kit deployed across a hundred throwaway domains leaves an identical path on every one, and the more unusual that path is, the more certain it is that two hosts serving it run the same kit. Rarity decides which paths are worth expanding.
// Phishing-kit paths ranked by how rare the URL structure is
MATCH (u:URL)
WHERE u.kind = "url-kit-fingerprint"
RETURN u.id AS kit_id, u.path AS kit_path, u.segmentRarity AS rarity,
u.hostnameCount AS member_hosts, u.apexCount AS member_apexes
ORDER BY u.segmentRarity DESC
LIMIT 15
Returns: kit_id, kit_path, rarity, member_hosts, member_apexes
Sample output (captured 2026-09-28):
[
{"kit_id": "6485183463413515491", "kit_path": "/microsoft", "rarity": 100, "member_hosts": 8, "member_apexes": 8},
{"kit_id": "6485183463413514565", "kit_path": "/anmeldung.php", "rarity": 100, "member_hosts": 5, "member_apexes": 5},
{"kit_id": "6485183463413515935", "kit_path": "/secure/document/authentication", "rarity": 100, "member_hosts": 16, "member_apexes": 16}
]
Costs: a filtered scan over the kit catalogue, which is small enough to sort whole; no indicator anchor, because the kit is the anchor; keep the LIMIT. Every row above is tied at rarity: 100 with no tiebreaker in the ORDER BY, so which three come back first — and their kit_ids — can change between runs even when the underlying kits do not; see the caveat below.
Read
rarityandmember_apexestogether, because that pairing is the finding. A path at rarity 100 appearing on sixteen hosts across sixteen distinct apexes is one operator deploying one kit to sixteen throwaway domains — sixteen hosts under one apex would just be a website. A low-rarity path like/loginmatches thousands of unrelated sites and tells you nothing. Sort by rarity to get campaigns; sort byhostnameCountto get scale, which surfaces the high-volume commodity paths instead. Carrykit_pathforward: it is the anchor for the next recipe.
From here, → Which domains serve one phishing kit?
Which domains serve one phishing kit?
You have one kit and want every domain currently serving it — the takedown list, or the block list, depending on who is asking. Anchor on the kit_path from the ranking recipe rather than inventing one.
// The hosts serving one phishing kit
MATCH (u:URL {name: "/ing"})-[:LINKS_TO]->(h:HOSTNAME)
OPTIONAL MATCH (h)-[:RESOLVES_TO]->(ip:IPV4)-[:BELONGS_TO]->(p:PREFIX)
RETURN DISTINCT h.name AS member_host, p.name AS prefix,
p.threatLevel AS prefix_threat
LIMIT 20
Returns: member_host, prefix, prefix_threat
Sample output (captured 2026-09-28, path /ing):
[
{"member_host": "blasgroup.com", "prefix": null, "prefix_threat": null},
{"member_host": "nice-engelbart.64-225-70-234.plesk.page", "prefix": "64.225.64.0/20", "prefix_threat": "LOW"},
{"member_host": "portal.betelhost.com.br", "prefix": "172.82.128.0/22", "prefix_threat": "LOW"},
{"member_host": "trontz.net", "prefix": "162.241.224.0/21", "prefix_threat": "NONE"},
{"member_host": "loving-darwin.109-71-253-24.plesk.page", "prefix": "109.71.253.0/24", "prefix_threat": "NONE"}
]
Costs: one indexed anchor on the kit and one LINKS_TO hop to its members, with an optional two-hop resolution arm; keep that arm OPTIONAL, because freshly registered phishing domains frequently have no observed resolution yet and a plain MATCH would drop exactly the newest rows. The ranking's member_hosts is a stored count and can run ahead of the live member list, so expect fewer rows here than it promises.
Empty result: no rows means no host serves that path in the current catalogue. Anchor on the path, never on kit_id: the path is the indexed spelling and it stays the same between runs, while a kit_id can change and name a different kit, so an id from an old report is not proof of anything. Null prefix columns on a row that did come back mean the host has not been seen resolving, which for a day-old phishing domain is normal.
The naming pattern in the output usually says as much as the infrastructure does: a run of
customer…,…validation…,datenabgleich…names serving one German-language login path is a single operator with a domain generator, whichever networks they land on this week. Run the member list throughexplain()and the lookalike triage above — the members nothing has flagged yet are your early warning.
From here, → Which kits are spread across the most separate domains?
Which kits are spread across the most separate domains?
You want the widest-spread campaigns first: the distinctive paths that appear under the largest number of separate registrable domains, which is the shape of a kit being resold or run at scale. That ordering decides where a takedown effort buys the most.
// Distinctive kit paths spanning many separate apexes
MATCH (u:URL)
WHERE u.kind = "url-kit-fingerprint" AND u.apexCount >= 8
RETURN u.path AS kit_path, u.segmentRarity AS rarity,
u.hostnameCount AS member_hosts, u.apexCount AS member_apexes
ORDER BY u.segmentRarity DESC, u.apexCount DESC
LIMIT 10
Returns: kit_path, rarity, member_hosts, member_apexes
Sample output (captured 2026-09-02):
[
{"kit_path": "/s/anmeldung.php", "rarity": 100, "member_hosts": 114, "member_apexes": 113},
{"kit_path": "/secure/document/authentication", "rarity": 100, "member_hosts": 16, "member_apexes": 16},
{"kit_path": "/authentication", "rarity": 100, "member_hosts": 14, "member_apexes": 14}
]
Costs: the same catalogue scan as the ranking recipe with one extra filter; the apexCount floor is yours to tune — lower it to see smaller campaigns, raise it to see only resold kits.
A near-1:1 ratio between
member_hostsandmember_apexesis the signature worth chasing — one host per domain, which is what disposable phishing infrastructure looks like. Where the two numbers diverge sharply you are usually looking at a shared platform rather than a campaign. Addu.id AS kit_idto theRETURNand feed the widest path into the expansion recipe above.
From here, → Which domains serve one phishing kit? for the domain list, then Scored verdict for a single suspect domain on each member.
Advanced
Track the kit across rotating domains via TLS fingerprint
What the graph does: EMITS_TLS_FINGERPRINT links an IP to its JA3/JARM fingerprint. A phishing kit's TLS stack fingerprints consistently even as its domains churn — pivot from a known-bad IP's fingerprint to every other IP presenting the same one.
// IPs sharing a TLS fingerprint with a threat-listed lookalike's IP
MATCH (h:HOSTNAME {name: "nelnetbanks.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH ip LIMIT 5
MATCH (ip)-[:EMITS_TLS_FINGERPRINT]->(fp:TLS_FINGERPRINT)<-[:EMITS_TLS_FINGERPRINT]-(other:IPV4)
WHERE other <> ip
RETURN fp.name AS fingerprint, other.name AS same_fingerprint_ip
LIMIT 25
Fold the resulting IPs back into the co-hosting recipe to surface domains the kit is serving under names you haven't enumerated yet — but weigh the fingerprint first. A JARM shared by a handful of hosts is a lead; one shared by hundreds is a stock TLS stack that thousands of unrelated servers also present, and it tells you about the software, not the operator.
CALL whisper.lookupTlsFingerprint("<hash>")names the client or server build behind a common hash before you cluster on it.
Empty result: the graph holds 271 IP-to-fingerprint observations in total, so expect no match on almost any indicator. A zero-row result here means Whisper holds no observation — not that the host shares no infrastructure.
Backlink check — who references your real domain
What the graph does: a pilot sample of the web hyperlink graph (LINKS_TO, HOSTNAME→HOSTNAME) is queryable next to DNS and WHOIS. Find sites linking to your official domain to spot scrapers, clones, and parked pages that embed your assets — as leads, not as a census.
// External sites linking to your official domain
MATCH (source:HOSTNAME)-[:LINKS_TO]->(h:HOSTNAME {name: "paypal.com"})
RETURN source.name AS linking_site
LIMIT 20
Legitimate backlinks are noisy and expected. Filter for value by joining the source to a verdict —
MATCH (source)-[:LISTED_IN]->(:FEED_SOURCE)— or by feedinglinking_sitethroughexplain().
Run it from an AI agent
Every recipe here also runs from an MCP client. Point Claude, Cursor, or any MCP-capable assistant at https://mcp.whisper.security and it runs these traversals itself, mid-conversation: run_workflow executes the Typosquat Scanner end to end, explain_indicator returns a sourced verdict for any hit, and query runs the cluster-expansion Cypher above. The agent does the pivoting; you read the answer. See AI & Agents and the MCP setup guide.
# The whole loop from the command line: generate, then triage
curl -s https://graph.whisper.security/api/query \
-H "Content-Type: application/json" \
-H "X-API-Key: $WHISPER_API_KEY" \
-H "User-Agent: brand-watch/1.0" \
-d '{"query":"CALL whisper.variants(\"paypal.com\") YIELD variant, method, exists WHERE exists WITH variant, method LIMIT 50 MATCH (h:HOSTNAME {name: variant})-[:RESOLVES_TO]->(ip:IPV4) RETURN variant, method, ip.name AS resolves_to, ip.verdictLevel AS verdict, ip.verdictBlocking AS blocking ORDER BY blocking DESC LIMIT 25"}'
Next: Cross-Layer Patterns for the pivots these recipes share with other use cases, or Threat Feeds & Categories to see which sources back your verdicts.