Chapter 23

Beaconing Detection in Python

Beaconing detection is one of the most powerful network forensics techniques for finding C2 — even when the traffic is encrypted and you can't see the payload. A C2 beacon checks in at regular intervals, creating a statistically distinct pattern in connection timing. The Coefficient of Variation (CV = stddev/mean) of inter-connection intervals is the key metric: low CV means regular intervals, which means automated behavior, which is a strong C2 indicator.

Scenario

A Cobalt Strike beacon is configured to check in every 60 seconds with 15% jitter. Every packet is TLS-encrypted — you can't see the payload. But the TCP SYN timestamps tell you exactly when each check-in occurred. The intervals are [58.2, 61.4, 59.8, 62.1, 57.9, 60.3, 61.0, ...]. Mean = 60.1s, stddev = 1.5s, CV = 0.025. This CV is orders of magnitude lower than any legitimate application's connection pattern. Your Python beaconing detector flags this as a high-confidence beacon.

The Mathematics of Beaconing Detection

  Coefficient of Variation (CV) for Beaconing Detection
  ═══════════════════════════════════════════════════════════════════

  Given N connection timestamps: t₁, t₂, t₃, ... tₙ

  Intervals: Δᵢ = tᵢ₊₁ - tᵢ  (N-1 intervals for N timestamps)

  Mean (μ): average interval
    μ = (Σ Δᵢ) / (N-1)

  Standard Deviation (σ): measure of interval variation
    σ = sqrt(Σ(Δᵢ - μ)² / (N-1))

  Coefficient of Variation (CV): normalized measure of variation
    CV = σ / μ

  Interpretation:
  ├── CV < 0.10: extremely regular → very likely beaconing
  ├── CV 0.10–0.20: regular → likely beaconing (consider jitter setting)
  ├── CV 0.20–0.50: moderate variation → possible beaconing, investigate
  ├── CV > 0.50: high variation → probably not beaconing
  └── CV > 1.0: very high variation → almost certainly not beaconing

  Typical CV values by connection type:
  ├── Cobalt Strike beacon (0% jitter): CV ≈ 0.001
  ├── Cobalt Strike beacon (15% jitter): CV ≈ 0.05–0.10
  ├── Cobalt Strike beacon (50% jitter): CV ≈ 0.20–0.30
  ├── Windows Update check (human-initiated): CV > 1.0
  ├── Chrome browser activity: CV >> 1.0 (very irregular)
  └── NTP sync (legitimate timer): CV < 0.01 but consistent with known servers

  Minimum sample size: N ≥ 10 connections for statistical significance
  Recommended: N ≥ 20 for high confidence; flag N < 10 as "insufficient data"

Complete Beaconing Detector

Pythonbeaconing-detector.py
#!/usr/bin/env python3
"""
Beaconing detector for network forensics.
Input: Zeek conn.log or tshark CSV of SYN timestamps
Output: ranked list of beaconing candidates with CV scores
"""
import subprocess, sys, csv, json, math
from collections import defaultdict
from pathlib import Path

# ── CONFIG ────────────────────────────────────────────────────────────
CV_THRESHOLD = 0.20      # Lower CV = more regular = more suspicious
MIN_CONNECTIONS = 10     # Minimum connections to compute meaningful CV
MIN_DURATION_HOURS = 0.5 # Ignore sessions shorter than 30 minutes
EXCLUDE_PORTS = {123, 53, 67, 68, 443, 80}  # NTP, DNS, DHCP worth investigating separately

# ── DATA EXTRACTION ───────────────────────────────────────────────────
def extract_from_pcap(pcap_file: str) -> dict:
    """
    Extract SYN timestamps using tshark.
    Returns dict: {(src_ip, dst_ip, dst_port): [timestamp1, timestamp2, ...]}
    """
    cmd = [
        "tshark", "-r", pcap_file, "-n",
        "-Y", "tcp.flags.syn==1 and not tcp.flags.ack",
        "-T", "fields",
        "-E", "separator=\t",
        "-e", "frame.time_epoch",
        "-e", "ip.src",
        "-e", "ip.dst",
        "-e", "tcp.dstport",
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
    flows = defaultdict(list)
    for line in result.stdout.splitlines():
        parts = line.split("\t")
        if len(parts) < 4:
            continue
        try:
            ts, src, dst, dport = float(parts[0]), parts[1], parts[2], int(parts[3])
            if dport not in EXCLUDE_PORTS:
                flows[(src, dst, dport)].append(ts)
        except (ValueError, IndexError):
            pass
    return flows

def extract_from_zeek_conn_log(conn_log: str) -> dict:
    """Extract from Zeek conn.log (tab-separated)."""
    flows = defaultdict(list)
    with open(conn_log) as f:
        for line in f:
            if line.startswith("#"):
                continue
            parts = line.strip().split("\t")
            if len(parts) < 7:
                continue
            try:
                ts = float(parts[0])
                src_ip = parts[2]
                dst_ip = parts[4]
                dst_port = int(parts[5])
                proto = parts[6]
                if proto == "tcp" and dst_port not in EXCLUDE_PORTS:
                    flows[(src_ip, dst_ip, dst_port)].append(ts)
            except (ValueError, IndexError):
                pass
    return flows

# ── ANALYSIS ──────────────────────────────────────────────────────────
def compute_cv(timestamps: list) -> tuple[float, float, float, int]:
    """Returns (mean, stddev, cv, count) or (None, None, None, count)."""
    n = len(timestamps)
    if n < MIN_CONNECTIONS:
        return None, None, None, n
    intervals = [timestamps[i+1] - timestamps[i] for i in range(n-1)]
    # Filter out negative intervals (clock skew or out-of-order) and >1 hour gaps
    intervals = [iv for iv in intervals if 0 < iv < 3600]
    if len(intervals) < MIN_CONNECTIONS - 1:
        return None, None, None, n
    mean = sum(intervals) / len(intervals)
    if mean < 1:  # Ignore sub-second mean intervals (port scans, not beacons)
        return None, None, None, n
    variance = sum((iv - mean) ** 2 for iv in intervals) / len(intervals)
    stddev = math.sqrt(variance)
    cv = stddev / mean
    return mean, stddev, cv, n

def analyze_beaconing(flows: dict) -> list:
    """Analyze all flows and return ranked beaconing candidates."""
    results = []
    for (src, dst, dport), timestamps in flows.items():
        timestamps.sort()
        # Check minimum duration
        duration_hours = (timestamps[-1] - timestamps[0]) / 3600
        if duration_hours < MIN_DURATION_HOURS:
            continue

        mean, stddev, cv, count = compute_cv(timestamps)
        if cv is None:
            continue

        if cv <= CV_THRESHOLD:
            confidence = "HIGH" if cv < 0.1 else "MEDIUM" if cv < 0.2 else "LOW"
            results.append({
                "src": src,
                "dst": dst,
                "dstport": dport,
                "count": count,
                "cv": round(cv, 4),
                "mean_interval_sec": round(mean, 1),
                "stddev_sec": round(stddev, 2),
                "duration_hours": round(duration_hours, 1),
                "confidence": confidence,
            })

    return sorted(results, key=lambda x: x["cv"])

# ── MAIN ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
    input_file = sys.argv[1]

    if input_file.endswith(".pcap") or input_file.endswith(".pcapng"):
        print(f"Extracting from PCAP: {input_file}")
        flows = extract_from_pcap(input_file)
    else:
        print(f"Reading Zeek conn.log: {input_file}")
        flows = extract_from_zeek_conn_log(input_file)

    print(f"Analyzing {len(flows)} unique flows...")
    candidates = analyze_beaconing(flows)

    print(f"\n=== Beaconing Candidates (CV ≤ {CV_THRESHOLD}) ===")
    print(f"{'Confidence':8}  {'CV':6}  {'Interval':8}  {'Count':6}  {'Hours':6}  Connection")
    print("-" * 80)
    for c in candidates[:30]:
        print(f"{c['confidence']:8}  {c['cv']:6.4f}  {c['mean_interval_sec']:8.1f}s  "
              f"{c['count']:6d}  {c['duration_hours']:6.1f}h  "
              f"{c['src']} → {c['dst']}:{c['dstport']}")

    # Write full results
    out_file = "beaconing_candidates.json"
    with open(out_file, "w") as f:
        json.dump(candidates, f, indent=2)
    print(f"\nFull results: {out_file}")
Common mistake: flagging NTP and Windows Update as beaconing

NTP (UDP 123) runs every 8–15 minutes from most Windows workstations — extremely regular intervals with low CV. Windows Update checks run at scheduled times. Chrome's Safe Browsing update runs every 30 minutes. These are all legitimate beaconing-like patterns that will be flagged if you don't exclude them. The exclusion strategy: (1) Exclude traffic to known NTP server IPs (time.windows.com, pool.ntp.org IP ranges), (2) Exclude UDP 123 entirely, (3) Exclude traffic to Microsoft/Google/Apple ASN ranges from the beaconing analysis (but keep them in IOC extraction for completeness), (4) Check the process name from endpoint EDR if available — if the connection comes from chrome.exe or svchost.exe hosting W32TM, it's legitimate. Process-to-network correlation is the ultimate false-positive reducer for beaconing detection.

Q & A

Q: A beacon uses sleep + jitter with 50% randomness. Can beaconing detection still work?

With 50% jitter (typical: sleep = base_interval ± 50% of base), the CV will be around 0.28–0.33 — above the typical 0.2 threshold. A standard CV detector will miss it. More advanced techniques for high-jitter beacons: (1) Extended sampling window: with enough data points (30+ connections), even 50% jitter still produces a mean that converges on the base interval. The CV threshold needs to be relaxed to 0.35–0.40. (2) Interval histogram analysis: even with jitter, connections cluster around the base interval. A histogram of intervals should show a bell-curve centered at the base interval — random legitimate traffic shows no such clustering. (3) Fourier analysis (FFT): apply FFT to a time series of the connection timestamps. Periodic beaconing produces a peak at the beacon frequency, even with jitter. (4) Size consistency: Cobalt Strike beacons with high jitter still send consistent payload sizes at check-in. Even if timing is irregular, consistent payload sizes indicate automated behavior. For very sophisticated operators (>75% jitter), beaconing detection from timing alone may be insufficient — endpoint process analysis becomes necessary.