Catching Encrypted C2 Beacons with Delta Timing and Jitter Math
TLS didn’t kill network threat hunting; it just forced us to stop relying on lazy payload signatures. Modern Command and Control (C2) frameworks like Cobalt Strike, Sliver, and Havoc encrypt every payload by default, rendering traditional IDS signatures useless on unintercepted traffic. But while an attacker can easily hide what they are sending, they rarely obscure how they send it: an automated agent running on an infected host, executing a sleep loop, and polling a remote server at structured intervals.
That underlying state machine leaves an unmistakable mathematical footprint in flow metadata. You don’t need to decrypt a single byte to spot a beacon—you just need packet timestamps, basic descriptive statistics, and a way to filter out normal background noise.
Extracting Raw Flow Metadata with Tshark
To analyze beaconing behaviors, you need structured timing data from packet captures. While full NetFlow/IPFIX or Zeek conn.log files work best in production enterprise environments, tshark gives us an immediate, reproducible way to slice a raw PCAP into usable CSV metadata.
Run the following command to dump frame timestamps, IP pairs, destination ports, and payload sizes from a pcap:
tshark -r enterprise_traffic.pcap \
-Y "ip and (tcp.flags.syn == 1 and tcp.flags.ack == 0 or udp)" \
-T fields \
-e frame.time_epoch \
-e ip.src \
-e ip.dst \
-e tcp.dstport \
-e udp.dstport \
-e frame.len \
-E header=y -E separator=, -E quote=d > flow_metadata.csv
Notice that we filter specifically for TCP connection initiations (syn == 1 and ack == 0) and UDP flows. Tracking individual TCP handshake attempts or initial UDP bursts isolates the check-in event rather than getting confused by variable packet counts inside a single long-lived session.
Calculating Inter-Arrival Times and Jitter in Python
Once you have timestamps for every session initiation, the core analytical metric is Inter-Arrival Time (IAT)—the delta in seconds between consecutive connections from the same source to the same destination.
A perfectly fixed beacon contacting an IP every 60 seconds has a standard deviation of 0. Attackers know this, so C2 frameworks include a “jitter” setting (e.g., 20% jitter on a 60s sleep means the delay varies randomly between 48s and 72s). However, uniform random jitter added to a fixed base sleep still produces a highly distinct standard deviation compared to human browsing or bursty application APIs.
We measure this using the Coefficient of Variation ($CV$), defined as $CV = \frac{\sigma}{\mu}$ (standard deviation divided by the mean). A low $CV$ indicates consistent, regular timing regardless of whether the base interval is 5 seconds or 5 minutes.
Here is a complete Python script using pandas to group flows and calculate $CV$:
import pandas as pd
import numpy as np
# Load flow data
df = pd.read_csv('flow_metadata.csv')
# Merge port columns into a single destination port
df['dstport'] = df['tcp.dstport'].fillna(df['udp.dstport'])
df = df.drop(columns=['tcp.dstport', 'udp.dstport'])
# Sort chronologically
df['frame.time_epoch'] = df['frame.time_epoch'].astype(float)
df = df.sort_values('frame.time_epoch')
# Group by unique connection tuples
grouped = df.groupby(['ip.src', 'ip.dst', 'dstport'])
results = []
for (src, dst, port), group in grouped:
# Need at least 5 connections to establish a statistical trend
if len(group) < 5:
continue
# Calculate IAT (deltas between consecutive timestamps)
timestamps = group['frame.time_epoch'].values
iats = np.diff(timestamps)
mean_iat = np.mean(iats)
std_iat = np.std(iats)
# Avoid division by zero for identical timestamps
cv = std_iat / mean_iat if mean_iat > 0 else 999.0
results.append({
'src': src,
'dst': dst,
'port': port,
'connection_count': len(group),
'mean_iat_sec': round(mean_iat, 2),
'std_iat_sec': round(std_iat, 2),
'cv': round(cv, 4)
})
results_df = pd.DataFrame(results)
# Filter for low Coefficient of Variation (high regularity)
suspicious_beacons = results_df[
(results_df['cv'] < 0.35) &
(results_df['mean_iat_sec'] > 2.0)
].sort_values('cv')
print(suspicious_beacons.to_string(index=False))
A $CV$ below 0.35 catches default beacon behavior across almost all public C2 frameworks, even when attackers configure up to 30% jitter.
Stripping False Positives Without Full Decryption
Running the script above against raw enterprise traffic will immediately flag legitimate background noise: telemetry endpoints (Microsoft, Google), NTP syncs, cloud storage polling, and operational keep-alives. You don’t need SSL inspection to clear these false positives; you just need contextual filtering based on network metadata.
1. Identify SNI via Unencrypted TLS Client Hellos
Even if payload bytes are encrypted, the Server Name Indication (SNI) extension in the initial TLS Client Hello is sent in plaintext (unless Encrypted Client Hello/ECH is enforced). Extract SNIs for your flagged IP destinations:
tshark -r enterprise_traffic.pcap \
-Y "tls.handshake.type == 1" \
-T fields \
-e ip.dst \
-e tls.handshake.extensions_server_name | sort -u
If a flagged destination maps to *.trafficmanager.net or *.ubuntu.com, cross-reference its update behavior before declaring an incident.
2. Destination IP Entropy and Categorization
C2 servers rarely reside on major Content Delivery Networks (CDNs) without complex fronting configurations. Check destination ASNs using whois or local GeoIP databases. An isolated /32 IP belonging to a cheap VPS provider (DigitalOcean, Linode, Hetzner) that exhibits a $CV < 0.3$ is a high-confidence indicator of compromise. Conversely, an IP belonging to AS16509 (Amazon) serving thousands of distinct local hosts is usually shared infrastructure.
3. Byte Mass Consistency
Human web browsing features wildly inconsistent frame lengths—downloading an HTML page, then a heavy image asset, then submitting a form. C2 check-ins with no queued commands feature virtually identical packet lengths on every iteration (e.g., a fixed HTTP GET request returning a 0-byte or fixed 200-OK response).
If standard deviation of packet lengths (frame.len) approaches zero alongside a low IAT $CV$, raise the alert priority immediately.
Operationalizing the Hunt
In an operational environment, running raw PCAP processing scripts won’t scale across gigabytes of daily traffic. To use this approach effectively in production:
- Push computation to your log engine: Convert the math into native Query Language in Splunk, Elastic, or Snowflake. Group your
connlogs by(src_ip, dest_ip, dest_port)over 1-hour or 4-hour windows. CalculateAVG(duration),STDDEV(duration), and evaluate $CV$. - Alert on low-frequency, persistent beacons: High-frequency beacons (1s sleep) get caught quickly, but sophisticated operators use 15-minute or 1-hour sleep intervals. Expand your time windows to 24 hours to ensure you capture at least 20-30 data points for slow beacons.
- Automate threat scoring: Combine the $CV$ score with destination domain age (registered < 30 days ago) and ASN reputation. A target with $CV < 0.25$, an unknown SNI, and a newly registered host address is no longer a generic threat hunt lead—it’s an active incident.
Related content
Why Signatures Will Never Catch Living-off-the-Land Attacks
ResearchAnatomy of a Modern Supply Chain Attack — And Where Defenses Actually Break
ResearchFixing Broken Sudoers: From NOPASSWD Script Abuse to Strict Least Privilege
ResearchFrom ATT&CK Technique to Production Query: Hunting LSASS Memory Dumping
Want a second set of eyes on your security posture?
Let's talk about where your real exposure is.
Book an advisory call