Lesson 2 of 5 · 30 min read

Beacon Hunting — Interval Analysis, Jitter, and Byte Signatures

Beacon hunting is statistical pattern recognition. The adversary’s implant fires on a timer — and timers produce patterns that human behavior does not. The hunter’s job is to separate programmatic from human in a sea of connections.


The Multi-Factor Beacon Score

No single metric reliably identifies beacons without flooding results with benign programmatic traffic (software updates, telemetry, NTP). The RITA model uses four factors:

Factor 1: Connection Rate to Destination

// Connections per hour from single host to single external destination
Zeek_conn_CL
| where TimeGenerated > ago(24h)
| where local_orig_b == true and local_resp_b == false
| summarize conn_count = count(), 
            unique_hours = dcount(bin(TimeGenerated, 1h))
  by id_orig_h_s, id_resp_h_s, id_resp_p_i
| extend conn_rate_per_hour = toreal(conn_count) / toreal(unique_hours)
| where conn_rate_per_hour > 20   // >20 connections/hour = programmatic

Factor 2: Interval Regularity (CV)

Computing true interval statistics requires time-series ordered data. In Sentinel/KQL:

// Simplified: compute interval from sorted timestamps per dst
let src = "10.1.2.3";
let dst = "91.213.50.5";
Zeek_conn_CL
| where id_orig_h_s == src and id_resp_h_s == dst
| order by TimeGenerated asc
| extend prev_time = prev(TimeGenerated)
| extend interval_sec = datetime_diff('second', TimeGenerated, prev_time)
| where isnotnull(interval_sec) and interval_sec > 0
| summarize 
    mean_interval = avg(interval_sec),
    std_interval = stdev(interval_sec),
    conn_count = count()
| extend cv = std_interval / mean_interval
| where conn_count > 20  // enough samples

CV interpretation:

Threshold for flagging: CV < 0.30 is the standard beacon threshold. CV < 0.60 is worth investigation when combined with other factors.

Factor 3: Byte-Size Consistency

Zeek_conn_CL
| where id_orig_h_s == src and id_resp_h_s == dst
| summarize 
    mean_orig = avg(orig_bytes_l),
    std_orig = stdev(orig_bytes_l),
    mean_resp = avg(resp_bytes_l),
    std_resp = stdev(resp_bytes_l),
    conn_count = count()
| extend cv_bytes = std_orig / (mean_orig + 1)  // +1 avoids div/0

Fixed check-in packets produce CV_bytes < 0.10. Variable-size traffic (HTTP responses) produces CV_bytes > 0.50.

Factor 4: Peer Cohort Filter

// How many internal hosts talk to this destination?
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) by id_resp_h_s
// Beacon candidates: host_count == 1 (only this host) or <=5

Safe Harboring — The FP Killer

Without exclusions, beacon hunts return thousands of results dominated by:

Build your safe harbor list:

let safe_harbor_ips = dynamic(["13.107.0.0/14", "52.96.0.0/14",  // Microsoft
                                "8.8.8.8", "1.1.1.1",             // DNS resolvers
                                "17.0.0.0/8",                     // Apple
                                "216.58.0.0/16"]);                 // Google
// Exclude in beacon hunt:
| where not(ipv4_is_in_range(id_resp_h_s, "13.107.0.0/14"))
// Or use: ASN-based exclusion for Microsoft/Akamai/Cloudflare

ASN-based exclusion is more robust than IP-based because CDN IPs change frequently:

# ASNs to always safe-harbor in beacon hunts:
AS8075  Microsoft (update.microsoft.com, office365.com)
AS16509 Amazon AWS (may include legitimate SaaS)
AS13335 Cloudflare (may be C2 — use with caution, exclude only for >=100-host destinations)
AS15169 Google
AS20940 Akamai

The Long-Connection Alternative

When beacon interval math is unreliable (sampled data, high jitter), hunt for long connections instead. C2 sessions that maintain persistent connections appear as single conn.log entries with very long duration.

Zeek_conn_CL
| where TimeGenerated > ago(7d)
| where local_orig_b == true and local_resp_b == false
| where duration_d > 3600           // > 1 hour
| where service_s !in ("ssl", "http") or isempty(service_s)  // prefer unusual services
| summarize 
    session_count = count(),
    total_duration = sum(duration_d),
    max_resp_bytes = max(resp_bytes_l)
  by id_orig_h_s, id_resp_h_s, id_resp_p_i
| where session_count > 5           // recurring long sessions
| order by total_duration desc

Long connections + non-standard service + non-CDN destination = high-priority investigation.