Attribution & Law Enforcement
Pivot from indicators to operators using WHOIS, registrar relationships, hosting providers, dated routing and registration history, Tor-relay identity, and observed dark-web services.
On this page (19)
- Attribution: IP to responsible operator
- IP attribution chain
- IP to jurisdiction
- Operator's physical footprint
- Ownership: the full registration record
- Complete WHOIS ownership chain
- Timestamped registration history (the dated evidence)
- Related-domain discovery
- Pivot via shared registrant email
- Pivot via shared registrant organization
- Pivot via shared hosting (co-tenancy)
- Typosquats and lookalikes
- Anonymity infrastructure & threat verdict
- Tor-exit identity that survives IP rotation
- Sourced threat verdict with evidence
- Dark-web infrastructure
- Which dark-web services has the graph observed, and are they live?
- Putting it together: a defensible attribution packet
- Where to go next
Attribution & Law Enforcement Documentation
You build infrastructure maps that hold up in court. These recipes take you to the responsible network operator and its jurisdiction, the dated registration and routing record, the relay behind a Tor exit, and the dark-web services the graph has observed — every result sourced from public data and pairable with a timestamp, so the pivot you make at 2am survives disclosure six months later. Anchor each query on a known indicator, keep the LIMIT, and capture the response alongside the moment you ran it. For the legal-process side of attribution you want the responsible network operator and its jurisdiction; for the relational side you want shared registrant, shared infrastructure, and the dated history that proves when a link existed.
Run it live: Digital Infrastructure Mapping · Build the takedown evidence package — each opens with a live result you can rerun on your own indicator.
New to the graph? Start with Getting Started, and keep the Graph Schema and Procedures open. Run everything below at https://graph.whisper.security/api/query.
Key concepts: Infrastructure pivoting · Reconciled verdict · Tor exit node · RDAP.
Evidentiary hygiene. The graph is continuously refreshed, so a result is a point-in-time observation. For anything destined for an affidavit or warrant return, pair the query result with the moment you ran it and, where the data is historical, with the
whisper.historysnapshot timestamp. "Captured from WhisperGraph on <date/time>, sourced from <feed/registry>" is the citation pattern.
Attribution: IP to responsible operator
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.whisper.explaindoes not returncoverageat all. Full contract: Coverage — what we looked at.
IP attribution chain
Why it's hard with flat tools. A WHOIS-on-IP lookup gives you a netblock and an org string, but not the announcing ASN, and the announced prefix often differs from the allocated one. You end up cross-referencing a routing-table dump by hand.
What the graph does. One traversal takes the IP to the prefix actually being announced, to the ASN announcing it, to that network's registered name — the operator you serve process on.
// IP -> announced prefix -> ASN -> network name (the legal-process target)
MATCH (ip:IPV4 {name: "185.220.101.1"})
-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network
LIMIT 5
[{"ip": "185.220.101.1", "prefix": "185.220.101.0/24", "asn": "AS60729", "network": "TORSERVERS-NET - Stiftung Erneuerbare Freiheit"}]
The
asn.namefield is the AS number; the registered network name lives onASN_NAME(reach it via the virtualHAS_NAMEhop), and the registrant company on(a)-[:REGISTERED_BY]->(:ORGANIZATION).CALL explain("AS60729")adds the verdict and contributing feeds in one call. Network names are read live, so capture the string with the timestamp — the sample above carried a different registered name a few weeks earlier.
IP to jurisdiction
Why it matters. The announcing operator and the IP's geolocation can sit in different countries — that distinction drives which MLAT or domestic process applies.
// IP -> city -> country, for jurisdiction
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:LOCATED_IN]->(city:CITY)
-[:HAS_COUNTRY]->(country:COUNTRY)
RETURN ip.name AS ip, city.name AS city, country.name AS country
LIMIT 5
[{"ip": "185.220.101.1", "city": "Brandenburg, DE", "country": "DE"}]
Anycast and CDN addresses often carry no city edge and return no rows rather than a wrong city; for those, read the registered country of the announcing prefix instead:
(ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:HAS_COUNTRY]->(co:COUNTRY).
Operator's physical footprint
Why it's hard with flat tools. Knowing an ASN is one thing; knowing which datacenter or internet exchange it physically sits in — useful for identifying a co-located host, a peering point, or a facility operator who can be served — is a separate dataset entirely.
What the graph does. The physical layer is pre-joined. Walk from the ASN to the buildings and IXPs it's present at.
// Where the announcing network physically sits
MATCH (a:ASN {name: "AS60729"})
OPTIONAL MATCH (a)-[:AS_PRESENT_AT]->(f:FACILITY)
OPTIONAL MATCH (a)-[:IX_MEMBER]->(ix:INTERNET_EXCHANGE)
RETURN a.name AS asn,
collect(DISTINCT f.name) AS facilities,
collect(DISTINCT ix.name) AS exchanges
LIMIT 5
Facility names are concrete (
Equinix DA1 - Dallas), which makes them usable directly in a subpoena to the colocation provider. Both legs areOPTIONAL, so the row comes back even when the lists are empty — and for the network above they are: a small operator with no published facility or exchange presence is itself a documentable fact. Run the same query on a large transit network to see the populated shape.
Ownership: the full registration record
Complete WHOIS ownership chain
Why it's hard with flat tools. A single WHOIS query returns the current registrar and contacts. The chain of who held the domain before — the registrar transfers that often track a change of control — is scattered across historical lookups.
What the graph does. Current and prior registrars, contact emails, phones, and registrant org sit on the hostname as edges. One query documents the whole record.
// Complete publicly available registration record for a domain
MATCH (h:HOSTNAME {name: "paypal.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
OPTIONAL MATCH (h)-[:PREV_REGISTRAR]->(pr:REGISTRAR)
OPTIONAL MATCH (h)-[:HAS_EMAIL]->(e:EMAIL)
OPTIONAL MATCH (h)-[:HAS_PHONE]->(p:PHONE)
OPTIONAL MATCH (h)-[:REGISTERED_BY]->(org:ORGANIZATION)
RETURN h.name AS domain,
collect(DISTINCT r.name) AS current_registrar,
collect(DISTINCT pr.name) AS previous_registrars,
collect(DISTINCT e.name) AS emails,
collect(DISTINCT p.name) AS phones,
collect(DISTINCT org.name) AS organizations
LIMIT 5
[{
"domain": "paypal.com",
"current_registrar": ["iana:292"],
"previous_registrars": ["registrar:markmonitor inc.", "iana:292"],
"emails": ["hostmaster@ebay.com", "hostmaster@paypal.com", "service@sintl-paypal.com"],
"phones": ["+14083767400", "+18882211161"],
"organizations": ["domain administrator", "host master", "paypal"]
}]
PREV_REGISTRARis the historical registrar chain — document when a domain changed hands. Registrar ids use theiana:NNNNform, resolvable at the IANA registrar database. Phone numbers are E.164 when available. Register-redaction (privacy-proxy) values likedata-protected.netare themselves a documentable fact: note that the registrant elected privacy as of the capture date. Raw registrant strings (host master,paypal) are stored as the registrar wrote them;(org)-[:SAME_ORG_AS]->(:ORGANIZATION)folds the variants to the canonical company name where the graph has mapped one.
Timestamped registration history (the dated evidence)
Why it matters. "The domain was registered to X" is weak. "WHOIS captured 2023-08-14 shows registrant X; the prior snapshot 2021-02-03 shows registrant Y" is defensible. whisper.history.whois returns dated WHOIS snapshots (RDAP/WHOIS-sourced) for a domain, one row per snapshot, with a fixed column set you can cite exhibit after exhibit.
// Dated WHOIS snapshots — one row per snapshot, stable columns
CALL whisper.history.whois("paypal.com")
YIELD indicator, queryTime, createDate, updateDate, expiryDate, registrar, registrant, nameServers
RETURN indicator, queryTime, createDate, updateDate, expiryDate, registrar, registrant, nameServers
LIMIT 10
Each row carries its own snapshot timestamp (
queryTime) — cite the full timestamp and the registry/feed source. A subdomain folds up to its registrable parent, and theregistrableDomaincolumn names the parent the lookup resolved to, so an exhibit can state exactly which registration record it shows. For an IP, ASN or prefix the routing variantwhisper.history.bgpreturns dated origin history (origin,prefix,startTime,endTime,visibility) so you can show which ASN announced an address on a given date — directly relevant when an offense maps to a specific time window. Keep aLIMITon it and expect a longer round trip for a large network.
Related-domain discovery
Pivot via shared registrant email
Why it's hard with flat tools. Reverse-WHOIS by email is a siloed feature on most platforms, and it doesn't join to anything else you know.
What the graph does. A contact email is a shared node — every domain that ever listed it hangs off it by one edge. Walk the email back out to its siblings.
// Domains sharing a contact email with the anchor domain
MATCH (h1:HOSTNAME {name: "paypal.com"})-[:HAS_EMAIL]->(e:EMAIL)
WITH e LIMIT 25
MATCH (e)<-[:HAS_EMAIL]-(h2:HOSTNAME)
RETURN e.name AS contact_email, collect(DISTINCT h2.name)[..50] AS related_domains
LIMIT 25
Document the pivot precisely: "paypal.com and <domain> both list registrant email <X> in WHOIS, captured <date>." Be cautious with registrar/privacy-proxy emails (e.g.
*@markmonitor.com,*@data-protected.net) — they're shared by thousands of unrelated domains and are not evidence of common control. Bind the high-fan-out side withWITH ... LIMIT, as above.
Pivot via shared registrant organization
// Other domains registered to the same organization.
// Bound the per-org fan-out in a subquery — a registrar-scale org can
// own millions of domains, so cap each branch before collecting.
MATCH (h1:HOSTNAME {name: "paypal.com"})-[:REGISTERED_BY]->(org:ORGANIZATION)
CALL {
WITH org
MATCH (org)<-[:REGISTERED_BY]-(h2:HOSTNAME)
RETURN h2.name AS related LIMIT 50
}
RETURN org.name AS organization, collect(DISTINCT related) AS related_domains
LIMIT 10
A generic registrant string such as
domain administratoris shared by unrelated domains across the whole internet and proves nothing on its own; a distinctive company string does. Check(org)-[:SAME_ORG_AS]->(:ORGANIZATION)for the canonical company before you treat two spellings as two owners.
Pivot via shared hosting (co-tenancy)
Why it matters. Shared registrant proves a paperwork link; shared IP proves an operational one. Both, dated, are stronger than either alone.
// Other hostnames resolving to the same IP as the target
MATCH (h:HOSTNAME {name: "paypal.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH ip LIMIT 10
MATCH (ip)<-[:RESOLVES_TO]-(sibling:HOSTNAME)
RETURN ip.name AS shared_ip, collect(DISTINCT sibling.name)[..50] AS co_tenants
LIMIT 10
Note the direction:
RESOLVES_TOis HOSTNAME→IP, so co-tenants come back via(ip)<-[:RESOLVES_TO]-(sibling). On a shared-hosting or CDN IP, co-tenancy is weak evidence of a relationship — qualify it. On a dedicated host it's strong.CALL whisper.identify(["paypal.com"])tells you which case you're in: itshost_classfield separates dedicated hosts from multi-tenant platforms, cloud, and CDN space.
Typosquats and lookalikes
Why it matters. Phishing and fraud cases turn on lookalike domains. Generating them by hand misses homoglyph and bitsquat variants; checking which are actually registered is a second pass.
// Registered lookalikes of the brand, with the algorithm that generated each
CALL whisper.variants("paypal.com")
By default
whisper.variantsreturns only variants that exist as nodes — registered, not necessarily malicious. Pivot each hit throughexplain()for a verdict (next section) before characterizing it. For the full brand workflow, including the phishing-kit fingerprints that tie disposable domains to one operator, see Lookalike Hunting.
Anonymity infrastructure & threat verdict
Tor-exit identity that survives IP rotation
Why it's hard with flat tools. A "this IP is a Tor exit" boolean tells you nothing about the relay's stable identity. Operators rotate IPs; the relay fingerprint persists.
What the graph does. An IP links to the Tor relay it operates, keyed by fingerprint — a stable identifier you can track across address changes and cite as the relay's identity.
// Is this IP a Tor exit, and what is the relay's stable identity?
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:OPERATES_EXIT_NODE]->(relay:TOR_RELAY)
RETURN ip.name AS ip, relay.name AS relay_fingerprint, ip.isTor AS flagged_tor
LIMIT 5
The relay fingerprint is the durable identity; the IP is just where it ran at observation time. A single IP can operate several relay fingerprints, so expect multiple rows;
CALL whisper.lookupTorRelay("185.220.101.1")returns the fuller relay record. Combine withwhisper.history.bgpon the IP to show the BGP origin on the offense date.
Sourced threat verdict with evidence
Why it matters. An affidavit needs the basis for calling an indicator malicious, not just a label. explain returns a reconciled, blocking-aware score with the contributing feeds, factors, and first/last-seen dates — an inspectable evidence chain.
// Scored verdict + the exact feeds, factors and timestamps behind it
CALL explain("185.220.101.1")
YIELD indicator, score, level, explanation, factors, sources
RETURN indicator, score, level, explanation, factors, sources
Name the columns for the exhibit. A bare
CALL explain(...)hands back the procedure's full column set, and any column that carries nothing for this indicator comes back blank next to the ones that do — a blank cell in an exhibit invites a question you do not want to answer on the stand.YIELDthe columns you are citing:factorsis the arithmetic,sourcesis the feed-by-feed provenance with first/last-seen dates. To cite only the feeds that moved the score,UNWIND sources AS sand keepWHERE s.weight >= 1.0.
You can also read the reconciled verdict and category flags straight off a node when you've already traversed to it:
// Reconciled verdict + flags carried on the node
MATCH (ip:IPV4 {name: "185.220.101.1"})
RETURN ip.name AS ip,
ip.verdictScore AS score,
ip.verdictLevel AS level,
ip.verdictBlocking AS blocking,
ip.isTor AS isTor,
ip.isC2 AS isC2,
ip.isAnonymizer AS isAnonymizer
LIMIT 1
Prefer
verdictScore/verdictLevel(reconciled across feeds) over any single-source score. The flags (isC2,isMalware,isTor,isAnonymizer, …) let you characterize what kind of bad — useful for matching infrastructure to a specific offense. For what each feed covers, see Threat Feeds & Categories.
Dark-web infrastructure
Which dark-web services has the graph observed, and are they live?
A case file references an onion address and you want it resolved inside the same graph as everything else: whether it has been observed live, on which network, and what verdict it carries. Liveness at the time you looked is the fact you will be asked about, so timestamp it.
// Observed dark-web domains, with liveness and verdict
MATCH (d:DWI_DOMAIN)
RETURN d.name AS onion_domain, d.dwi_network AS network,
d.dwi_http_status AS http_status, d.verdictLevel AS verdict
LIMIT 10
Returns: onion_domain, network, http_status, verdict
Sample output (captured 2026-09-02):
[
{"onion_domain": "darkmmnjhxn5sf3j2rz3hy36kdotf3apgfh4g6iez6cb2q2feazlsuad.onion", "network": "onion", "http_status": 200, "verdict": "MEDIUM"},
{"onion_domain": "darkmmaugjlnyv7i367vwddz4jkvy2sdlaeutb2uilgs5g3no54mb4qd.onion", "network": "onion", "http_status": 200, "verdict": "MEDIUM"}
]
Costs: a bounded browse over a small catalogue, no traversal; to check one address rather than browse, anchor it — MATCH (d:DWI_DOMAIN {name: "<address>.onion"}) RETURN d.dwi_http_status, d.dwi_final_url, d.dwi_last_event_at, d.dwi_is_challenge_page LIMIT 1 — and read an empty result as "not observed", never as "not live".
dwi_http_statusis the last observed response — a200means the service answered when it was last checked, which for an onion service is a perishable fact worth timestamping againstdwi_last_event_at.dwi_final_urlrecords where the request ended up after redirects.dwi_is_challenge_pageflags a response that was really an anti-crawling interstitial rather than the site itself, which matters if you are about to assert the content of a page in a filing.
From here, → Sourced threat verdict with evidence for the feeds behind the verdict column, then the attribution packet below for anything the service resolves to on the clear web.
Putting it together: a defensible attribution packet
A single pass that produces network owner, jurisdiction, anonymity status, and verdict — the spine of an evidence exhibit.
// Attribution packet for one IP: owner, country, Tor status, verdict
MATCH (ip:IPV4 {name: "185.220.101.1"})
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(country:COUNTRY)
OPTIONAL MATCH (ip)-[:OPERATES_EXIT_NODE]->(relay:TOR_RELAY)
RETURN ip.name AS ip,
ap.name AS announced_prefix,
a.name AS asn,
n.name AS network_operator,
city.name AS city,
country.name AS country,
relay.name AS tor_relay,
ip.verdictLevel AS verdict,
ip.verdictBlocking AS blocking
LIMIT 5
[{"ip": "185.220.101.1", "announced_prefix": "185.220.101.0/24", "asn": "AS60729", "network_operator": "TORSERVERS-NET - Stiftung Erneuerbare Freiheit", "city": "Brandenburg, DE", "country": "DE", "tor_relay": "6c64100d8f7050e76f420ce404031eabc7101124", "verdict": "LOW", "blocking": false}]
Then attach the dated routing history and the scored evidence chain:
// Dated BGP origin history for the offense window — stable routing columns
CALL whisper.history.bgp("185.220.101.1")
YIELD origin, prefix, startTime, endTime, visibility
RETURN origin, prefix, startTime, endTime, visibility
LIMIT 10
// reconciled verdict + contributing feeds & timestamps
CALL explain("185.220.101.1")
YIELD indicator, score, level, explanation, factors, sources
RETURN indicator, score, level, explanation, factors, sources
Capture all three results with the run timestamp. Together they answer who (network operator + registrant), where (jurisdiction + physical facility), when (dated WHOIS/BGP snapshots), and what (reconciled verdict with sourced feeds) — each backed by a specific graph edge rather than an analyst's recollection. The packet returns one row per Tor relay the address operates, so expect several near-identical rows when the IP runs more than one.
Where to go next
- Graph Schema — every label, edge, and property. Watch the direction landmines:
RESOLVES_TOis host→IP,NAMESERVER_FOR/MAIL_FORare server→domain,CHILD_OFis child→parent. - Procedures — signatures for
explain,whisper.history.whois/whisper.history.bgp,whisper.variants, andwhisper.origins, plus thewhisper.identify/whisper.assess/whisper.walkhost-context calls. - Campaign Pivoting — the actor and ATT&CK reference layer, alias resolution, and the sparse attribution and malware-tag edges.
- Internet Measurement — the bulk and aggregate side of research queries: topology surveys and longitudinal studies.
- Cross-Layer Patterns — the reusable multi-layer pivots behind every recipe here.
- Threat Feeds & Categories — the 134 feeds and 32 categories behind the verdicts.
- AI & Agents — point an MCP client at the graph (setup); the
queryandexplain_indicatortools let an assistant build the attribution packet above in-conversation, every claim citing a graph edge.