Chapter 48

Zeek Threat Detection

This chapter covers a library of production-ready Zeek-based detections for the most common attack techniques seen in enterprise incidents. Each detection is grounded in the attack technique's actual network fingerprint, includes the Zeek query or script needed to implement it, and notes the expected false positive rate and tuning approach.

Scenario

A detection engineer is building a Zeek-based detection library for a new SOC. They need detections for: lateral movement via SMB, credential theft via NTLM capture, C2 beaconing, data exfiltration, and phishing infrastructure. Each detection needs to fire with enough specificity that the SOC's analysts can investigate every alert — ideally fewer than 20 high-confidence alerts per day in a 1000-host environment.

Lateral Movement Detection

bashzeek-lateral-movement.sh
#!/bin/bash
LOG_DIR="${1:-/opt/zeek/logs/current}"

echo "=== SMB lateral movement: new connections to port 445 ==="
# Focus on hosts that made more than 3 unique SMB connections in 1 hour
awk 'NR>8 && !/^#/' "$LOG_DIR/conn.log" | \
  awk -F'\t' '$6 == "445" {print $3"\t"$5}' | \
  sort -u | awk -F'\t' '{print $1}' | sort | uniq -c | sort -rn | \
  awk '$1 > 3 {print "SMB_fan_out hosts=" $1 " src=" $2}'

echo ""
echo "=== PsExec indicator: SMB to ADMIN$ followed by svcctl pipe ==="
# In SMB logs, look for tree connects to ADMIN$ or C$
awk 'NR>8 && !/^#/' "$LOG_DIR/conn.log" | \
  awk -F'\t' '$6 == "445" && $7 == "smb" {print $1"\t"$3"\t"$5"\t"$9}' | \
  head -20

echo ""
echo "=== Lateral movement via WMI (port 135 + dynamic port) ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/conn.log" | \
  awk -F'\t' '$6 == "135" {print $1"\t"$3"\t"$5}' | head -20

echo ""
echo "=== Pass-the-hash detection (NTLM auth without prior negotiation) ==="
# Kerberos error followed by NTLM success from same source to same dest
# Look for kerberos failures + smb NTLM auth
awk 'NR>8 && !/^#/' "$LOG_DIR/kerberos.log" 2>/dev/null | \
  awk -F'\t' '$8 != "true" {print $3"\t"$5"\t"$7}' | \
  head -20

C2 Detection Queries

bashzeek-c2-hunting.sh
#!/bin/bash
LOG_DIR="${1:-/opt/zeek/logs/current}"

echo "=== JA3 hash matching known C2 frameworks ==="
KNOWN_BAD_JA3=(
    "51c64c77e60f3980eea90869b68c58a8"  # Cobalt Strike default
    "de9f2c7fd25e1b3afad3e85a0226a4c4"  # Metasploit
    "a0e9f5d64349fb13191bc781f81f42e1"  # Trickbot
)
for hash in "${KNOWN_BAD_JA3[@]}"; do
    matches=$(grep "$hash" "$LOG_DIR/ssl.log" 2>/dev/null | wc -l)
    if [ "$matches" -gt 0 ]; then
        echo "  MATCH JA3=$hash: $matches connections"
        grep "$hash" "$LOG_DIR/ssl.log" | head -5
    fi
done

echo ""
echo "=== TLS without SNI (no server name indication) ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/ssl.log" | \
  awk -F'\t' '$7 == "-" {print $3"\t"$5"\t"$6}' | \
  sort | uniq -c | sort -rn | head -20

echo ""
echo "=== Self-signed certs from external IPs ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/ssl.log" | \
  awk -F'\t' '
    $5 !~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/ &&
    $NF ~ /self signed/ {
      print $3"\t"$5"\t"$6"\t"$8
    }' | sort | uniq -c | sort -rn | head -20

echo ""
echo "=== Beaconing to external IPs (regular SYN timing, any port) ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/conn.log" | \
  awk -F'\t' '$5 !~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/ && $8 !~ /-/ {
    print $1"\t"$3"\t"$5"\t"$6
  }' | python3 -c "
import sys, math
from collections import defaultdict
flows = defaultdict(list)
for line in sys.stdin:
    parts = line.strip().split('\t')
    if len(parts) >= 4:
        try:
            ts = float(parts[0])
            key = (parts[1], parts[2], parts[3])
            flows[key].append(ts)
        except:
            pass

for key, timestamps in flows.items():
    if len(timestamps) < 10:
        continue
    timestamps.sort()
    intervals = [timestamps[i+1]-timestamps[i] for i in range(len(timestamps)-1)]
    intervals = [iv for iv in intervals if 0 < iv < 3600]
    if len(intervals) < 5:
        continue
    mean = sum(intervals)/len(intervals)
    if mean < 1:
        continue
    variance = sum((x-mean)**2 for x in intervals)/len(intervals)
    cv = math.sqrt(variance)/mean
    if cv < 0.15:
        src, dst, port = key
        print(f'BEACON cv={cv:.3f} interval={mean:.0f}s n={len(timestamps)} {src}→{dst}:{port}')
" | head -20

Exfiltration Detection

bashzeek-exfil-detect.sh
#!/bin/bash
LOG_DIR="${1:-/opt/zeek/logs/current}"

echo "=== Large outbound transfers (>100MB to single external IP) ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/conn.log" | \
  awk -F'\t' '
    $5 !~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/ &&
    $9 ~ /[0-9]/ {
      key = $3"\t"$5
      bytes[key] += $9+0
    }
    END {
      for (k in bytes)
        if (bytes[k] > 100000000)
          printf "%.1fMB\t%s\n", bytes[k]/1048576, k
    }' | sort -rn | head -20

echo ""
echo "=== DNS exfil indicator (many queries to single domain) ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/dns.log" | \
  awk -F'\t' '{
    if ($9 ~ /\./) {
      n = split($9, parts, ".")
      if (n >= 2) sld = parts[n-1] "." parts[n]
      else sld = $9
      print $3"\t"sld
    }
  }' | sort | uniq -c | sort -rn | \
  awk '$1 > 100 && $3 !~ /(microsoft|google|apple|cloudflare|amazon|windows)/' | head -20

echo ""
echo "=== High upload ratio to unknown destinations ==="
awk 'NR>8 && !/^#/' "$LOG_DIR/conn.log" | \
  awk -F'\t' '
    $5 !~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/ &&
    $9+0 > 5000000 && $10 != "-" {
      orig = $9+0
      resp = $10+0
      if (orig > 0 && resp >= 0) {
        ratio = orig / (orig + resp + 1)
        if (ratio > 0.9)
          printf "upload_ratio=%.2f orig=%dKB resp=%dKB  %s → %s\n",
            ratio, orig/1024, resp/1024, $3, $5
      }
    }' | sort -rn | head -10
Mental model: detection quality = specificity × coverage

A detection that catches everything generates too many alerts to be actionable. A detection that never fires is useless. Good Zeek threat detection finds the sweet spot: specific enough to have a low false-positive rate (you can review every alert), broad enough to catch the attack technique across its known variations. The way to find this sweet spot in practice: (1) Start specific — make the detection catch only what you're confident is malicious. (2) Measure the false positive rate against 1 week of baseline traffic. (3) If the false positive rate is acceptable, expand coverage. (4) If false positives are too high, tighten one dimension (raise a threshold, add an allowlist). Never tune in the other direction by lowering sensitivity — that makes you miss real attacks. The goal is a detection library where every alert has a >50% chance of being real, so every alert is worth investigating.

Q & A

Q: How do I correlate Zeek alerts with Splunk/Elastic logs from endpoints?

The correlation key is the source IP address + timestamp window. Workflow: (1) Zeek fires a detection for source IP 10.0.0.50 at 14:23:17 UTC. (2) In Splunk/Elastic: search for endpoint telemetry from the host with IP 10.0.0.50 within ±5 minutes of 14:23:17. (3) Look for: process creation events (Sysmon EventID 1) that correlate with the network activity — which process created the network connection? (4) In Elastic/Splunk with ECS or Sysmon data: correlate by process.pid or network.community_id. The Community ID (Ch05) is the best cross-source correlation key if all your sources compute it — Zeek, Suricata, Windows Sysmon (via configuration), and Elastic all support it. Set up Community ID computation in Zeek's conn.log, ensure your Sysmon configuration logs Community ID with network connections, and your SIEM can join them directly. Without Community ID, IP+port+timestamp is the next-best correlation approach.