The C2 Beaconing Problem
Modern C2 frameworks — CobaltStrike, Sliver, Havoc, Mythic — all share a common operational pattern: the implant periodically “checks in” with an operator-controlled server to receive tasks and upload results. This beacon interval, usually configurable from seconds to hours, is the mechanism that makes long-term access durable.
The challenge for defenders is that a single connection from a workstation to an internet IP doesn’t tell you much. But regular, periodic connections to the same destination are statistically unusual in normal user traffic and highly characteristic of automated beacon behavior.
Understanding Beacon Characteristics
A typical beacon exhibits:
- Fixed sleep interval — e.g., every 60 seconds
- Jitter — randomness added to avoid fixed-interval signatures (CobaltStrike’s jitter is
sleep * jitter%) - Small GET/POST requests — check-in payloads are usually <1KB
- Consistent User-Agent — implants typically don’t rotate UA strings unless configured
- Low byte variance — beacon check-ins have predictable sizes; the implant only sends large data when exfiltrating
Detection Approach 1: Beacon Interval Analysis
The core technique is measuring time deltas between successive connections from the same source to the same destination.
KQL — CobaltStrike Beacon Interval Detection
// Microsoft Sentinel — using CommonSecurityLog (firewall/proxy)
CommonSecurityLog
| where TimeGenerated > ago(24h)
| where DeviceAction != "Deny"
| where DestinationPort in (80, 443, 8080, 8443)
| summarize
ConnectionTimes = make_list(TimeGenerated, 500),
TotalConnections = count()
by SourceIP, DestinationIP, DestinationPort
| where TotalConnections > 20
| extend
// Calculate deltas between successive connections
Deltas = array_sort_asc(ConnectionTimes)
| mv-expand ConnectionTime = ConnectionTimes to typeof(datetime)
| sort by SourceIP asc, DestinationIP asc, ConnectionTime asc
| extend
PrevTime = prev(ConnectionTime, 1),
SameFlow = SourceIP == prev(SourceIP, 1) and DestinationIP == prev(DestinationIP, 1)
| where SameFlow
| extend DeltaSeconds = datetime_diff('second', ConnectionTime, PrevTime)
| where DeltaSeconds > 0 and DeltaSeconds < 3600
| summarize
AvgDelta = avg(DeltaSeconds),
StdDev = stdev(DeltaSeconds),
Connections = count()
by SourceIP, DestinationIP, DestinationPort
| where Connections > 15
| extend JitterRatio = StdDev / AvgDelta
| where JitterRatio < 0.35 // Low variance = consistent timing = likely beacon
| where AvgDelta between (10 .. 3600) // Reasonable beacon sleep range
| project SourceIP, DestinationIP, DestinationPort, AvgDelta, StdDev, JitterRatio, Connections
| sort by JitterRatio asc
The key metric is the jitter ratio — standard deviation divided by mean delta. For human-driven browsing, this ratio is high (random behavior). For beacons with low or zero jitter configured, this ratio approaches 0.
A JitterRatio below 0.15 with 20+ connections is extremely suspicious. Below 0.05 is almost certainly automated.
Detection Approach 2: DNS Tunneling Detection
DNS tunneling tools like iodine, dnscat2, and dns2tcp encode data in DNS query/response records to exfiltrate data or establish C2 channels over port 53 — a protocol most firewalls allow outbound.
Characteristics of DNS Tunneling
- High query frequency to a single domain
- Unusually long hostnames (data encoded as subdomains)
- High entropy subdomain labels — random-looking vs dictionary words
- TXT and NULL record types — used for data encoding
- High unique subdomain count per parent domain
KQL — DNS Query Volume Anomaly
// Using Sysmon Event 22 (DNS Query) or DNS server logs
Event
| where Source == "Microsoft-Windows-Sysmon" and EventID == 22
| extend QueryName = extract(@"QueryName: (.+)", 1, RenderedDescription)
| extend ParentDomain = extract(@"(?:[^.]+\.){0,10}([^.]+\.[^.]+)$", 1, QueryName)
| where isnotempty(ParentDomain)
| summarize
UniqueSubdomains = dcount(QueryName),
TotalQueries = count(),
AvgLabelLength = avg(strlen(QueryName))
by Computer, ParentDomain, bin(TimeGenerated, 1h)
| where UniqueSubdomains > 50
| where AvgLabelLength > 40 // Long encoded subdomains
| sort by UniqueSubdomains desc
High Entropy Subdomain Detection
DNS tunneling tools encode binary data as hex or base32, producing high-entropy subdomain labels. Legitimate subdomains like mail.example.com have low entropy — they’re human-readable words.
# Python script for offline entropy analysis of DNS logs
import math
import re
from collections import Counter
def shannon_entropy(s: str) -> float:
"""Calculate Shannon entropy of a string."""
counts = Counter(s.lower())
total = len(s)
return -sum((c/total) * math.log2(c/total) for c in counts.values())
def is_suspicious_subdomain(fqdn: str, entropy_threshold: float = 3.5) -> bool:
"""Flag FQDNs with high-entropy subdomain labels."""
labels = fqdn.split('.')
# Check all labels except the TLD and registered domain
for label in labels[:-2]:
if len(label) > 20 and shannon_entropy(label) > entropy_threshold:
return True
return False
# Example usage
test_domains = [
"abjxkzmnqwrtyuioplkjhgfdsazxcvbn.evil.com", # High entropy
"mail.google.com", # Low entropy
"api.v2.service.example.com", # Low entropy
"xkcd7283badf00d1337cafe.tunnel.io", # High entropy
]
for domain in test_domains:
labels = domain.split('.')
sub = labels[0]
ent = shannon_entropy(sub)
suspicious = is_suspicious_subdomain(domain)
print(f"{'[ALERT]' if suspicious else '[OK]'} {domain} — entropy: {ent:.2f}")
Detection Approach 3: Payload Size Patterns
C2 check-ins are small; C2 responses with tasking are also small. But legitimate web traffic shows much higher variance. Look for sessions where:
- Connections are frequent (> 30/hour)
- Request sizes cluster tightly (low variance)
- Response sizes are consistently small (< 500 bytes)
// Proxy/firewall log with byte counts
CommonSecurityLog
| where TimeGenerated > ago(24h)
| where DestinationPort in (80, 443)
| summarize
SessionCount = count(),
AvgRequestBytes = avg(SentBytes),
AvgResponseBytes = avg(ReceivedBytes),
StdDevRequest = stdev(SentBytes),
StdDevResponse = stdev(ReceivedBytes)
by SourceIP, DestinationIP, bin(TimeGenerated, 1h)
| where SessionCount > 20
| where AvgResponseBytes < 1000
| where StdDevResponse < 200 // Very consistent response sizes
| where StdDevRequest < 100
| sort by SessionCount desc
Tuning Considerations
Legitimate high-frequency destinations to baseline and exclude:
- Windows Update servers (
windowsupdate.microsoft.com,update.microsoft.com) - Telemetry endpoints (
vortex.data.microsoft.com, similar) - Monitoring agents (your Splunk/Elastic UF, CrowdStrike, etc.)
- CDN health checks from infrastructure
Build a dynamic allowlist of these destinations. Compare new high-frequency destinations to the baseline before alerting.
Jitter pitfall: CobaltStrike with 50%+ jitter will have a higher variance ratio. Combine interval analysis with other signals — payload size, User-Agent consistency, geographic destination — rather than relying on a single metric.
Prioritizing Hunts
Not every potential beacon is a C2 implant. Prioritize investigation when you see:
- Beaconing to a recently registered domain (< 30 days old)
- Destination IP with no reverse DNS
- Destination is a cloud hosting provider (AS13335, AS14618, AS16509) rather than a known CDN
- User-Agent string that doesn’t match the installed browser
- HTTP over non-standard port (e.g., TCP 4444, 8443 to a fresh IP)
Any two of these together warrants immediate escalation.