Chapter 67

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.

Scenario

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

ChannelProtocolDetection SignalEvasion Difficulty
HTTPS uploadTLS/443Large bytes_toserver, upload ratio >0.8Medium (looks like file upload)
Cloud sync (S3, OneDrive)TLS/443Non-browser UA to cloud API, large uploadHigh (uses legitimate services)
FTP/SFTPTCP/21,22FTP DATA connection with large bytesLow (easily detectable, unusual)
DNS tunnelingUDP/53High query rate, long labels, TXT/CNAME typesMedium (slow, detectable)
ICMP tunnelingICMPLarge ICMP payloads, high entropy, sustained pairsLow (unusual, often blocked)
DoH tunnelingTLS/443Non-browser UA + DoH content-type, beaconingHigh (encrypted, CDN-hosted DoH)
Email (SMTP)TCP/25,465,587Large attachments outbound, many recipientsLow-Medium
Covert timing channelAnyHigh upload ratio with small packetsHigh (no large payload)

Multi-Channel Exfiltration Detection

pythonexfil-detector.py
#!/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)
Mental model: bytes don't lie, but protocols can mislead

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.