Hunting requires knowing which questions each data source answers. A hunter who reaches for dns.log for beacon detection, or conn.log for URL analysis, wastes time and may miss the finding. The data model is the map — use it before every hypothesis.
The Network Hunter’s Log Map
┌─────────────────────────────────────┐
│ NETWORK TRAFFIC │
└──────┬───────────────┬──────────────┘
│ │
┌────────────▼─────────┐ ┌──▼─────────────────┐
│ conn.log │ │ Protocol Logs │
│ (all connections) │ │ (if protocol seen) │
│ │ │ │
│ Who talked to who │ │ dns.log (queries) │
│ For how long │ │ http.log (URLs) │
│ How many bytes │ │ ssl.log (TLS meta)│
│ Service detected? │ │ files.log (hashes) │
└──────────────────────┘ └─────────────────────┘
Log-to-Question Map
| Hunter Question | Primary Log | Key Fields |
|---|---|---|
| Is this host beaconing? | conn.log | ts, duration, orig_bytes, resp_bytes, id.resp_h, id.resp_p |
| Is a host doing unusual DNS? | dns.log | query, qtype_name, rcode_name, answers, TTL |
| Is TLS suspicious? | ssl.log | ja3, server_name, validation_status, cipher |
| Was a file downloaded? | files.log | sha256, MIME type, tx_hosts, rx_hosts, conn_uids |
| What URLs was a host visiting? | http.log | host, uri, user_agent, method, status_code |
| Is a host scanning internally? | conn.log | many S0/REJ entries, single src, many dsts |
| Did the host use a rare JA3? | ssl.log | ja3, count aggregated over time |
| Is DNS traffic carrying data? | dns.log | long subdomain labels, TXT qtype, high volume |
The Power-Law of Connection Frequency
Normal enterprise traffic follows a power-law distribution. A small number of destinations get most of the connections; a long tail of destinations get very few.
Connections to office365.com: 14,200 per day
Connections to google.com: 8,400 per day
Connections to cdn.example.com: 1,200 per day
...
Connections to analytics-cdn.io: 2 per day
Connections to update-cdn.net: 1 per day ← hunter territory
Connections to cdn-metrics.io: 1 ever ← high suspicion
Hunter insight: Attacker C2 lives in the tail. Query for:
- Destinations seen by exactly 1 host in the last 30 days
- First-ever connection to a domain (no prior DNS resolution)
- IP addresses with no PTR record (bare IP C2 — no domain)
Baseline First, Hunt Second
Before running any hunt, establish what “normal” is for your environment. Without a baseline, everything looks suspicious. With a baseline, you know what to explain away.
Three network baselines every hunter needs:
1. Destination Frequency Baseline
// Establish "well-known" external IPs and domains (seen by >10 hosts, every week)
Zeek_conn_CL
| where local_orig_b == true and local_resp_b == false
| where TimeGenerated > ago(30d)
| summarize
host_count = dcount(id_orig_h_s),
conn_count = count()
by id_resp_h_s
| where host_count >= 10 // seen by 10+ internal hosts = likely legitimate
| project id_resp_h_s as known_good_ip
Save this as your “known good” destination set. Any connection to an IP NOT in this set is a hunting candidate.
2. JA3 Frequency Baseline
// Most common JA3 fingerprints in your environment
Zeek_ssl_CL
| where TimeGenerated > ago(30d)
| summarize count() by ja3_s
| order by count_ desc
// Top 20 JA3 values = your expected browser/OS fingerprints
// Anything not in this set = anomalous TLS client
3. Per-Host Connection Profile
// For each internal host: how many unique external destinations per day?
Zeek_conn_CL
| where local_orig_b == true and local_resp_b == false
| summarize unique_dsts = dcount(id_resp_h_s) by id_orig_h_s, bin(TimeGenerated, 1d)
| summarize avg_unique_dsts = avg(unique_dsts), p99_unique_dsts = percentile(unique_dsts, 99) by id_orig_h_s
// A server that contacts 2 external IPs per day suddenly contacting 200 = anomaly
Long-Tail Hunting: First Principles
The simplest and most productive network hunt: find internal hosts talking to external destinations that no other internal host has seen.
// "Lone wolf" external destinations — seen by exactly 1 internal host ever
Zeek_conn_CL
| where TimeGenerated > ago(30d)
| where local_orig_b == true and local_resp_b == false
| summarize
host_count = dcount(id_orig_h_s),
total_bytes = sum(orig_bytes_l + resp_bytes_l),
first_seen = min(TimeGenerated),
last_seen = max(TimeGenerated)
by id_resp_h_s
| where host_count == 1 // only 1 internal host ever talked to this IP
| where total_bytes > 100000 // >100KB total (not a brief failed connection)
| order by total_bytes desc
Each result is a hypothesis: “Why is this host the only one that ever talked to this IP, and why did they exchange this much data?”
Hunt Planning: Data Source Validation
Before spending hours on a hunt, validate that the data exists in your environment:
Hunt: "Find hosts beaconing to C2 on 60-second intervals"
Data required:
- conn.log with timestamps (ts field) — ✓ available?
- External destination IP/port (id.resp_h, id.resp_p) — ✓ available?
- Zeek retention period covers the hunt window — ✓ 30 days available?
Data gap:
- If conn.log has been truncated or sampled, beacon math is unreliable
- If only flow data (NetFlow, sFlow) is available without packet-level timing,
connection timestamps may be rounded to 5-minute windows (not usable for 60s beacon)
Resolution: Confirm with infra team whether Zeek conn.log is full-fidelity or sampled.
If sampled: switch hypothesis to long-connection hunt (duration > 4h) which is
more robust to sampling artifacts.
Never start a hunt without validating the data source. A hunt that refutes a hypothesis because the data was sampled is worse than not hunting — it creates false confidence.