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.
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
#!/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}")
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.