C2 beaconing is the network heartbeat of post-exploitation activity. The attacker’s implant makes periodic connections to receive commands and send results. The periodic, programmatic nature of this traffic is statistically distinguishable from human-driven browsing — if you know what to measure.
Beacon Math: What Makes C2 Statistical
The Interval Distribution
Human web browsing generates connections with high variance in timing — you browse, read, click, distracted by a meeting. The inter-request intervals range from seconds to hours. Coefficient of variation (CV) for human traffic is typically high (> 1.0).
A C2 beacon configured to fire every 60 seconds looks like this:
Connection intervals (seconds): 61, 59, 62, 58, 60, 61, 60, 59
Mean: 60.0
Std Dev: 1.2
CV: 1.2 / 60.0 = 0.02
CV of 0.02 is extremely low — this is programmatic. Real-world beacons with 10% jitter:
Connection intervals: 55, 67, 58, 63, 52, 71, 61, 59
Mean: 60.75
Std Dev: 5.8
CV: 5.8 / 60.75 = 0.096
CV threshold for suspicion: CV < 0.3 for a series of 10+ connections to the same destination.
The Byte Size Pattern
Beacons often send check-in packets with fixed or nearly-fixed sizes:
orig_bytes per connection: 512, 512, 514, 512, 516, 512
resp_bytes per connection: 128, 0, 128, 256, 128, 128
The originator (implant) sends a fixed-size check-in. The responder (C2 server) sends commands (variable) or 0 (nothing to do).
The Duration and Count Pattern
# C2 beacon over 4 hours, check-in every 60s:
Total connections: ~240 to same destination
Total duration: 4 hours
Unique destinations: 1 (the C2 IP)
# Human browsing over 4 hours:
Total connections: hundreds, to dozens of different destinations
Connections per destination: typically 1-10
Detecting Beacons in Zeek conn.log
KQL / SPL query approach:
// Zeek conn.log in Sentinel
Zeek_conn_CL
| where TimeGenerated > ago(24h)
| where local_orig_b == true // internal host
| where local_resp_b == false // external destination
| summarize
conn_count = count(),
mean_interval = avg(duration_d),
avg_orig_bytes = avg(orig_bytes_l),
avg_resp_bytes = avg(resp_bytes_l),
unique_sessions = dcount(uid_s)
by id_resp_h_s, id_resp_p_i // group by dst IP+port
| where conn_count > 20 // enough samples to be meaningful
| order by conn_count desc
Then add interval calculation (requires time-series analysis on ordered events per destination).
Manual beacon identification workflow:
- Filter conn.log: one source IP → one destination IP
- Extract timestamps, sort ascending
- Calculate deltas between consecutive connections
- Compute mean and std dev of deltas
- CV < 0.3 and count > 20 = investigate
C2 over HTTPS — The Encryption Problem
Most modern C2 uses HTTPS (TLS on port 443) to blend with legitimate web traffic. You can’t see the content, but ssl.log gives you metadata:
Indicators in ssl.log:
| Indicator | Suspicious Value |
|---|---|
server_name | Random or generic (CDN-like domains, no-SNI) |
ja3 | Matches known malware tool |
validation_status | Certificate invalid or self-signed |
cert_chain_fuids | Short cert chain (1-2 certs vs. typical 3) |
Certificate metadata (x509.log):
# Legitimate cert:
subject=CN=api.example.com, O=Example Corp, C=US
issuer=DigiCert Global Root CA
not_valid_after=2025-08-14 ← 1-2 year validity
san=api.example.com
# C2 cert:
subject=CN=localhost
issuer=CN=localhost ← self-signed
not_valid_after=2025-02-14 ← short validity (90 days = Let's Encrypt or auto-gen)
san= ← no SAN
Self-signed certs on port 443 with no valid chain are highly suspicious. Combined with JA3 match and beacon pattern, this is a confirmed C2 channel.
Protocol Tunnelling Detection
DNS Tunnelling
See M17 for the core detection. Additional queries in dns.log:
# Calculate average query length per destination domain
Zeek_dns_CL
| extend domain = tostring(split(query_s, ".")[-2]) // extract registered domain
| summarize
avg_label_len = avg(strlen(query_s)),
query_count = count(),
unique_query_types = dcount(qtype_name_s)
by domain
| where avg_label_len > 40 and query_count > 100
Labels over 40 chars + TXT or NULL queries + high volume = DNS tunnelling.
ICMP Tunnelling
# Check ICMP in conn.log for abnormal payload sizes
Zeek_conn_CL
| where proto_s == "icmp"
| where orig_bytes_l > 1000 or resp_bytes_l > 1000 // normal ping = 32-64 bytes
| project TimeGenerated, id_orig_h_s, id_resp_h_s, orig_bytes_l, resp_bytes_l
Normal ping: 32 bytes. Tunnelling: kilobytes per exchange.
HTTP Tunnelling
C2 over HTTP uses standard HTTP but the content is C2 traffic:
# Indicators in http.log:
user_agent="" # empty user-agent (no browser)
method=POST + large request_body_len # uploading data
uri=/update or /check or /heartbeat # C2-like paths
status_code=200 + response_body_len=0 # server acknowledges, sends nothing