Lesson 2 of 6 · 30 min read

C2 Beaconing Detection — Statistics and Signatures

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:

  1. Filter conn.log: one source IP → one destination IP
  2. Extract timestamps, sort ascending
  3. Calculate deltas between consecutive connections
  4. Compute mean and std dev of deltas
  5. 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:

IndicatorSuspicious Value
server_nameRandom or generic (CDN-like domains, no-SNI)
ja3Matches known malware tool
validation_statusCertificate invalid or self-signed
cert_chain_fuidsShort 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