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.
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 Technique | How It Evades Point-in-Time Detection | Counter-Detection |
|---|---|---|
| 4-hour beacon interval | CV is fine, but most tools look for 30-60min intervals | Extend CV window to 24-48 hours; check 4h CV too |
| Jittered beacon (±30%) | High CV may evade beaconing detectors | Use time-series clustering, not just CV threshold |
| Slow Kerberoasting (1/day) | Never exceeds per-hour threshold | 30-day TGS-REQ count per source; alert on >7/month to unusual SPNs |
| Cloud C2 via OneDrive API | Traffic looks like normal OneDrive sync | Non-browser UA + beaconing interval to cloud domain |
| Slow exfiltration (10MB/day) | Never triggers single-session threshold | 30-day cumulative bytes per src→dst pair |
| DNS TXT record exfil (slow) | Never exceeds per-minute query threshold | 30-day DNS query count to single SLD; unusual TXT queries |
| Legitimate proxy for C2 | Traffic looks like normal browsing | Check User-Agent vs. TLS fingerprint consistency |
| No SNI (no name in TLS) | Can't match on domain name | TLS without SNI + self-signed cert to external IP |
Long-Window Detection
#!/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
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.