Exfiltration Patterns
Data exfiltration leaves volume-based and behavioral anomalies in network data, regardless of the transport used. Whether the attacker uses HTTPS, DNS tunneling, ICMP, or a cloud sync client, the bytes still have to traverse the network. This chapter surveys the most common exfiltration channels and their detection signatures from Zeek, NetFlow, and PCAP analysis.
A nation-state actor uses a novel exfiltration method: encoding data in the timing intervals between legitimate HTTP keep-alive packets to a CDN-hosted blog. Normal exfiltration detection (looking for large bytes_toserver) sees nothing — the upload is only 200 bytes per packet. But the upload ratio is nearly 1:0.01 (attacker sends 200 bytes, CDN replies with 12 bytes). Across 10,000 such packets, this is 2 MB of exfiltrated data with a 99% upload ratio — caught by the asymmetric ratio detector.
Exfiltration Channel Reference
| Channel | Protocol | Detection Signal | Evasion Difficulty |
|---|---|---|---|
| HTTPS upload | TLS/443 | Large bytes_toserver, upload ratio >0.8 | Medium (looks like file upload) |
| Cloud sync (S3, OneDrive) | TLS/443 | Non-browser UA to cloud API, large upload | High (uses legitimate services) |
| FTP/SFTP | TCP/21,22 | FTP DATA connection with large bytes | Low (easily detectable, unusual) |
| DNS tunneling | UDP/53 | High query rate, long labels, TXT/CNAME types | Medium (slow, detectable) |
| ICMP tunneling | ICMP | Large ICMP payloads, high entropy, sustained pairs | Low (unusual, often blocked) |
| DoH tunneling | TLS/443 | Non-browser UA + DoH content-type, beaconing | High (encrypted, CDN-hosted DoH) |
| Email (SMTP) | TCP/25,465,587 | Large attachments outbound, many recipients | Low-Medium |
| Covert timing channel | Any | High upload ratio with small packets | High (no large payload) |
Multi-Channel Exfiltration Detection
#!/usr/bin/env python3
"""
Multi-signal exfiltration detection from Zeek conn.log and dns.log.
Checks: large transfers, upload ratio, DNS volume, protocol anomalies.
"""
import sys
import math
from collections import defaultdict
from ipaddress import ip_address, ip_network
from dataclasses import dataclass, field
from typing import List
PRIVATE = [ip_network(n) for n in ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]]
CLOUD_KEYWORDS = [
"mega.nz", "backblaze.com", "onedrive.live.com", "dropbox.com",
"drive.google.com", "wetransfer.com", "gofile.io", "anonfiles.com",
"transfer.sh", "file.io"
]
def is_private(ip_str: str) -> bool:
try:
addr = ip_address(ip_str)
return any(addr in net for net in PRIVATE)
except ValueError:
return True
def entropy(data: str) -> float:
from collections import Counter
c = Counter(data)
total = len(data)
if total == 0:
return 0.0
return -sum((v/total)*math.log2(v/total) for v in c.values())
@dataclass
class ConnRecord:
src: str
dst: str
dport: int
duration: float
bytes_orig: int
bytes_resp: int
service: str
ts: float
def parse_zeek_conn_log(filepath: str) -> List[ConnRecord]:
records = []
with open(filepath) as f:
for line in f:
if line.startswith('#'):
continue
parts = line.strip().split('\t')
if len(parts) < 10:
continue
try:
records.append(ConnRecord(
ts=float(parts[0]),
src=parts[2],
dst=parts[4],
dport=int(parts[5]) if parts[5].isdigit() else 0,
duration=float(parts[8]) if parts[8] != '-' else 0.0,
bytes_orig=int(parts[9]) if parts[9].isdigit() else 0,
bytes_resp=int(parts[10]) if len(parts) > 10 and parts[10].isdigit() else 0,
service=parts[7] if len(parts) > 7 else '-',
))
except (ValueError, IndexError):
pass
return records
def detect_exfil(records: List[ConnRecord]) -> None:
# Group outbound connections by src → dst
by_pair = defaultdict(list)
for r in records:
if is_private(r.src) and not is_private(r.dst):
by_pair[(r.src, r.dst, r.dport)].append(r)
findings = []
for (src, dst, dport), recs in by_pair.items():
total_orig = sum(r.bytes_orig for r in recs)
total_resp = sum(r.bytes_resp for r in recs)
total = total_orig + total_resp
if total == 0:
continue
upload_ratio = total_orig / total
# Signal 1: Large total transfer
if total_orig > 10_000_000: # >10 MB outbound
findings.append({
"type": "LARGE_OUTBOUND",
"score": min(100, int(total_orig / 100_000)),
"src": src, "dst": dst, "port": dport,
"detail": f"{total_orig/1_000_000:.1f}MB sent",
})
# Signal 2: High upload ratio (more sent than received)
if upload_ratio > 0.85 and total_orig > 1_000_000:
findings.append({
"type": "HIGH_UPLOAD_RATIO",
"score": int(upload_ratio * 80),
"src": src, "dst": dst, "port": dport,
"detail": f"ratio={upload_ratio:.2f} ({total_orig/1024:.0f}KB sent vs {total_resp/1024:.0f}KB recv)",
})
findings.sort(key=lambda x: -x["score"])
print(f"\n{'Score':>6} {'Type':<25} {'Src':<15} {'Dst':<15} {'Port':>5} Detail")
print("-" * 90)
for f in findings[:20]:
print(f"{f['score']:>6} {f['type']:<25} {f['src']:<15} {f['dst']:<15} "
f"{f['port']:>5} {f['detail']}")
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "/opt/zeek/logs/current/conn.log"
print(f"Loading {path}...")
records = parse_zeek_conn_log(path)
print(f"Loaded {len(records)} connection records")
detect_exfil(records)
Whatever the exfiltration channel, data still takes up bytes on the wire. An attacker can choose any protocol or service to exfiltrate through, but they cannot avoid the fundamental constraint: the data volume shows up in the connection record. This is why upload-ratio and total-bytes analysis is more robust than protocol-specific detection. Protocol analysis catches known patterns (DNS tunneling TXT record rates, ICMP payload size) but is evadeable by an attacker who chooses an uncategorized channel. Volume analysis is protocol-agnostic: if 500 MB left your network to a destination that normally receives only 1 MB, something unusual happened regardless of what protocol was used. In practice: combine both approaches. Protocol-specific detections catch the majority of commodity attackers who use known tools. Volume/ratio analysis catches the outliers. The combination leaves few gaps.
Q & A
Q: How do I baseline "normal" outbound transfer volume to avoid false positives in the exfiltration detector?
Baseline construction for exfiltration detection: (1) Historical percentile: for each src→dst pair, compute the 95th-percentile of daily bytes_toserver over the last 30 days. Alert when any single day exceeds 3× this baseline. (2) Peer grouping: workstations of similar roles should have similar traffic patterns. A CFO's laptop should look like other executive laptops. An anomaly relative to the peer group is more specific than an anomaly relative to the host's own history (catches "normal host, unusual behavior day"). (3) Destination reputation: bucket destinations by category: cloud services (expected moderate upload), enterprise SaaS (expected), raw IP addresses (rarely expected for large uploads), residential ISP IP blocks (almost never expected). Apply lower volume thresholds for unusual destination categories. (4) Time-of-day analysis: 500 MB upload at 2 PM during business hours may be a video upload. The same 500 MB at 3 AM is almost certainly not business-related. (5) Use 7-day rolling window: a single-day spike may be a legitimate backup. Sustained high-volume upload over multiple days (common in slow-and-low exfiltration) is harder to explain as legitimate and easier to catch with rolling averages.