Chapter 70

APT Low-and-Slow

Advanced Persistent Threat (APT) actors use slow, patient techniques to avoid triggering thresholds: one TGS request per day (not 50 in 10 seconds), small exfiltration over weeks (not 100 GB in one session), reuse of legitimate services for C2 (not raw IP on port 4444). Detecting low-and-slow requires long-retention data, statistical analysis over extended time windows, and baseline comparisons that distinguish unusual-for-this-host from unusual-in-general.

Scenario

An APT actor spends 6 weeks inside a corporate network before triggering any alert. Their technique: check in to a custom DoH resolver once every 4 hours (evades beaconing detection that looks for sub-60-minute intervals), exfiltrate 10 MB per day disguised as Dropbox sync traffic (evades large-transfer detection), and request one new TGS per day for 30 days (Kerberoasting a service account each day, staying under any per-day threshold). Detection required 30-day rolling windows and behavioral baselines — not point-in-time thresholds.

APT Evasion Techniques vs. Counter-Detections

APT TechniqueHow It Evades Point-in-Time DetectionCounter-Detection
4-hour beacon intervalCV is fine, but most tools look for 30-60min intervalsExtend CV window to 24-48 hours; check 4h CV too
Jittered beacon (±30%)High CV may evade beaconing detectorsUse time-series clustering, not just CV threshold
Slow Kerberoasting (1/day)Never exceeds per-hour threshold30-day TGS-REQ count per source; alert on >7/month to unusual SPNs
Cloud C2 via OneDrive APITraffic looks like normal OneDrive syncNon-browser UA + beaconing interval to cloud domain
Slow exfiltration (10MB/day)Never triggers single-session threshold30-day cumulative bytes per src→dst pair
DNS TXT record exfil (slow)Never exceeds per-minute query threshold30-day DNS query count to single SLD; unusual TXT queries
Legitimate proxy for C2Traffic looks like normal browsingCheck User-Agent vs. TLS fingerprint consistency
No SNI (no name in TLS)Can't match on domain nameTLS without SNI + self-signed cert to external IP

Long-Window Detection

bashapt-long-window.sh
#!/bin/bash
# Long-window (30-day) threat hunting queries for APT behavior
ZEEK_ARCHIVE="${1:-/opt/zeek/logs}"

echo "=== 30-day cumulative exfiltration by src→dst pair ==="
# Find all conn.log files from last 30 days
find "$ZEEK_ARCHIVE" -name "conn.log*" -newer "$ZEEK_ARCHIVE/$(date -d '30 days ago' +%Y-%m-%d)" \
    -not -name "*.lock" 2>/dev/null | \
    xargs -I{} zcat -f {} 2>/dev/null | \
    awk 'NF>8 && !/^#/' | \
    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
            conns[key]++
        }
        END {
            for (k in bytes)
                if (bytes[k] > 1000000000)   # >1GB over 30 days
                    printf "%.1fGB  %d_conns  %s\n", bytes[k]/1073741824, conns[k], k
        }' | sort -rn | head -20

echo ""
echo "=== 30-day Kerberoasting: slow one-per-day pattern ==="
find "$ZEEK_ARCHIVE" -name "kerberos.log*" -newer "$ZEEK_ARCHIVE/$(date -d '30 days ago' +%Y-%m-%d)" \
    2>/dev/null | xargs -I{} zcat -f {} 2>/dev/null | \
    awk 'NF>8 && !/^#/' | \
    awk -F'\t' '$7=="TGS" {print $3"\t"$6}' | \
    sort -u | awk -F'\t' '{print $1}' | sort | uniq -c | sort -rn | \
    awk '$1 > 5 {print "SLOW_KERBEROAST: " $1 " unique days with TGS from " $2}'

echo ""
echo "=== Low-frequency beaconing (4+ hour interval) ==="
# Similar to beaconing detection but extend the gap filter and lower count threshold
find "$ZEEK_ARCHIVE" -name "conn.log*" \
    -newer "$ZEEK_ARCHIVE/$(date -d '7 days ago' +%Y-%m-%d)" \
    2>/dev/null | xargs -I{} zcat -f {} 2>/dev/null | \
    awk '!/^#/' | \
    awk -F'\t' '$5 !~ /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/ {
        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:
            flows[(parts[1], parts[2], parts[3])].append(float(parts[0]))
        except: pass

for (src, dst, port), ts in flows.items():
    ts.sort()
    if len(ts) < 5: continue
    # Look for 4-24 hour intervals
    ivs = [ts[i+1]-ts[i] for i in range(len(ts)-1)
           if 3600*3 <= ts[i+1]-ts[i] <= 3600*24]
    if len(ivs) < 3: continue
    mean = sum(ivs)/len(ivs)
    cv = math.sqrt(sum((x-mean)**2 for x in ivs)/len(ivs))/mean if mean > 0 else 999
    if cv < 0.20:
        print(f'SLOW_BEACON cv={cv:.3f} n={len(ts)} int={mean/3600:.1f}h {src}→{dst}:{port}')
" | head -20
Mental model: APT detection requires data retention measured in months, not days

Most threshold-based detections operate on a 1-hour or 1-day window. An APT actor who knows this can stay below every threshold indefinitely by spreading activity across longer time periods. The counter-strategy is to store enough network telemetry (Zeek logs, NetFlow, Suricata metadata) to run monthly aggregation queries. In practice: 30-day rolling window queries over Zeek conn.log and kerberos.log are the minimum for APT-tier detection. 90-day windows are better. This requires log storage at scale — a busy enterprise network may generate 100+ GB of compressed Zeek logs per day. At 30-day retention that's 3 TB of compressed logs. The investment pays off when it lets you ask: "Has any single source made outbound connections to this external IP more than 20 times in the last 30 days with a consistent interval?" — a question that catches slow beacons no daily alert would ever fire on.

Q & A

Q: I found a potential APT beacon with a 4-hour interval but only 6 data points over 24 hours. How confident should I be?

Six data points over 24 hours is barely enough to compute a meaningful CV. Your confidence level should be low — treat this as a "worth investigating further" signal, not a confirmed beacon. The math: CV from 5 intervals has high variance — a single anomalous interval can significantly change the CV. With only 6 points, even a truly random process can appear to have low CV by chance. To increase confidence: (1) Extend the time window: pull 7 days of data and see if the same source→destination→port pattern continues with the same interval. 40+ data points dramatically increases statistical confidence. (2) Corroborate with other signals: does the destination have no SNI in TLS? Is the certificate self-signed or recently issued? Does the traffic pattern look like browser traffic or a script? Multiple low-confidence signals that all point in the same direction become collectively high-confidence. (3) Check baseline: does this source host normally communicate with this destination? If it's a new connection that started 24 hours ago, that's a more significant signal than a connection that's been running for 6 months. (4) Accept that you can't be certain: escalate as "suspected beaconing, low confidence, recommend PCAP review and host investigation" rather than treating 6 data points as proof.