Campaign Pivoting
Turn one indicator into the whole campaign: co-tenancy, registrant, nameserver, TLS and CT pivots, and the actor/ATT&CK reference layer.
On this page (35)
- Quick triage
- Get the scored evidence chain for any indicator
- Pivot one indicator into the whole campaign
- Co-tenancy: every domain on the same IP
- Registrant pivot: every domain sharing a WHOIS email
- Which company is really behind a messy registrant string?
- Shared-nameserver siblings
- One query, three pivots: the campaign in a single traversal
- Typosquats & lookalikes
- Actor & ATT&CK layer
- Which actor is behind this alias?
- What techniques does a named actor use?
- Which tactic vocabulary is the graph built on?
- Which other actors share a technique?
- Which malware family is this host tagged with, and what else carries the tag?
- Has this indicator been attributed to a named actor?
- Egress & fingerprint pivots
- Identify Tor-exit infrastructure that survives IP rotation
- Track C2 across changing domains with a TLS fingerprint
- Discover subdomains and SANs from Certificate Transparency
- Separate the operator from the WHOIS owner
- Infrastructure ownership & identity
- Who hosts this domain, on whose network
- Infrastructure signals
- Which networks does the graph flag as bulletproof hosting?
- What address space does a bulletproof network route?
- Which hosts are flagged for wildcard DNS?
- How many siblings under the same apex share this IP?
- History & change tracking
- Watch how a domain's registration moved over time
- De-cloak the real origin behind a CDN
- Batch & evidence collection
- Triage a list of indicators in one request
- Inbound web links: who points at the target
- Where to go next
Campaign Pivoting Documentation
You hold one indicator — a phishing domain, a C2 IP, a suspicious nameserver. These recipes take you to the rest of the campaign: every sibling domain, the shared registrant folded to one company, the co-tenant hosts, the operator's infrastructure habits, the actor public reporting names, and how it all moved over time. WhisperGraph pre-joins DNS, WHOIS/RDAP, BGP, threat verdicts, Tor/TLS egress, Certificate Transparency and the ATT&CK knowledge base into one surface, so each pivot is a single hop and the whole campaign falls out of one traversal.
Every recipe below is copy-paste against the Cypher/REST endpoint at https://graph.whisper.security/api/query. New to the graph? Start with Getting Started, keep the Graph Schema and Procedures open, and pull more patterns from Cross-Layer Patterns.
Run it live: Investigate an Indicator · Digital Infrastructure Mapping · Build the takedown evidence package — each opens with a live result you can rerun on your own indicator.
Key concepts: Co-hosted domains · Infrastructure pivoting · Passive DNS · C2 infrastructure · Bulletproof hosting · MITRE ATT&CK.
Quick triage
For a full triage workflow (verdict, feeds, posture, escalation), see Indicator Triage (SOC): open on the reconciled verdict, the one blocking-aware answer that decides whether this indicator is worth pivoting on at all. The read below is the minimum you need on top of it.
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.
Get the scored evidence chain for any indicator
What the graph does: explain returns the arithmetic — every contributing feed, its weight, and first/last-seen — for an IP, IPv6, hostname, ASN, or CIDR.
CALL explain("paypal-account-verify.com")
YIELD indicator, type, found, score, level, explanation, factors, sources
RETURN indicator, type, found, score, level, explanation, factors, sources
Tip:
explainworks on a whole ASN or CIDR too —CALL explain("AS_number")quantifies a malicious neighborhood. A clean result means "not listed", not "safe": no data is not the same as known clean.
Pivot one indicator into the whole campaign
Co-tenancy: every domain on the same IP
Try it on a live IP — every hostname currently resolving to it:
What the graph does: one hop in, one hop back out. RESOLVES_TO is HOSTNAME → IP, so the sibling hosts hang off the reverse arrow.
// Domains co-hosted with a suspicious host on the same IP
MATCH (seed:HOSTNAME {name: "payapl.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH seed, ip LIMIT 10
MATCH (ip)<-[:RESOLVES_TO]-(sibling:HOSTNAME)
WHERE sibling <> seed
RETURN ip.name AS shared_ip, sibling.name AS co_tenant
LIMIT 25
Tip: one shared-hosting IP can carry thousands of unrelated tenants, so the
WITH ... LIMIT 10bounds the IP fan-out first. Co-tenancy on a big provider doesn't by itself imply a relationship — treat it as a lead, then confirm with a registrant or nameserver pivot, or measure how many siblings under the same apex share the address (How many siblings under the same apex share this IP?). If the seed no longer resolves, pullCALL whisper.history.whois(...)for what it used to be.
Registrant pivot: every domain sharing a WHOIS email
What the graph does: WHOIS is per-domain, but the registrant email is a first-class node. Pivot through it to the actor's whole portfolio. HAS_EMAIL is HOSTNAME → EMAIL, so the siblings are on the reverse arrow.
// All domains registered with the same WHOIS contact email
MATCH (seed:HOSTNAME {name: "cloudflare.com"})-[:HAS_EMAIL]->(e:EMAIL)
WITH seed, e LIMIT 5
MATCH (e)<-[:HAS_EMAIL]-(sibling:HOSTNAME)
WHERE sibling <> seed
RETURN e.name AS shared_email, sibling.name AS related_domain
LIMIT 25
Tip: registrar privacy services replace the real registrant with a proxy address (
domains@cloudflare.com,contact@privacyguardian.org). A proxy email clusters by registrar, not by actor — confirm the email isn't a privacy service before drawing attribution conclusions. To keep only the siblings nothing has flagged yet, addAND coalesce(sibling.isThreat, false) = falseto theWHERE: that inverts the pivot from confirming what you know into surfacing what nobody has judged.
Which company is really behind a messy registrant string?
Registrant organisation strings come out of WHOIS exactly as the registrar stored them: truncated, lower-cased, comma-terminated, spelled three ways for one company. Clustering on the raw string splits one owner into several. The graph carries a canonical link that folds the variants together, so a portfolio view holds.
// The raw registrant string, and the company it actually is
MATCH (h:HOSTNAME {name: "github.com"})-[:REGISTERED_BY]->(o:ORGANIZATION)
OPTIONAL MATCH (o)-[:SAME_ORG_AS]->(c:ORGANIZATION)
RETURN o.name AS raw_registrant, collect(DISTINCT c.name) AS canonical
LIMIT 5
Returns: raw_registrant, canonical
Sample output (captured 2026-09-02):
[
{"raw_registrant": "github hostmaster", "canonical": ["GitHub, Inc."]},
{"raw_registrant": "github,", "canonical": ["GitHub, Inc."]}
]
Costs: one anchored hop plus an optional canonical hop; most organisation strings have no canonical twin, so the second hop must stay OPTIONAL or the raw value disappears with it; an empty canonical list means the string is already clean or simply unmapped, never that the owner is unknown.
Tip: cluster on the canonical name and keep the raw string as evidence — it is the difference between "four registrants" and "one company, four spellings". The same edge works from the address side, so an IP range resolves to its registered holder rather than to whatever string the RIR record carried:
MATCH (ip:IPV4 {name: "1.1.1.1"})-[:BELONGS_TO]->(:REGISTERED_PREFIX)-[:REGISTERED_BY]->(o:ORGANIZATION) OPTIONAL MATCH (o)-[:SAME_ORG_AS]->(c:ORGANIZATION).
From here, → Registrant pivot: every domain sharing a WHOIS email for the contact-level pivot the organisation confirms.
Shared-nameserver siblings
What the graph does: NAMESERVER_FOR points server → domain, so a custom nameserver fans straight out to its whole client list — a strong clustering signal when the actor runs their own DNS.
// Every domain delegating DNS to a specific nameserver
MATCH (ns:HOSTNAME {name: "ns1.dsredirection.com"})-[:NAMESERVER_FOR]->(domain:HOSTNAME)
RETURN domain.name AS delegated_domain
LIMIT 25
Tip: filter out the giants. Domains on
ns1.google.comor Cloudflare's nameservers tell you nothing, and a parking or redirection service like the one seeded above clusters by service, not by actor — it is a starting shape, not a finding. A private or oddly-named nameserver shared across a handful of suspicious domains is the find. When you start from a domain rather than a nameserver, bound both sides before you aggregate:WITH ns LIMIT 2after the reverse hop, thenWITH ns, sib LIMIT 200before thecollect. A trailingLIMITon theRETURNdoes not save you — the engine still walks every domain on the nameserver first.
One query, three pivots: the campaign in a single traversal
What the graph does: co-tenancy, shared registrant, and shared nameserver are three edge types on the same node. UNION them into one campaign view, tagged by pivot type.
// Blast radius: union co-tenants, registrant siblings, and NS siblings
MATCH (seed:HOSTNAME {name: "paypal-account-verify.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH seed, ip LIMIT 10
MATCH (ip)<-[:RESOLVES_TO]-(s:HOSTNAME) WHERE s <> seed
RETURN "co-tenant IP" AS pivot, s.name AS related, ip.name AS via
LIMIT 25
UNION
MATCH (seed:HOSTNAME {name: "paypal-account-verify.com"})-[:HAS_EMAIL]->(e:EMAIL)
WITH seed, e LIMIT 5
MATCH (e)<-[:HAS_EMAIL]-(s:HOSTNAME) WHERE s <> seed
RETURN "registrant email" AS pivot, s.name AS related, e.name AS via
LIMIT 25
UNION
MATCH (seed:HOSTNAME {name: "paypal-account-verify.com"})<-[:NAMESERVER_FOR]-(ns:HOSTNAME)
WITH ns LIMIT 5
MATCH (ns)-[:NAMESERVER_FOR]->(s:HOSTNAME)
WHERE s.name <> "paypal-account-verify.com"
RETURN "shared nameserver" AS pivot, s.name AS related, ns.name AS via
LIMIT 25
Tip: the
pivotcolumn tells your analyst why each host joined the cluster — co-tenancy is the weakest signal (shared hosting), shared registrant and private nameserver are stronger. Feed therelatedlist back throughexplain()to rank the cluster by verdict. For a list you already hold, run the clustering the other way:UNWINDthe names, match each to itsWITH e.name AS registrant, collect(DISTINCT h.name) AS members WHERE size(members) > 1— thesizefilter drops every registrant that explains only one domain and leaves just the links.
Typosquats & lookalikes
Lookalike domains are pivot seeds like any other: generate the registered set with the typosquat recipe, then run each hit back through the co-tenancy, registrant and nameserver pivots above. exists: true means registered, not malicious — a parked typo and an active phishing kit look identical at that stage, so take the verdict from explain() first and cluster only the flagged ones. For the full brand playbook (anchored prefix sweeps, TLD sweeps, parked-vs-weaponized, phishing-kit fingerprints, takedown evidence), see Lookalike Hunting.
Actor & ATT&CK layer
WhisperGraph carries the MITRE ATT&CK knowledge base as graph structure — 9,630
USES_TECHNIQUEedges fromACTORtoATTACK_PATTERNand 881USES_TACTICedges, across 1,986 actors and 721 techniques. This is a curated reference layer, not Whisper's own attribution. It reflects what public reporting has mapped, not what Whisper observed. Two edges reach from it to live infrastructure, and both are sparse by design:TAGGED_AS(138,972 edges) is a malware-family or campaign label, andATTRIBUTED_TO(345 edges) is a published attribution to a named group. An empty result on either means nobody has published one, never that the indicator is unattributed in the world.
The ACTOR and ATTACK_PATTERN nodes sit in the same graph as the infrastructure you just mapped, so a campaign cluster reaches them in one hop — as a lead about the reporting, never as a name on the infrastructure. Actor names and alias values are exact-match and case-sensitive, so resolve the name first and pivot second.
Which actor is behind this alias?
The report on your desk says "Leviathan", your feed says "Kryptonite Panda", a colleague says "APT40". They are one group, and a name search on any one of them finds only that one. Search the alias set instead, then pivot on whichever canonical name comes back.
// Which canonical actor does this alias belong to?
MATCH (a:ACTOR)
WHERE "Leviathan" IN a.aliases
RETURN DISTINCT a.name AS canonical_actor, a.aliases AS aliases
LIMIT 5
Returns: canonical_actor, aliases
Sample output (captured 2026-09-02):
[
{"canonical_actor": "APT40", "aliases": ["TEMP.Periscope", "TEMP.Jumper", "Leviathan", "BRONZE MOHAWK", "GADOLINIUM", "KRYPTONITE PANDA", "G0065", "ATK29", "TA423", "Red Ladon", "ITG09", "MUDCARP", "ISLANDDREAMS", "Gingham Typhoon", "ISLAND CASTLE"]},
{"canonical_actor": "Leviathan", "aliases": ["Leviathan", "MUDCARP", "Kryptonite Panda", "Gadolinium", "BRONZE MOHAWK", "TEMP.Jumper", "APT40", "TEMP.Periscope", "Gingham Typhoon"]}
]
Costs: a filter over a small catalogue with no traversal; the alias value is matched exactly ("Leviathan" finds it, "leviathan" does not); more than one node can legitimately answer, because different sources canonicalise the same group under different primary names.
Tip: take the aliases from whichever row you match and search on the widest set rather than assuming the first row is authoritative. When you only have a partial name,
MATCH (a:ACTOR) WHERE a.name STARTS WITH "Lazarus" RETURN a.nameis indexed and cheap, and tells you the exact capitalisation the graph uses. If a name comes back more than once, use the row whosedescriptionmatches the group you mean.
From here, → What techniques does a named actor use?
What techniques does a named actor use?
You are profiling an adversary and want its technique set from ATT&CK in one query, ready for a report or a detection-coverage review. The set tells you which of your controls the group is known to test.
// An actor's MITRE ATT&CK techniques
MATCH (a:ACTOR {name: "APT28"})-[:USES_TECHNIQUE]->(p:ATTACK_PATTERN)
RETURN a.name AS actor, collect(DISTINCT p.name)[0..12] AS techniques
LIMIT 1
Returns: actor, techniques
Sample output (captured 2026-09-02):
[{"actor": "APT28", "techniques": ["Junk Data", "OS Credential Dumping", "LSASS Memory", "NTDS", "Data from Local System", "Rootkit", "SMB/Windows Admin Shares", "Data from Removable Media", "Encrypted/Encoded File", "Data Transfer Size Limits", "Masquerading", "Match Legitimate Resource Name or Location"]}]
Costs: one indexed anchor and one hop, bounded by the slice; actor names are case-sensitive in their canonical spelling (APT28, Sandman APT), so an empty result usually means the name, not the absence of the actor.
Tip: the label is
:ATTACK_PATTERN, never:TECHNIQUE— one label carries techniques and tactics, separated bykind. Anchor a single technique by its T-number and climb to the tactic it serves:MATCH (t:ATTACK_PATTERN {name: "T1003"})-[:USES_TACTIC]->(tac:ATTACK_PATTERN) RETURN t.name, tac.id, tac.name. The name index knows each technique by its T-number as well as its readable name, so{name: "T1003"}is an indexed lookup even thought.namereturns "OS Credential Dumping" and the T-number itself ist.id;{id: "T1003"}returns the same row by scanning every attack pattern.
From here, → Which other actors share a technique? to widen the candidate set, or Which tactic vocabulary is the graph built on? before you map report language onto it.
Which tactic vocabulary is the graph built on?
You are mapping report language onto ATT&CK. The framework renumbers and renames tactics between releases, and a mapping written against last year's names will not join cleanly. Read the vocabulary the graph speaks before you write the mapping.
// The tactic vocabulary this graph is built on
MATCH (t:ATTACK_PATTERN {kind: "tactic"})
RETURN t.id AS tactic_id, t.name AS tactic
ORDER BY tactic_id
LIMIT 20
Returns: tactic_id, tactic
Sample output (captured 2026-09-02):
[
{"tactic_id": "TA0001", "tactic": "Initial Access"},
{"tactic_id": "TA0002", "tactic": "Execution"},
{"tactic_id": "TA0003", "tactic": "Persistence"}
]
Costs: a filter over a small catalogue, no traversal; the set is short enough to read whole, so the LIMIT is a formality rather than a bound.
Tip: watch for
TA0005reading Stealth alongsideTA0112Defense Impairment — that pairing tells you the graph is built on the release that split the old Defense Evasion tactic in two. Detection content that still maps to "Defense Evasion" now lands on one of those two.
From here, → What techniques does a named actor use?
Which other actors share a technique?
A technique stood out in your investigation and you want to know who else is known to use it. On a rare technique this widens the candidate set behind an intrusion; on a common one it tells you the technique will not narrow anything.
// Pivot from an actor's techniques to other actors using them
MATCH (a:ACTOR {name: "APT28"})-[:USES_TECHNIQUE]->(p:ATTACK_PATTERN)
WITH p LIMIT 3
MATCH (p)<-[:USES_TECHNIQUE]-(other:ACTOR)
RETURN p.name AS technique, collect(DISTINCT other.name)[0..8] AS actors
LIMIT 5
Returns: technique, actors
Sample output (captured 2026-09-02):
[
{"technique": "Junk Data", "actors": ["APT28"]},
{"technique": "OS Credential Dumping", "actors": ["Sowbug", "APT39", "APT32", "APT28", "Suckfly", "BlackByte", "Tonto Team", "Leviathan"]}
]
Costs: one anchored hop out, one bounded hop back; the WITH p LIMIT 3 bounds the technique side, because credential dumping and discovery are shared by dozens of actors and would fan out without it.
Tip: the pivot earns its keep on rarer, more distinctive techniques — favour the unusual ones. A technique that returns only the actor you started from (as
Junk Datadoes above) is a distinctive fingerprint of that group; one that returns eight is background noise.
From here, → Which actor is behind this alias? to fold the returned names onto their canonical groups.
Which malware family is this host tagged with, and what else carries the tag?
You hold a domain from an alert and want the other direction: not what an actor uses, but which family this infrastructure is associated with and which other hosts share the label. A shared tag is a strong clustering signal, so this is where a campaign map starts.
// Malware-family tag on a host, and the other hosts carrying the same tag
MATCH (n:HOSTNAME {name: "command-center.cc"})-[:TAGGED_AS]->(t:THREAT_TAG)
WITH n, t LIMIT 5
MATCH (t)<-[:TAGGED_AS]-(sib)
WHERE sib <> n
WITH n, t, sib LIMIT 200
RETURN t.name AS tag, n.verdictLevel AS verdict,
collect(DISTINCT sib.name)[0..10] AS same_tag, count(sib) AS shown
LIMIT 5
Returns: tag, verdict, same_tag, shown
Sample output (captured 2026-09-26):
[{"tag": "win.count_loader", "verdict": "CRITICAL", "same_tag": ["azure-s3-bucket.cc", "communicationfirewall-security.cc", "fileless-market.cc", "fileless-storage-s3.cc", "files-storage.cc", "filezilla.cc", "holiday-forever.cc", "memory-protection-layer1.cc", "memory-protection-layer2.cc", "network-defender.cc"], "shown": 22}]
The sibling names are worth a second look: they sit on the same .cc suffix and imitate cloud storage, file transfer and security products. A family tag plus a shared naming pattern is a stronger cluster than either signal alone.
Costs: one anchored hop to the tag and one bounded hop back; both WITH ... LIMITs sit before the aggregation so a widely used tag cannot run away; shown tells you whether the sibling set hit the bound.
Empty result: TAGGED_AS is a coverage-scoped layer. Zero rows means no source has labelled this host with a family, never that it belongs to none. Tag values carry their namespace (win.phorpiex, misp:...), so match on the full string when you anchor on a tag instead of a host. To find live examples to work from, drop the anchor: MATCH (n:HOSTNAME)-[:TAGGED_AS]->(t:THREAT_TAG) RETURN n.name, t.name LIMIT 15.
Tip: a tag is a family association, not an attribution — two hosts sharing
win.phorpiexare associated with the same malware label, which is a reason to cluster them, not a name on the operator. Run thesame_taglist through the co-tenancy and registrant pivots above to see whether the infrastructure agrees with the label.
From here, → Has this indicator been attributed to a named actor?
Has this indicator been attributed to a named actor?
Attribution to a named group runs over its own edge, separate from the family tag, and it is the claim a reader will challenge. Read it as a published attribution you can cite, with its source, and never infer it from a tag or a technique match.
// Named-adversary attribution carried on the indicator
MATCH (n:IPV4 {name: "194.87.93.153"})-[:ATTRIBUTED_TO]->(a:ACTOR)
RETURN n.name AS indicator, a.name AS actor, n.verdictLevel AS verdict
LIMIT 5
Returns: indicator, actor, verdict
Sample output (captured 2026-09-02):
[{"indicator": "194.87.93.153", "actor": "Head Mare", "verdict": "NONE"}]
Costs: one indexed anchor and one hop over a very sparse edge; the same shape runs from the actor down to its infrastructure (MATCH (n)-[:ATTRIBUTED_TO]->(a:ACTOR {name: "Head Mare"}) RETURN labels(n)[0], n.name).
Empty result: ATTRIBUTED_TO is deliberately sparse (345 edges). Zero rows means nobody has published an attribution for this indicator, never that it is unattributed in the world, and never that it is clean — the verdict is a separate read. To see what is attributed right now, drop the anchor: MATCH (n)-[:ATTRIBUTED_TO]->(a:ACTOR) RETURN labels(n)[0], n.name, a.name LIMIT 15.
Tip: notice the
verdictcolumn readsNONEon an attributed address — attribution and reputation are different claims from different sources, and they routinely disagree. Cite the attribution, then runexplain()for the feed evidence, and let the report carry both.
From here, → What techniques does a named actor use? once you have a canonical name to profile.
Egress & fingerprint pivots
Identify Tor-exit infrastructure that survives IP rotation
What the graph does: an exit node's IP changes; the relay identity (its fingerprint) doesn't. OPERATES_EXIT_NODE ties an IP to that stable TOR_RELAY identity, so you can confirm an IP is a Tor exit and recover the relay behind it.
// Is this IP a Tor exit, and which relay identity operates it?
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:OPERATES_EXIT_NODE]->(relay:TOR_RELAY)
RETURN ip.name AS ip, ip.isTor AS flagged_tor, relay.name AS relay_fingerprint
LIMIT 10
Tip: Tor egress isn't malicious by itself, but it changes how you weight other signals. Cross-check
ip.isAnonymizerandip.isProxyon the same node.
Track C2 across changing domains with a TLS fingerprint
What the graph does: when an actor rotates domains and IPs, the only stable thread is how the server speaks TLS. EMITS_TLS_FINGERPRINT ties an IP to that JA3/JARM fingerprint. Find the fingerprint your seed C2 emits, then pivot to every other IP emitting the same one.
// Other IPs emitting the same JA3/JARM fingerprint as a known C2 IP
MATCH (seed:IPV4 {name: "144.217.207.19"})-[:EMITS_TLS_FINGERPRINT]->(fp:TLS_FINGERPRINT)
WITH fp LIMIT 5
MATCH (fp)<-[:EMITS_TLS_FINGERPRINT]-(other:IPV4)
WHERE other.name <> "144.217.207.19"
RETURN fp.name AS fingerprint, other.name AS same_stack_ip, other.verdictLevel AS level
LIMIT 25
Empty result: TLS-fingerprint coverage is partial, so expect most indicators to return nothing. A zero-row result here means Whisper holds no observation — not that the host shares no infrastructure.
Tip: common server stacks share fingerprints across millions of hosts, so the pivot is only meaningful when the JARM is distinctive — typically a bespoke C2 framework. Rank hits by
verdictLevelto surface the ones already flagged. Fingerprint names carry their scheme as a prefix (jarm:,ja3:); to learn what a hash is before you cluster on it,CALL whisper.lookupTlsFingerprint("<hash>")names the client build behind common ones.
Discover subdomains and SANs from Certificate Transparency
What the graph does: an actor's staging subdomains may never resolve publicly, but they leak into CT logs the moment a cert is issued. SEEN_IN_CT connects a host to its Certificate Transparency observations, surfacing names that DNS alone would miss. A wildcard observation is the loudest of these: it proves a cert covers subdomains the actor never had to publish.
// CT observations for a domain — surfaces SANs / staging subdomains
MATCH (h:HOSTNAME {name: "koinbase.com"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.name AS ct_observation,
ct.wildcard AS covers_subdomains,
ct.certCount AS certificates
LIMIT 25
Sample output (captured 2026-09-03):
[
{"ct_observation": "*.koinbase.com", "covers_subdomains": true, "certificates": 2},
{"ct_observation": "koinbase.com", "covers_subdomains": false, "certificates": 2}
]
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.
Tip: CT discovery pairs well with the campaign pivots above — a SAN found here is a new seed host. Run it back through the co-tenancy and registrant pivots to extend the cluster.
Separate the operator from the WHOIS owner
What the graph does: WHOIS says who owns a netblock; it rarely says who operates it, and attackers abuse the gap. DELEGATED_TO records the vendor actually running an address, distinct from the registrant ORGANIZATION, and it hangs off the address itself — no detour through the prefix is needed.
// Which vendor operates this address? (one of the addresses a registered paypal.com lookalike resolves to)
MATCH (ip:IPV4 {name: "52.33.207.7"})-[:DELEGATED_TO]->(v:VENDOR)
RETURN ip.name AS ip, v.name AS operated_by
LIMIT 10
Tip:
VENDORnames are lowercase slugs (aws,azure,cloudflare,sendgrid), and an address with no delegation on file returns no row — that is "unknown operator", not "self-operated". A "consumer" address delegated to a cloud vendor, or the reverse, is a mismatch worth a second look. Combine with the host → network-owner walk below for the full ownership picture.
Infrastructure ownership & identity
Who hosts this domain, on whose network
What the graph does: host → IP → prefix → ASN → network name → country is one traversal. ANNOUNCED_BY covers the IP, ROUTES reaches the ASN, HAS_NAME resolves the network name, and LOCATED_IN/HAS_COUNTRY geolocate it.
// Domain → IP → announced prefix → ASN → network name, plus country
MATCH (h:HOSTNAME {name: "payapl.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH h, ip LIMIT 5
MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(c:COUNTRY)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network, c.name AS country
LIMIT 10
Tip:
RESOLVES_TOandANNOUNCED_BYare forward edges (HOSTNAME → IP,IP → prefix);ROUTESmatches from either direction. The ASN's number lives ona.name(AS13335); the human-readable network name lives on the separateASN_NAMEnode, and the registrant company on(a)-[:REGISTERED_BY]->(:ORGANIZATION)— reverse that hop to enumerate every network registered to the same company.countrycomes from anOPTIONAL MATCH, so it is blank for any IP with no city-level geolocation on file. Hosting identity is a separate question from the threat verdict — answer the second withexplain().
Infrastructure signals
A verdict says how bad; a signal says what kind of setup. The graph attaches curated, infrastructure-level signals through HAS_SIGNAL to three kinds of node, each with its own set: prefixes carry signals such as toxic-neighborhood and prefix-age-anomaly, autonomous systems such as bulletproof-hosting, critical-infrastructure, ddos-mitigation and satellite-network, and hostnames such as hidden-primary-soa, subdomain-takeover and wildcard-dns. Anchor on the label that carries the signal you want; anchoring on the wrong one is the usual reason for an empty result. MATCH (n)-[:HAS_SIGNAL]->(s:THREAT_SIGNAL_TYPE) RETURN s.name, labels(n)[0], count(*) ORDER BY count(*) DESC LIMIT 20 is the census, and it survives the catalogue changing under you.
Which networks does the graph flag as bulletproof hosting?
You keep finding the same operators behind unrelated campaigns. Rather than rediscover them one indicator at a time, pull the list the graph itself characterises as bulletproof hosting, ranked by how much of the internet sits downstream of each, and decide which belong on a watch list.
// Networks the graph flags as bulletproof hosting, largest customer cone first
MATCH (a:ASN)-[:HAS_SIGNAL]->(:THREAT_SIGNAL_TYPE {name: "bulletproof-hosting"})
RETURN a.name AS asn, a.asRank AS as_rank, a.coneAsns AS customer_cone
ORDER BY a.coneAsns DESC
LIMIT 15
Returns: asn, as_rank, customer_cone
Sample output (captured 2026-09-02):
[
{"asn": "AS49581", "as_rank": 441, "customer_cone": 105},
{"asn": "AS31561", "as_rank": 450, "customer_cone": 102},
{"asn": "AS56584", "as_rank": 654, "customer_cone": 68}
]
Costs: one hop from a small signal catalogue back to the networks that carry it, sorted on a node property; no indicator anchor is needed, because the signal node is the anchor.
Tip:
coneAsnscounts the autonomous systems reachable through this one as a provider, so it is a rough measure of how much the operator carries for other people. A bulletproof-flagged network with a hundred ASNs in its cone is reselling, which means the abuse you find there is probably several tenants deep. Use the list as a watch list rather than a block list: the signal characterises the operator, not any individual customer.
From here, → What address space does a bulletproof network route?
What address space does a bulletproof network route?
You have a flagged operator and want its actual address blocks — the ranges to watch, hunt through, or hand to a detection engineer. The prefix pattern itself is often the tell.
// From a bulletproof-hosting ASN to the prefixes it routes
MATCH (a:ASN)-[:HAS_SIGNAL]->(:THREAT_SIGNAL_TYPE {name: "bulletproof-hosting"})
WITH a LIMIT 3
MATCH (a)-[:ROUTES]->(p:ANNOUNCED_PREFIX)
RETURN a.name AS asn, collect(p.name)[0..8] AS sample_prefixes
LIMIT 5
Returns: asn, sample_prefixes
Sample output (captured 2026-09-02):
[
{"asn": "AS197170", "sample_prefixes": ["45.153.34.0/24", "45.156.87.0/24", "85.11.167.0/24", "87.121.84.0/24", "91.92.40.0/24", "91.92.42.0/24", "91.92.47.0/24", "93.152.221.0/24"]},
{"asn": "AS197769", "sample_prefixes": ["31.57.184.0/24", "31.57.216.0/24", "91.231.222.0/24", "102.220.160.0/22", "102.220.163.0/24", "130.12.181.0/24", "130.12.182.0/24"]}
]
Costs: one hop to the flagged networks, bounded to three before the expansion, then one ROUTES hop per network; the WITH a LIMIT 3 is not optional, because a single operator can route thousands of blocks and the slice only applies after the collect.
Tip: long runs of adjacent
/24s carved out of one allocation is what churn-and-burn hosting looks like from the routing table. Feed a block into the neighbourhood read on the SOC page (Neighborhood toxicity) to see how much of it is already listed, or anchor a specific network by name and drop the signal match.
From here, → Who hosts this domain, on whose network to check whether a suspect resolves into one of these blocks.
Which hosts are flagged for wildcard DNS?
A domain answering on every possible subdomain is cheap infrastructure for whoever generates names faster than a feed can list them. The graph flags hosts for exactly that behaviour, and the flag is useful precisely where reputation data is silent.
// Hostnames flagged for wildcard-DNS behaviour
MATCH (h:HOSTNAME)-[:HAS_SIGNAL]->(:THREAT_SIGNAL_TYPE {name: "wildcard-dns"})
RETURN h.name AS host, h.verdictLevel AS verdict, h.verdictCoverage AS coverage
LIMIT 20
Returns: host, verdict, coverage
Sample output (captured 2026-09-02):
[
{"host": "bloomstudio.app", "verdict": "NONE", "coverage": "known-clean"},
{"host": "mathea.as", "verdict": "NONE", "coverage": "known-clean"}
]
Costs: one hop from the signal node back to the hosts that carry it, bounded by the LIMIT; read the verdict columns next to the signal and notice they disagree.
Tip: wildcard DNS is a configuration fact, not a verdict — plenty of legitimate operators use it, which is why almost every row reads clean. That is the point: the signal is an infrastructure property that no feed will ever list. Combine it with a registrant or nameserver pivot before you draw any conclusion from it.
From here, → Shared-nameserver siblings
How many siblings under the same apex share this IP?
Co-tenancy on its own means very little — thousands of unrelated sites share a CDN address. What says something is how many siblings from the same apex answer from the same address, because that is a fact about one operator's deployment rather than a shared platform.
// How many sibling hosts under the same apex share this IP?
MATCH (h:HOSTNAME {name: "www.github.com"})-[:CHILD_OF]->(apex)
MATCH (ip:IPV4 {name: "140.82.121.4"})<-[:RESOLVES_TO]-(sibling)-[:CHILD_OF]->(apex)
WHERE sibling <> h
RETURN apex.name AS apex, count(sibling) AS sibling_fanout
LIMIT 5
Returns: apex, sibling_fanout
Sample output (captured 2026-09-02):
[{"apex": "github.com", "sibling_fanout": 3}]
Costs: two indexed anchors joined on the apex, with WHERE sibling <> h keeping the host from counting itself; a raw co-tenant count on a busy IP can run to six figures and mean nothing, where this number stays small and specific.
Tip: this is the denominator that co-hosting scores are missing. Swap
IPV4forIPV6to score the v6 side of the same estate, and pair it withCALL whisper.identify(["<host>"])— itshost_classtells you whether the address is dedicated or a multi-tenant platform before you read anything into the fan-out.
From here, → Co-tenancy: every domain on the same IP for the full tenant list once the fan-out says the address is worth it.
History & change tracking
Watch how a domain's registration moved over time
What the graph does: current WHOIS is a single snapshot; the changes are the actual signal. whisper.history.whois returns the timestamped WHOIS snapshots, one row per snapshot, so you can see exactly when the registration shifted. It needs an API key, so sign in to run it — there is no card to enter.
CALL whisper.history.whois("paypal-account-verify.com")
YIELD indicator, createDate, updateDate, registrar, registrant, nameServers
RETURN indicator, createDate, updateDate, registrar, registrant, nameServers
LIMIT 10
Sample output (captured 2026-09-02):
[
{"indicator": "paypal-account-verify.com", "createDate": "2019-08-01", "updateDate": "2019-08-01", "registrar": "Vitalwerks Internet Solutions, LLC / No-IP.com", "registrant": "", "nameServers": "static-1.no-ip.com|static-2.no-ip.com|static-3.no-ip.com"},
{"indicator": "paypal-account-verify.com", "createDate": "2021-01-30", "updateDate": "2021-01-30", "registrar": "Google LLC", "registrant": "Contact Privacy Inc. Customer 1249278109", "nameServers": "ns-cloud-d1.googledomains.com|ns-cloud-d2.googledomains.com|ns-cloud-d3.googledomains.com|ns-cloud-d4.googledomains.com"}
]
Tip: read the sample as a timeline — a fresh
createDateunder a new registrar with a privacy-proxy registrant is the domain being re-registered by someone else, which is a classic ownership-handoff marker. Always call the single-shape variant from a script:whisper.history.whois(domain)emits the same WHOIS columns every time, andwhisper.history.bgp(ip|asn|prefix)the same routing columns, so a fixedYIELDnever breaks. A subdomain folds up to its registrable parent (registrableDomainnames the parent it resolved to), and a full URL folds to its host first. Seewhisper.history()for the full procedure reference.
De-cloak the real origin behind a CDN
What the graph does: whisper.origins reconstructs candidate origin IPs from MX/SPF, sibling, and crawl signals — highest confidence first.
CALL whisper.origins("nytimes.com")
YIELD ip, confidence, methods
RETURN ip, confidence, methods
ORDER BY confidence DESC
LIMIT 5
Tip:
confidenceis a0.0–1.0scale, andmethodsnames the signal each candidate came from (sibling,links_to,mx,spf) — read the two together rather than the number alone, since asiblinghit and alinks_tohit are very different kinds of evidence, and a candidate found by more than one method outranks any single-method one. Lone mail-derived candidates are deliberately down-weighted, because third-party mail providers serve thousands of unrelated domains. By default the output is origin-grade only; pass{include_related: true}to also see CDN and shared-provider context, labelled bykindandcategory. A de-cloaked origin is a fresh seed: run eachipback through the hosting walk above for its network, and through co-tenancy andexplain()for its neighbours — those often aren't behind the CDN and expose the rest of the campaign. Guided version: Find the real infrastructure behind the CDN.
Batch & evidence collection
Triage a list of indicators in one request
What the graph does: UNWIND a list and let the graph fan it out in a single round-trip. OPTIONAL MATCH keeps one row per input, so the names the graph has never seen come back too, instead of silently vanishing.
// Batch verdict + registrar for a list of suspect domains, misses included
UNWIND ["paypal-account-verify.com", "secure-paypai.com", "paypal.com"] AS name
OPTIONAL MATCH (h:HOSTNAME {name: name})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
RETURN name, h IS NOT NULL AS known, h.verdictLevel AS level, h.verdictScore AS score,
collect(DISTINCT r.name) AS registrars
LIMIT 100
Tip:
known: falsemeans the graph has no node for that name — unknown to the graph, which is different from "assessed clean", so surface the gap to your analyst rather than implying a verdict. Names are stored lowercase, so fold the case in your own code before you build the list; an upper-case letter in an anchor matches nothing. For a verdict with coverage, or owner, country and network per indicator, hand the list towhisper.assessorwhisper.enrichinstead — see Working in batches.
Inbound web links: who points at the target
What the graph does: point LINKS_TO at a lookalike and you get what references it — the parked-domain networks, redirectors, and directory spam that push traffic at it. Point it at the legitimate brand instead and you get the reverse trick: phishing pages that link to the real site to look credible.
LINKS_TO (HOSTNAME → HOSTNAME) is a pilot sample of the web hyperlink graph in the same surface, so treat it as a lead generator rather than a census.
// Sites that link to a target host
MATCH (source:HOSTNAME)-[:LINKS_TO]->(h:HOSTNAME {name: "payapl.com"})
RETURN source.name AS linking_site
LIMIT 25
Tip: flip the arrow —
(h)-[:LINKS_TO]->(target)— to see what the suspect domain references outbound, which can expose shared kit, redirectors, or affiliate tracking tied to the campaign. The same edge type also carries phishing-kit membership from aURLnode to the hosts serving that path; those recipes are on Lookalike Hunting.
Where to go next
- Indicator Triage (SOC) — the verdict-first triage workflow that feeds these pivots.
- Actor & ATT&CK layer — alias resolution, technique and tactic rollups, and the two sparse edges that reach live infrastructure.
- Cross-Layer Patterns — the reusable multi-layer query patterns behind every recipe here, including the batch shapes.
- Procedures — full signatures for
explain,whisper.variants,whisper.history.whois, andwhisper.origins. - Graph Schema — every label, edge, and property, with direction notes.
- Threat Feeds & Categories — the 134 feeds and 33 categories behind the verdicts.
- AI & Agents — point an MCP client at the graph and let your agent run these pivots itself, mid-investigation.
For inline enrichment in SPL, see Splunk Use Cases for Infrastructure Intel and Enterprise Security Integration.