>samit_hota
Back to research
Offensive Security

Catching Encrypted C2 Beacons with Delta Timing and Jitter Math

Samit Hota·
#threat-hunting#c2#network-security#python

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:

  1. Push computation to your log engine: Convert the math into native Query Language in Splunk, Elastic, or Snowflake. Group your conn logs by (src_ip, dest_ip, dest_port) over 1-hour or 4-hour windows. Calculate AVG(duration), STDDEV(duration), and evaluate $CV$.
  2. 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.
  3. 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.

Want a second set of eyes on your security posture?

Let's talk about where your real exposure is.

Book an advisory call