Beaconing and C2 channel detection

Four hunts for network-level indicators of command and control communication: high-frequency outbound connections, consistent beacon intervals, TLS client fingerprinting, and protocol-port mismatches. The network layer view complements endpoint-side C2 detection with evidence that survives endpoint log gaps and is harder for an attacker to suppress.

Data source: Zeek conn.log and ssl.log. The hunts assume log files in the current directory; adjust paths to match the collection environment. Compressed logs can be piped via zcat.

High-frequency outbound connections

Hypothesis: a beacon is checking in periodically, producing many short connections to the same external address.

# connections per source/destination/port, excluding RFC 1918 destinations
zeek-cut id.orig_h id.resp_h id.resp_p < conn.log | \
  awk '!/^#/ && $2 !~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/' | \
  sort | uniq -c | sort -rn | head -30

A workstation with hundreds of connections to a single external IP on an unusual port is worth investigating. Software update services, NTP, and time synchronisation appear in the same list and can be suppressed once confirmed legitimate.

Consistent connection intervals

Hypothesis: an implant is beaconing at a fixed interval with low timing variance, a pattern that distinguishes automated check-ins from human-driven browsing.

# write sorted connection data to a temp file, then analyse with Python
zeek-cut ts id.orig_h id.resp_h id.resp_p < conn.log | \
  awk '!/^#/' | sort -k2,2 -k3,3 -k4,4 -k1,1n > /tmp/conn_sorted.txt

python3 << 'EOF'
import math

pairs = {}
with open('/tmp/conn_sorted.txt') as f:
    for line in f:
        parts = line.split()
        if len(parts) < 4:
            continue
        ts, src, dst, port = parts[:4]
        pairs.setdefault((src, dst, port), []).append(float(ts))

for (src, dst, port), times in pairs.items():
    if len(times) < 10:
        continue
    intervals = [times[i+1] - times[i] for i in range(len(times)-1)]
    mean = sum(intervals) / len(intervals)
    if mean == 0:
        continue
    stddev = math.sqrt(sum((x - mean)**2 for x in intervals) / len(intervals))
    cv = stddev / mean
    if cv < 0.2:
        print(f"CV={cv:.3f}  count={len(times)}  interval={mean:.1f}s  {src} -> {dst}:{port}")
EOF

rm -f /tmp/conn_sorted.txt

A coefficient of variation below 0.2 across ten or more connections is unusual for legitimate traffic. Browser and application connections have high interval variance; a beacon sleeping for exactly 60 seconds has near-zero variance. Some C2 frameworks add jitter to raise the CV; the threshold for suspicion rises accordingly, but the interval distribution remains distinct from organic traffic.

TLS fingerprinting

Hypothesis: a C2 implant is presenting a recognisable TLS client hello that can be fingerprinted regardless of the destination hostname or IP.

# JA3 fingerprints seen in ssl.log, sorted by frequency
zeek-cut id.orig_h id.resp_h server_name ja3 < ssl.log | \
  awk '!/^#/ && $4 != "-" {print $4, $1, $2, $3}' | \
  sort | uniq -c | sort -rn | head -30

# check against hashes associated with known C2 defaults
# Cobalt Strike default profile:  72a589da586844d7f0818ce684948eea
# Metasploit Meterpreter default: c1b547cba89e2579e772d2e67b898c85
for hash in \
  72a589da586844d7f0818ce684948eea \
  c1b547cba89e2579e772d2e67b898c85; do
  result=$(zeek-cut id.orig_h id.resp_h server_name ja3 < ssl.log | \
    awk -v h="$hash" '!/^#/ && $4 == h {print $1, $2, $3}')
  [ -n "$result" ] && echo "=== $hash ===" && echo "$result"
done

JA3 hashes are bypassable: a motivated attacker randomises the TLS client hello. Their value is catching frameworks using default configurations, which describes a large proportion of intrusions in practice. The hash is a property of the client library and settings, not the payload; changing it requires recompiling or reconfiguring the implant.

Protocol-port mismatches

Hypothesis: C2 traffic is disguised on port 80 or 443 but carries a binary protocol that does not match the expected service.

# service type identified by Zeek on common web ports
zeek-cut id.orig_h id.resp_h id.resp_p service < conn.log | \
  awk '!/^#/ && $4 != "-" && $4 != "" {
    port = $3
    svc  = $4
    if ((port == "443" && svc != "ssl") ||
        (port == "80"  && svc != "http"))
      printf "port=%s service=%s  %s -> %s\n", port, svc, $1, $2
  }' | sort | uniq -c | sort -rn | head -20

# connections on uncommon ports with significant data transfer
zeek-cut id.orig_h id.resp_h id.resp_p orig_bytes resp_bytes < conn.log | \
  awk '!/^#/ &&
       $3 !~ /^(80|443|22|53|25|587|465|143|993|110|995|21|3306|5432|3389|8080|8443)$/ &&
       ($4 + $5) > 100000 {
    print $3, $4+$5, $1, $2
  }' | sort -k2 -rn | head -20

A service value of dtls, an empty string, or a protocol name inconsistent with the port is worth a second look. Zeek’s protocol detection is not perfect, but a connection on port 443 that does not complete a TLS handshake is a reliable anomaly.