Beaconing & C2 Detection¶
In one sentence
An implant has to phone home, and it does so in ways that are statistically different from a human: regular timing, consistent sizes, one destination for a long time, odd TLS/HTTP fingerprints, and destinations no one else visits — this page is the checklist and the queries for finding that in Zeek/Splunk, plus what modern C2 does to hide.
The signals¶
| Signal | Why it exists | Where to measure |
|---|---|---|
| Regular interval (with or without jitter) | Implant sleeps N seconds between check-ins | conn.log ts deltas per (orig_h, resp_h, resp_p) |
| Consistent payload size when idle | "Nothing to do" is the same message every time | orig_bytes / resp_bytes variance; http.log response_body_len |
| Long-lived relationship | Same pair talks for hours/days | Count of connections per pair per day; first-seen date |
| Rare destination | Only the infected host(s) know the C2 | dc(id.orig_h) per resp_h / SNI / domain |
Low dns.log presence or new domain |
Hard-coded IP, or freshly registered domain | Conn without preceding DNS; domain age |
| Fingerprint mismatch | Implant's TLS/HTTP stack ≠ a browser | JA3/JA4 rarity, missing ALPN, odd UA, header order |
| Cert oddities | Self-signed / default / just-created | ssl.log validation_status, x509.log not_valid_before |
| Off-hours activity | Machines don't sleep, users do | Connections at 03:00 from a workstation whose user is asleep |
| Long connections with low bytes | Interactive shell / reverse tunnel kept alive | conn.log duration > hours, orig_bytes small |
| Bytes out ≫ bytes in | Exfil | sum(orig_bytes) per pair; SRUM on the host |
Interval analysis — the core technique¶
For each (id.orig_h, id.resp_h, id.resp_p) with enough connections (say ≥ 20 in a day), compute the time between consecutive connections and look at the distribution:
- Perfect beacon: all deltas ≈ 60 s → tiny standard deviation, one spike in the histogram.
- Jittered beacon (Cobalt Strike
sleep 60 jitter 30): deltas uniformly spread in 42–60 s → a box, still bounded, still nothing like human traffic. - Human / browser: deltas from milliseconds to hours, heavy tail, clustered in working hours.
- Legit background: NTP (exactly regular, port 123, tiny), Windows Update, AV updates, OneDrive, Teams presence — regular and to well-known destinations used by many hosts. Rare destination is what separates them.
index=zeek sourcetype=zeek:conn local_orig=true local_resp=false
| sort 0 id.orig_h, id.resp_h, id.resp_p, _time
| streamstats current=f last(_time) as prev_time by id.orig_h, id.resp_h, id.resp_p
| eval delta = _time - prev_time
| where isnotnull(delta) AND delta > 0
| stats count avg(delta) as avg_gap stdev(delta) as sd_gap avg(orig_bytes) as avg_up stdev(orig_bytes) as sd_up dc(uid) as conns min(_time) as first max(_time) as last by id.orig_h, id.resp_h, id.resp_p
| where count >= 20
| eval jitter_pct = round(100 * sd_gap / avg_gap, 1), size_var_pct = round(100 * sd_up / (avg_up+1), 1)
| eval span_hours = round((last-first)/3600, 1)
| where jitter_pct < 30 AND span_hours > 2
| convert ctime(first) ctime(last)
| sort jitter_pct
Line by line: sort 0 …, _time orders every connection by pair and time (0 = no row limit). streamstats current=f last(_time) as prev_time by … walks down the sorted list and, for each row, fetches the timestamp of the previous row in the same pair — current=f means "don't include the current row", so you get the previous one. eval delta = _time - prev_time is the gap in seconds. stats … by pair then summarises each pair: how many gaps, their average and standard deviation, the average/stdev of upload size, and the first/last time. jitter_pct is stdev divided by mean as a percentage — a perfect beacon is ~0%, Cobalt Strike with 30% jitter lands around 15–20%, browsing is well over 100%. where jitter_pct < 30 AND span_hours > 2 keeps only regular, long-running pairs. What you see at the top: a workstation → one VPS IP:443, 1,400 connections, avg gap 61 s, jitter 3%, spanning 24 hours.
Add rarity in the same pass:
eventstats is like stats but keeps the original rows and appends the result as a new field — here, how many internal hosts talk to that destination at all. Beacons to a destination that only 1–3 hosts ever contact are the ones to open first.
# deltas for one pair
zeek-cut ts id.orig_h id.resp_h id.resp_p < conn.log \
| awk '$2=="10.0.0.25" && $3=="203.0.113.7" && $4==443 {print $1}' | sort -n \
| awk 'NR>1{printf "%.0f\n", $1-prev} {prev=$1}' | sort -n | uniq -c | sort -rn | head
# → "1372 60" (1,372 gaps of exactly 60 s) is a beacon; a browser gives a long messy list.
# pairs by connection count, then eyeball the frequent ones
zeek-cut id.orig_h id.resp_h id.resp_p < conn.log | sort | uniq -c | sort -rn | awk '$1>100' | head -30
# Import Zeek logs, then ask for beacons / long connections / DNS oddities
rita import /data/zeek/logs/2026-09-16 case01
rita show-beacons case01 | head -20 # score, src, dst, connections, avg bytes, interval range
rita show-long-connections case01
rita show-exploded-dns case01 # subdomain explosion = tunnelling
rita show-strobes case01 # extremely high connection counts
RITA (Active Countermeasures) implements exactly this analysis with a 0–1 beacon score. AC-Hunter is the commercial UI.
HTTP/TLS-level tells¶
- Same URI, tiny response, forever:
http.logGET /pixel.gifevery minute,response_body_len0–100,POST /submit.php?id=…withrequest_body_len> 0 when results return. - User agent used by 1 host; HTTP/1.0 or missing
Accept-Language/Referer;Hostheader is a raw IP or blank. - JA3/JA4 unique to the host (see ssl.log); no ALPN; SNI blank; self-signed / brand-new cert;
sni_matches_cert = F(fronting). - Cert reuse: same
x509fingerprint on multiple IPs over weeks. - Cobalt Strike / Sliver / Mythic / Havoc defaults exist (default UA strings, default cert
Major Cobalt Strike, default URIs/ca,/dpixel,/__utm.gif,/activity), but competent operators change them — rely on behaviour, use defaults as a bonus.
DNS C2 & tunnelling¶
- Very high query rate to one domain, subdomains long and high-entropy, mostly
TXT/NULL/CNAME/MX; total DNS bytes per host far above peers; answers that don't look like addresses. (dns.log) - Clients talking DNS to non-corporate resolvers, or on TCP 53.
rita show-exploded-dns; Splunk:dc(query)per registered domain per host.
Other channels¶
| Channel | What it looks like |
|---|---|
| ICMP tunnel | conn.log proto=icmp with large orig_bytes, many packets, both directions; Wireshark icmp && data.len > 64 |
| Long-lived reverse shell / SOCKS (chisel, ngrok, ssh -R) | conn.log duration in hours, history ShADad… with continuous small data, service=ssh on non-22 or ssl to a tunnelling provider (*.ngrok.io, *.trycloudflare.com, *.serveo.net) |
| Cloud-service C2 (Slack, Discord, Telegram, Dropbox, GitHub, Google Drive APIs) | Destinations look legit; separate by regular interval + one host + API endpoints (api.telegram.org, discord.com/api/webhooks, raw.githubusercontent.com on a schedule) and by which process on the host (Sysmon 3 — a rundll32.exe talking to Discord is not Discord) |
| Domain fronting / CDN | SNI = big CDN, regular interval from one host, sni_matches_cert=F |
| DoH | TLS to DoH providers from a host that stops appearing in dns.log |
| WebSockets | http.log status_code=101 then a single long connection |
| QUIC/HTTP3 | UDP 443 with service=quic — Zeek 6+ logs quic.log with SNI |
| Peer-to-peer / SMB named-pipe C2 (Cobalt Strike SMB beacon) | No internet traffic from the victim — internal 445 to the pivot host with smb_files/dce_rpc showing named pipes (\\pipe\msagent_*, \\pipe\postex_*, \\pipe\MSSE-*); the pivot host is the one beaconing out |
Tuning out the noise¶
Build an allow-list by destination + behaviour, not by port. Things that beacon legitimately: NTP (123), Windows Update / Delivery Optimization (*.windowsupdate.com, *.delivery.mp.microsoft.com), Defender/AV cloud (*.wdcp.microsoft.com), Office/Teams presence (*.office.com, *.teams.microsoft.com), OneDrive/Dropbox/Google Drive sync, Chrome/Edge update, Slack, Zoom, Intune/SCCM/Jamf, EDR agents (know your own agent's destinations), printers, UPS/IoT to their clouds, monitoring agents (Zabbix, Nagios, Splunk UF to indexer on 9997), DNS to the resolver. Everything on that list is used by many hosts — so dc(id.orig_h) <= 3 removes most of it automatically; the rest you allow-list by SNI/domain once and document.
When you find one¶
- Freeze the facts: pair, port, first seen, interval, bytes in/out, SNI/URI/JA3, cert fingerprint.
- Pivot to
dns.logfor the name and every IP it resolved to; tohttp.log/ssl.logfor the fingerprints; tofiles.logfor anything downloaded on thoseuids. - Hunt the fleet for the same destination, same JA3, same cert, same URI pattern — implants come in sets.
- Go to the host: Sysmon 3 / EDR for the process; Prefetch / Amcache for the binary; SRUM for bytes per app; persistence keys (Registry ASEP).
- Decide: block at egress after you've collected, or you lose the beacon and the operator learns.