Campaign Pivoting

Turn one indicator into the whole campaign: co-tenancy, registrant, nameserver, TLS-fingerprint, and CT pivots as copy-paste Cypher recipes.

Updated July 2026

Campaign Pivoting Documentation

You hold one indicator — a phishing domain, a C2 IP, a suspicious nameserver — and you need the rest of the campaign: every sibling domain, the shared registrant, the co-tenant hosts, and how the infrastructure moved over time. With flat lookup tools that is a dozen tabs and a spreadsheet. WhisperGraph pre-joins DNS, WHOIS/RDAP, BGP, threat verdicts, Tor/TLS egress, and Certificate Transparency 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. Anonymous access runs 2-hop queries; a free key raises that to 3 hops, and paid plans go deeper. 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.

Quick triage

For a full triage workflow (verdict, feeds, posture, escalation), see Indicator Triage (SOC). The two reads below are the minimum you need before pivoting.

Triage one indicator on the reconciled verdict

Why it's hard with flat tools: you query five block lists, get five disagreeing answers, and still have to decide whether to block.

What the graph does: every threat-listed node carries a reconciled verdict — one blocking-aware answer rolled up across all 43 feeds — plus the boolean flags that tell you what kind of bad it is.

// Reconciled verdict + what-kind-of-bad flags, in one read
MATCH (ip:IPV4 {name: "185.220.101.1"})
RETURN ip.verdictScore   AS score,
       ip.verdictLevel   AS level,
       ip.verdictBlocking AS block,
       ip.isC2, ip.isMalware, ip.isTor, ip.isProxy, ip.isScanner

Tip: prefer verdictScore over the raw threatScore — it is the cross-feed reconciliation, not a single list's opinion. For the full evidence chain (which feeds, with weights and first/last-seen), use CALL explain(...).

Get the scored evidence chain for any indicator

Why it's hard with flat tools: a reputation score with no factors is unappealable — you can't paste "0.91, trust me" into a ticket.

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: explain works 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

Blast radius — pivot from one flagged IP to every co-hosted domain, the feeds that name it, and the network that routes it.

Co-tenancy: every domain on the same IP

Try it on a live IP — every hostname currently resolving to it:

Live · graph.whisper.security
read-only Cypher
Copy as
Open it in the Console

Why it's hard with flat tools: reverse-IP lookup is its own paid product, and it doesn't join to anything else.

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: "paypal-account-verify.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 10 bounds the IP fan-out first. If the seed no longer resolves, pull CALL whisper.history(...) for the IPs it used to resolve to.

Registrant pivot: every domain sharing a WHOIS email

Why it's hard with flat tools: WHOIS is per-domain, so you can't ask "what else did this registrant register" without scraping.

What the graph does: 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.

Shared-nameserver siblings

Why it's hard with flat tools: passive-DNS products show you the NS records but won't enumerate the reverse — every other domain that delegates to the same server.

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.example-bulletproof.net"})-[:NAMESERVER_FOR]->(domain:HOSTNAME)
RETURN domain.name AS delegated_domain
LIMIT 25

Tip: filter out the giants. Domains on ns1.google.com or Cloudflare's nameservers tell you nothing. A private or oddly-named nameserver shared across a handful of suspicious domains is the find.

One query, three pivots: the campaign in a single traversal

Why it's hard with flat tools: co-tenancy, shared registrant, and shared nameserver are three separate products with three exports you'd have to reconcile by hand.

What the graph does: they are three edge types on the same node. UNION them into one campaign view, tagged by pivot type. Three pivots in one statement can push past the anonymous 2-hop cap; a free key covers it.

// 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 pivot column 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 the related list back through explain() to rank the cluster by verdict.

Typosquats & lookalikes

The recipe below covers the campaign-pivoting angle: generate lookalikes as fresh pivot seeds. For the full brand playbook (anchored prefix sweeps, TLD sweeps, parked-vs-weaponized, takedown evidence), see Lookalike Hunting.

Generate the lookalike set for a brand

Why it's hard with flat tools: you'd hand-write homoglyph and bitsquat permutations, then check each one's registration.

What the graph does: whisper.variants runs many generation algorithms and returns only the variants that exist as nodes — registered lookalikes, ready to pivot.

CALL whisper.variants("paypal.com")
YIELD variant, method, exists, confidenceLabel
WHERE exists
RETURN variant, method, confidenceLabel
LIMIT 25

Tip: exists: true means registered, not malicious. A parked typo and an active phishing kit look identical here — pivot each hit through explain() for the verdict, then feed the flagged ones into the campaign pivots above.

Actor attribution & MITRE ATT&CK

The actor recipes moved to their own page: Actor Attribution & ATT&CK covers mapping a named actor to its techniques, finding actors that share a technique, and narrowing attribution by rare-technique convergence. The ACTOR and ATTACK_PATTERN nodes live in the same graph as the infrastructure you just mapped, so a campaign cluster pivots straight into attribution.

Egress & fingerprint pivots

Identify Tor-exit infrastructure that survives IP rotation

Why it's hard with flat tools: an exit node's IP changes; the relay identity (its fingerprint) doesn't, and most tools only see the IP.

What the graph does: OPERATES_EXIT_NODE ties an IP to a 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.isAnonymizer and ip.isProxy on the same node.

Track C2 across changing domains with a TLS fingerprint

Why it's hard with flat tools: when an actor rotates domains and IPs, the only stable thread is how the server speaks TLS — and that is invisible to DNS-based tooling.

What the graph does: EMITS_TLS_FINGERPRINT ties an IP to a JA3/JARM fingerprint. Find the fingerprint your seed C2 emits, then pivot to every other IP emitting the same one. Coverage is still rolling out, so read an empty result as "not observed yet".

// Other IPs emitting the same JA3/JARM fingerprint as a known C2 IP
MATCH (seed:IPV4 {name: "185.220.101.1"})-[:EMITS_TLS_FINGERPRINT]->(fp:TLS_FINGERPRINT)
WITH fp LIMIT 5
MATCH (fp)<-[:EMITS_TLS_FINGERPRINT]-(other:IPV4)
WHERE other.name <> "185.220.101.1"
RETURN fp.name AS fingerprint, other.name AS same_stack_ip, other.verdictLevel AS level
LIMIT 25

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 verdictLevel to surface the ones already flagged.

Discover subdomains and SANs from Certificate Transparency

Why it's hard with flat tools: an actor's staging subdomains may never resolve publicly, but they leak into CT logs the moment a cert is issued.

What the graph does: SEEN_IN_CT connects a host to its Certificate Transparency observations, surfacing names that DNS alone would miss.

// CT observations for a domain — surfaces SANs / staging subdomains
MATCH (h:HOSTNAME {name: "paypal-account-verify.com"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.name AS ct_observation
LIMIT 25

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

Why it's hard with flat tools: WHOIS says who owns a netblock; it rarely says who operates it — and attackers abuse the gap.

What the graph does: DELEGATED_TO records the vendor actually running address space, distinct from the registrant ORGANIZATION.

// Which vendor operates the address space this IP sits in?
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:DELEGATED_TO]->(v:VENDOR)
RETURN ip.name AS ip, v.name AS operated_by
LIMIT 10

Tip: a "consumer" netblock 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 attribution & identity

Who hosts this domain, on whose network

Why it's hard with flat tools: host → IP → prefix → ASN → network name → country is five lookups across four products.

What the graph does: one traversal. ANNOUNCED_BY covers the IP, ROUTES reaches the ASN, HAS_NAME resolves the network name, and LOCATED_IN/HAS_COUNTRY geolocate it. This chain exceeds the anonymous 2-hop cap — bring a free key.

// Domain → IP → announced prefix → ASN → network name, plus country
MATCH (h:HOSTNAME {name: "paypal-account-verify.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_TO and ANNOUNCED_BY are forward edges (HOSTNAME → IP, IP → prefix); ROUTES matches from either direction. The ASN's number lives on a.name (AS13335); the human-readable network name lives on the separate ASN_NAME node. Hosting identity is a separate question from the threat verdict — answer the second with explain().

History & change tracking

Watch how a domain's registration moved over time

Why it's hard with flat tools: current WHOIS is a single snapshot; the changes are the actual signal, and they're gone unless someone archived them.

What the graph does: whisper.history returns the timestamped WHOIS snapshots so you can see exactly when the registration shifted. (Requires an API key — not on the anonymous tier.)

CALL whisper.history("paypal-account-verify.com")
YIELD queryTime, createDate, updateDate, registrar, nameServers
RETURN queryTime, createDate, updateDate, registrar, nameServers
LIMIT 10

Tip: a registrar transfer and a nameserver change in the same week is a classic ownership-handoff marker. For an IP, ASN, or prefix the same call returns BGP routing history instead — keep a LIMIT, large networks take seconds. See whisper.history() for the full procedure reference.

De-cloak the real origin behind a CDN

Why it's hard with flat tools: the domain resolves to Cloudflare; the actual origin server is hidden behind it.

What the graph does: whisper.origins reconstructs candidate origin IPs from MX/SPF, sibling, and crawl signals — highest confidence first.

CALL whisper.origins("paypal-account-verify.com")
YIELD ip, confidence, methods, asnName
RETURN ip, confidence, methods, asnName
LIMIT 5

Tip: a de-cloaked origin is a fresh seed. Run it back through co-tenancy and explain() — the origin's neighbors 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

Why it's hard with flat tools: a batch lookup is N API calls and N rate-limit windows.

What the graph does: UNWIND a list and let the graph fan it out in a single round-trip.

// Batch verdict + registrar for a list of suspect domains
UNWIND ["paypal-account-verify.com", "secure-paypaI.com", "paypal.com"] AS name
MATCH (h:HOSTNAME {name: name})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
RETURN name,
       h.verdictLevel AS level,
       h.verdictScore AS score,
       collect(DISTINCT r.name) AS registrars
LIMIT 100

Tip: a domain absent from the result set didn't match the MATCH — it's unknown to the graph, which is different from "assessed clean." Surface the gap to your analyst rather than implying a verdict.

Why it's hard with flat tools: the open web's hyperlink graph isn't something you can query alongside DNS and WHOIS.

What the graph does: LINKS_TO is the Common Crawl hyperlink graph in the same surface. Phishing pages often link to the legitimate brand to look credible — inbound links surface them.

// Sites that link to a target host
MATCH (source:HOSTNAME)-[:LINKS_TO]->(h:HOSTNAME {name: "paypal-account-verify.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.

Where to go next

For inline enrichment in SPL, see Splunk Use Cases for Infrastructure Intel and Enterprise Security Integration.