Skip to content
Recipes
Skip navigation
Recipes
View as Markdown

Lookalike Hunting

Find typosquats, lookalike domains, fraudulent registrations, credential-theft infrastructure, and phishing kits reused across domains.

Published Last updated

On this page (21)

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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: true means registered, not malicious. A parked typosquat and a live phishing page both register. The next two recipes separate them. Pass false as 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 one run_workflow call.

Read coverage before band. 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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: true row is takedown-ready evidence. The NONE/LOW rows 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 through explain() 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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/NONE result 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

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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 on STARTS WITH (not CONTAINS) 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 the LIMIT. To catch the keyword mid-name too (login-paypal.top), run the Brand-impersonation TLD sweep workflow, which combines several anchored passes, or use CALL 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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() and explain() — 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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_domain against your full brand watchlist — one kit deployment often spoofs several brands at once. Bound the IP fan-out with WITH ... LIMIT so 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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→ORGANIZATION for the org-level pivot (and SAME_ORG_AS to fold spelling variants of one company together), or HAS_PHONE when the email is privacy-protected. For the registration-age signal (a fresh createDate on a privacy registrar is the classic burner tell), use whisper.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.

cypher · runnablegraph.whisper.securitySign in to run
// 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.

cypher
// 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):

json
[
  {"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/lastSeen are epoch-millis timestamps, so a freshly issued cert on a brand-new lookalike is a strong early signal — even before the page goes live. A wildcard: true row is worth its own note: the cert already covers every subdomain, so the operator can stand up login., 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[{"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.name is the slug (hagezi-tif-full) and displayName the human name; pivot each feed through BELONGS_TO to a CATEGORY (c.id is phishing, 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 rarity and member_apexes together, 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 /login matches thousands of unrelated sites and tells you nothing. Sort by rarity to get campaigns; sort by hostnameCount to get scale, which surfaces the high-volume commodity paths instead. Carry kit_path forward: 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 through explain() 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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_hosts and member_apexes is 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. Add u.id AS kit_id to the RETURN and 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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.

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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 feeding linking_site through explain().

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.

bash
# 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.