soc operations

Analyzing DNS Logs for Exfiltration

Analyzes DNS query logs to detect data exfiltration via DNS tunneling, DGA domain communication, and covert C2 channels using entropy analysis, query volume anomalies, and subdomain length detection in SIEM platforms. Use when SOC teams need to identify DNS-based threats that bypass traditional network security controls.

c2-detectiondgadnsdns-tunnelingexfiltrationsocsplunkthreat-detection
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

When to Use

Use this skill when:

  • SOC teams suspect data exfiltration through DNS tunneling to bypass firewall/proxy controls
  • Threat intelligence indicates adversaries using DNS-based C2 channels (e.g., Cobalt Strike DNS beacon)
  • UEBA detects anomalous DNS query volumes from specific hosts
  • Malware analysis reveals DNS-over-HTTPS (DoH) or DNS tunneling capabilities

Do not use for standard DNS troubleshooting or availability monitoring — this skill focuses on security-relevant DNS abuse detection.

Prerequisites

  • DNS query logging enabled (Windows DNS Server, Bind, Infoblox, or Cisco Umbrella)
  • DNS logs ingested into SIEM (Splunk with Stream:DNS, dns sourcetype, or Zeek DNS logs)
  • Passive DNS data for historical domain resolution analysis
  • Baseline of normal DNS behavior (query volume, domain distribution, TXT record frequency)
  • Python with math and collections libraries for entropy calculation

Workflow

Step 1: Detect DNS Tunneling via Subdomain Length Analysis

DNS tunneling encodes data in subdomain labels, creating unusually long queries:

index=dns sourcetype="stream:dns" query_type IN ("A", "AAAA", "TXT", "CNAME", "MX")
| eval domain_parts = split(query, ".")
| eval subdomain = mvindex(domain_parts, 0, mvcount(domain_parts)-3)
| eval subdomain_str = mvjoin(subdomain, ".")
| eval subdomain_len = len(subdomain_str)
| eval tld = mvindex(domain_parts, -1)
| eval registered_domain = mvindex(domain_parts, -2).".".tld
| where subdomain_len > 50
| stats count AS queries, dc(query) AS unique_queries,
        avg(subdomain_len) AS avg_subdomain_len,
        max(subdomain_len) AS max_subdomain_len,
        values(src_ip) AS sources
  by registered_domain
| where queries > 20
| sort - avg_subdomain_len
| table registered_domain, queries, unique_queries, avg_subdomain_len, max_subdomain_len, sources

Step 2: Detect High-Entropy Domain Queries (DGA Detection)

Domain Generation Algorithms produce random-looking domains:

index=dns sourcetype="stream:dns"
| eval domain_parts = split(query, ".")
| eval sld = mvindex(domain_parts, -2)
| eval sld_len = len(sld)
| eval char_count = sld_len
| eval vowels = len(replace(sld, "[^aeiou]", ""))
| eval consonants = len(replace(sld, "[^bcdfghjklmnpqrstvwxyz]", ""))
| eval digits = len(replace(sld, "[^0-9]", ""))
| eval vowel_ratio = if(char_count > 0, vowels / char_count, 0)
| eval digit_ratio = if(char_count > 0, digits / char_count, 0)
| where sld_len > 12 AND (vowel_ratio < 0.2 OR digit_ratio > 0.3)
| stats count AS queries, dc(query) AS unique_domains, values(src_ip) AS sources
  by query
| where unique_domains > 10
| sort - queries

Python-based Shannon Entropy Calculation for DNS queries:

import math
from collections import Counter
 
def shannon_entropy(text):
    """Calculate Shannon entropy of a string"""
    if not text:
        return 0
    counter = Counter(text.lower())
    length = len(text)
    entropy = -sum(
        (count / length) * math.log2(count / length)
        for count in counter.values()
    )
    return round(entropy, 4)
 
# Test with examples
normal_domain = "google"           # Low entropy
dga_domain = "x8kj2m9p4qw7n"      # High entropy
tunnel_subdomain = "aGVsbG8gd29ybGQ.evil.com"  # Base64 encoded data
 
print(f"Normal: {shannon_entropy(normal_domain)}")     # ~2.25
print(f"DGA:    {shannon_entropy(dga_domain)}")         # ~3.70
print(f"Tunnel: {shannon_entropy(tunnel_subdomain)}")   # ~3.50
 
# Threshold: entropy > 3.5 for subdomain = likely tunneling/DGA

Splunk implementation of entropy scoring:

index=dns sourcetype="stream:dns"
| eval domain_parts = split(query, ".")
| eval check_string = mvindex(domain_parts, 0)
| eval check_len = len(check_string)
| where check_len > 8
| eval chars = split(check_string, "")
| stats count AS total_chars, dc(chars) AS unique_chars by query, src_ip, check_string, check_len
| eval entropy_estimate = log(unique_chars, 2) * (unique_chars / check_len)
| where entropy_estimate > 3.5
| stats count AS high_entropy_queries, dc(query) AS unique_queries by src_ip
| where high_entropy_queries > 50
| sort - high_entropy_queries

Step 3: Detect Anomalous DNS Query Volume

Identify hosts generating abnormal DNS traffic:

index=dns sourcetype="stream:dns" earliest=-24h
| bin _time span=1h
| stats count AS queries, dc(query) AS unique_domains by src_ip, _time
| eventstats avg(queries) AS avg_queries, stdev(queries) AS stdev_queries by src_ip
| eval z_score = (queries - avg_queries) / stdev_queries
| where z_score > 3 OR queries > 5000
| sort - z_score
| table _time, src_ip, queries, unique_domains, avg_queries, z_score

Detect TXT record abuse (common tunneling method):

index=dns sourcetype="stream:dns" query_type="TXT"
| stats count AS txt_queries, dc(query) AS unique_txt_domains,
        values(query) AS domains by src_ip
| where txt_queries > 100
| eval suspicion = case(
    txt_queries > 1000, "CRITICAL — Likely DNS tunneling",
    txt_queries > 500, "HIGH — Possible DNS tunneling",
    txt_queries > 100, "MEDIUM — Unusual TXT volume"
  )
| sort - txt_queries
| table src_ip, txt_queries, unique_txt_domains, suspicion

Step 4: Detect Known DNS Tunneling Tools

Search for signatures of common DNS tunneling tools:

index=dns sourcetype="stream:dns"
| eval query_lower = lower(query)
| where (
    match(query_lower, "\.dnscat\.") OR
    match(query_lower, "\.dns2tcp\.") OR
    match(query_lower, "\.iodine\.") OR
    match(query_lower, "\.dnscapy\.") OR
    match(query_lower, "\.cobalt.*\.beacon") OR
    query_type="NULL" OR
    (query_type="TXT" AND len(query) > 100)
  )
| stats count by src_ip, query, query_type
| sort - count

Detect DNS over HTTPS (DoH) bypassing local DNS:

index=proxy OR index=firewall
dest IN ("1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4",
         "9.9.9.9", "149.112.112.112", "208.67.222.222")
dest_port=443
| stats sum(bytes_out) AS total_bytes, count AS connections by src_ip, dest
| where connections > 100 OR total_bytes > 10485760
| eval alert = "Possible DoH bypass — DNS queries sent over HTTPS to public resolver"
| sort - total_bytes

Step 5: Correlate DNS Findings with Endpoint Data

Cross-reference suspicious DNS with process data:

index=dns src_ip="192.168.1.105" query="*.evil-tunnel.com" earliest=-24h
| stats count AS dns_queries, earliest(_time) AS first_query, latest(_time) AS last_query
  by src_ip, query
| join src_ip [
    search index=sysmon EventCode=3 DestinationPort=53 Computer="WORKSTATION-042"
    | stats count AS connections, values(Image) AS processes by SourceIp
    | rename SourceIp AS src_ip
  ]
| table src_ip, query, dns_queries, first_query, last_query, processes

Step 6: Calculate Data Exfiltration Volume Estimate

Estimate data volume encoded in DNS queries:

index=dns src_ip="192.168.1.105" query="*.evil-tunnel.com" earliest=-24h
| eval domain_parts = split(query, ".")
| eval encoded_data = mvindex(domain_parts, 0)
| eval encoded_bytes = len(encoded_data)
| eval decoded_bytes = encoded_bytes * 0.75  -- Base64 decoding factor
| stats sum(decoded_bytes) AS total_bytes_estimated, count AS total_queries,
        earliest(_time) AS first_seen, latest(_time) AS last_seen
| eval estimated_kb = round(total_bytes_estimated / 1024, 1)
| eval estimated_mb = round(total_bytes_estimated / 1048576, 2)
| eval duration_hours = round((last_seen - first_seen) / 3600, 1)
| eval rate_kbps = round(estimated_kb / (duration_hours * 3600) * 8, 2)
| table total_queries, estimated_mb, duration_hours, rate_kbps, first_seen, last_seen

Key Concepts

Term Definition
DNS Tunneling Technique encoding data within DNS queries/responses to exfiltrate data or establish C2 channels through DNS
DGA Domain Generation Algorithm — malware technique generating pseudo-random domain names for C2 resilience
Shannon Entropy Mathematical measure of randomness in a string — high entropy (>3.5) in domain names indicates DGA or tunneling
TXT Record Abuse Using DNS TXT records (designed for text data) as a high-bandwidth channel for data tunneling
DNS over HTTPS (DoH) DNS queries encrypted over HTTPS (port 443), bypassing traditional DNS monitoring
Passive DNS Historical record of DNS resolutions showing which IPs a domain resolved to over time

Tools & Systems

  • Splunk Stream: Network traffic capture add-on providing parsed DNS query data for SIEM analysis
  • Zeek (Bro): Network security monitor generating detailed DNS transaction logs for analysis
  • Cisco Umbrella (OpenDNS): Cloud DNS security platform blocking malicious domains and logging query data
  • Infoblox DNS Firewall: DNS-layer security providing RPZ-based blocking and detailed query logging
  • Farsight DNSDB: Passive DNS database for historical domain resolution lookups and infrastructure mapping

Common Scenarios

  • Cobalt Strike DNS Beacon: Detect periodic TXT queries with encoded payloads to C2 domain
  • Data Exfiltration: Large volumes of unique subdomain queries encoding stolen data in Base64/hex
  • DGA Malware: Detect DNS queries to algorithmically generated domains (high entropy, no web content)
  • DNS-over-HTTPS Bypass: Employee using DoH to bypass corporate DNS filtering and monitoring
  • Slow Drip Exfiltration: Low-volume DNS tunneling staying below threshold alerts (requires baseline comparison)

Output Format

DNS EXFILTRATION ANALYSIS — WORKSTATION-042
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Period:       2024-03-14 to 2024-03-15
Source:       192.168.1.105 (WORKSTATION-042, Finance Dept)
 
Findings:
  [CRITICAL] DNS tunneling detected to evil-tunnel[.]com
    Query Volume:       12,847 queries in 18 hours
    Avg Subdomain Len:  63 characters (normal: <20)
    Avg Entropy:        3.82 (threshold: 3.5)
    Query Types:        TXT (89%), A (11%)
    Estimated Data:     ~4.7 MB exfiltrated via DNS
    Rate:               0.58 kbps (slow drip pattern)
 
  [HIGH] DGA-like domains resolved
    Unique DGA Domains: 247 domains resolved
    Pattern:            15-char random alphanumeric.xyz TLD
    Entropy Range:      3.6 - 4.1
 
Process Attribution:
  Process:   svchost_update.exe (masquerading — not legitimate svchost)
  PID:       4892
  Parent:    explorer.exe
  Hash:      SHA256: a1b2c3d4... (VT: 34/72 malicious — Cobalt Strike beacon)
 
Containment:
  [DONE] Host isolated via EDR
  [DONE] Domain evil-tunnel[.]com added to DNS sinkhole
  [DONE] Incident IR-2024-0448 created
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 1

api-reference.md2.6 KB

API Reference: DNS Exfiltration Detection Tools

Shannon Entropy Calculation

Python Implementation

import math
from collections import Counter
 
def shannon_entropy(text):
    counter = Counter(text.lower())
    length = len(text)
    return -sum((c/length) * math.log2(c/length) for c in counter.values())

Threshold Values

Entropy Classification
< 2.5 Normal domain (e.g., "google")
2.5 - 3.5 Borderline (monitor)
> 3.5 Suspicious (likely DGA/tunneling)
> 4.0 High confidence malicious

Splunk DNS Queries

Tunneling Detection

index=dns sourcetype="stream:dns"
| eval subdomain_len=len(mvindex(split(query,"."),0))
| where subdomain_len > 50
| stats count by registered_domain, src_ip

DGA Detection

index=dns
| eval sld=mvindex(split(query,"."), -2)
| where len(sld) > 12
| stats count, dc(query) AS unique by src_ip

Volume Anomaly

index=dns earliest=-24h
| bin _time span=1h
| stats count AS queries by src_ip, _time
| eventstats avg(queries) AS avg_q, stdev(queries) AS stdev_q by src_ip
| eval z_score=(queries - avg_q) / stdev_q
| where z_score > 3

TXT Record Abuse

index=dns query_type="TXT"
| stats count AS txt_queries by src_ip
| where txt_queries > 100

Zeek DNS Log Format

Log Fields (dns.log)

Column Field Description
0 ts Timestamp
2 id.orig_h Source IP
4 id.resp_h DNS server IP
9 query Query domain name
13 qtype_name Query type (A, TXT, CNAME)
15 rcode_name Response code
21 answers Response answers

Zeek CLI Analysis

cat dns.log | zeek-cut query qtype_name id.orig_h | sort | uniq -c | sort -rn

DNS Tunneling Tools (Detection Signatures)

Tool DNS Pattern
iodine *.pirate.sea (TXT/NULL records)
dnscat2 *.dnscat. prefix in queries
dns2tcp *.dns2tcp. pattern
Cobalt Strike DNS Periodic TXT queries with encoded payloads

Passive DNS Lookup APIs

Farsight DNSDB

curl -H "X-API-Key: $KEY" \
  "https://api.dnsdb.info/dnsdb/v2/lookup/rrset/name/evil.com/A"

VirusTotal Domain Resolutions

curl -H "x-apikey: $KEY" \
  "https://www.virustotal.com/api/v3/domains/evil.com/resolutions"

Cisco Umbrella (OpenDNS) Investigate API

Domain Categorization

curl -H "Authorization: Bearer $TOKEN" \
  "https://investigate.api.umbrella.com/domains/categorization/evil.com"

Security Information

curl -H "Authorization: Bearer $TOKEN" \
  "https://investigate.api.umbrella.com/security/name/evil.com"

Scripts 1

agent.py8.5 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""DNS exfiltration detection agent using entropy analysis and query pattern detection."""

import math
from collections import Counter, defaultdict


def shannon_entropy(text):
    """Calculate Shannon entropy of a string."""
    if not text:
        return 0.0
    counter = Counter(text.lower())
    length = len(text)
    entropy = -sum(
        (count / length) * math.log2(count / length)
        for count in counter.values()
    )
    return round(entropy, 4)


def extract_subdomain(fqdn):
    """Extract the subdomain portion from a fully qualified domain name."""
    parts = fqdn.rstrip(".").split(".")
    if len(parts) > 2:
        return ".".join(parts[:-2])
    return ""


def extract_registered_domain(fqdn):
    """Extract the registered domain (SLD + TLD) from an FQDN."""
    parts = fqdn.rstrip(".").split(".")
    if len(parts) >= 2:
        return ".".join(parts[-2:])
    return fqdn


def detect_tunneling(dns_records, subdomain_len_threshold=50, min_queries=20):
    """Detect DNS tunneling based on subdomain length anomalies."""
    domain_stats = defaultdict(lambda: {"queries": 0, "unique_queries": set(),
                                         "subdomain_lengths": [], "sources": set()})
    for record in dns_records:
        query = record.get("query", "")
        src = record.get("src_ip", "unknown")
        subdomain = extract_subdomain(query)
        reg_domain = extract_registered_domain(query)
        if len(subdomain) > subdomain_len_threshold:
            stats = domain_stats[reg_domain]
            stats["queries"] += 1
            stats["unique_queries"].add(query)
            stats["subdomain_lengths"].append(len(subdomain))
            stats["sources"].add(src)
    alerts = []
    for domain, stats in domain_stats.items():
        if stats["queries"] >= min_queries:
            avg_len = sum(stats["subdomain_lengths"]) / len(stats["subdomain_lengths"])
            max_len = max(stats["subdomain_lengths"])
            alerts.append({
                "domain": domain,
                "queries": stats["queries"],
                "unique_queries": len(stats["unique_queries"]),
                "avg_subdomain_length": round(avg_len, 1),
                "max_subdomain_length": max_len,
                "sources": list(stats["sources"]),
                "verdict": "CRITICAL - Likely DNS tunneling",
            })
    return sorted(alerts, key=lambda x: x["avg_subdomain_length"], reverse=True)


def detect_dga(dns_records, entropy_threshold=3.5, min_sld_length=12):
    """Detect Domain Generation Algorithm queries using entropy scoring."""
    suspicious = defaultdict(lambda: {"count": 0, "sources": set(), "entropies": []})
    for record in dns_records:
        query = record.get("query", "").rstrip(".")
        src = record.get("src_ip", "unknown")
        parts = query.split(".")
        if len(parts) < 2:
            continue
        sld = parts[-2]
        if len(sld) < min_sld_length:
            continue
        ent = shannon_entropy(sld)
        if ent > entropy_threshold:
            suspicious[query]["count"] += 1
            suspicious[query]["sources"].add(src)
            suspicious[query]["entropies"].append(ent)
    alerts = []
    for domain, data in suspicious.items():
        avg_entropy = sum(data["entropies"]) / len(data["entropies"])
        alerts.append({
            "domain": domain,
            "queries": data["count"],
            "avg_entropy": round(avg_entropy, 4),
            "sources": list(data["sources"]),
            "verdict": "HIGH - Possible DGA domain",
        })
    return sorted(alerts, key=lambda x: x["avg_entropy"], reverse=True)


def detect_volume_anomaly(dns_records, z_score_threshold=3.0):
    """Detect hosts with anomalously high DNS query volumes."""
    host_counts = defaultdict(int)
    for record in dns_records:
        src = record.get("src_ip", "unknown")
        host_counts[src] += 1
    if not host_counts:
        return []
    values = list(host_counts.values())
    mean_q = sum(values) / len(values)
    if len(values) < 2:
        return []
    variance = sum((x - mean_q) ** 2 for x in values) / (len(values) - 1)
    stdev_q = variance ** 0.5
    if stdev_q == 0:
        return []
    anomalies = []
    for host, count in host_counts.items():
        z = (count - mean_q) / stdev_q
        if z > z_score_threshold:
            anomalies.append({
                "src_ip": host,
                "queries": count,
                "z_score": round(z, 2),
                "mean": round(mean_q, 1),
                "verdict": "HIGH - Anomalous query volume",
            })
    return sorted(anomalies, key=lambda x: x["z_score"], reverse=True)


def detect_txt_abuse(dns_records, threshold=100):
    """Detect excessive TXT record queries (common tunneling method)."""
    txt_counts = defaultdict(lambda: {"count": 0, "unique_domains": set()})
    for record in dns_records:
        qtype = str(record.get("query_type", "")).upper()
        if qtype in ("TXT", "16"):
            src = record.get("src_ip", "unknown")
            txt_counts[src]["count"] += 1
            txt_counts[src]["unique_domains"].add(record.get("query", ""))
    alerts = []
    for src, data in txt_counts.items():
        if data["count"] > threshold:
            level = "CRITICAL" if data["count"] > 1000 else "HIGH" if data["count"] > 500 else "MEDIUM"
            alerts.append({
                "src_ip": src,
                "txt_queries": data["count"],
                "unique_domains": len(data["unique_domains"]),
                "verdict": f"{level} - Possible DNS tunneling via TXT records",
            })
    return sorted(alerts, key=lambda x: x["txt_queries"], reverse=True)


def estimate_exfil_volume(dns_records, target_domain):
    """Estimate data volume encoded in DNS queries to a specific domain."""
    total_encoded_bytes = 0
    query_count = 0
    for record in dns_records:
        query = record.get("query", "")
        if target_domain in query:
            subdomain = extract_subdomain(query)
            total_encoded_bytes += len(subdomain)
            query_count += 1
    decoded_bytes = int(total_encoded_bytes * 0.75)  # Base64 decode factor
    return {
        "target_domain": target_domain,
        "total_queries": query_count,
        "encoded_bytes": total_encoded_bytes,
        "estimated_decoded_bytes": decoded_bytes,
        "estimated_kb": round(decoded_bytes / 1024, 1),
        "estimated_mb": round(decoded_bytes / (1024 * 1024), 3),
    }


def parse_zeek_dns_log(log_path):
    """Parse a Zeek dns.log file into structured records."""
    records = []
    with open(log_path, "r") as f:
        for line in f:
            if line.startswith("#"):
                continue
            parts = line.strip().split("\t")
            if len(parts) >= 10:
                records.append({
                    "timestamp": parts[0],
                    "src_ip": parts[2],
                    "src_port": parts[3],
                    "dst_ip": parts[4],
                    "query": parts[9] if len(parts) > 9 else "",
                    "query_type": parts[13] if len(parts) > 13 else "",
                })
    return records


if __name__ == "__main__":
    print("=" * 60)
    print("DNS Exfiltration Detection Agent")
    print("Tunneling, DGA, volume anomaly, and TXT abuse detection")
    print("=" * 60)

    # Demo with synthetic DNS records
    demo_records = [
        {"query": f"{'a' * 60}.evil-tunnel.com", "src_ip": "192.168.1.105",
         "query_type": "TXT"} for _ in range(50)
    ] + [
        {"query": "x8kj2m9p4qw7nz3.xyz", "src_ip": "192.168.1.110",
         "query_type": "A"} for _ in range(5)
    ] + [
        {"query": "google.com", "src_ip": "192.168.1.50", "query_type": "A"}
        for _ in range(10)
    ]

    print("\n--- DNS Tunneling Detection ---")
    tunneling = detect_tunneling(demo_records, subdomain_len_threshold=30, min_queries=10)
    for t in tunneling:
        print(f"[!] {t['domain']}: {t['queries']} queries, "
              f"avg subdomain len={t['avg_subdomain_length']}")

    print("\n--- DGA Detection ---")
    dga = detect_dga(demo_records, entropy_threshold=3.0, min_sld_length=10)
    for d in dga[:5]:
        print(f"[!] {d['domain']}: entropy={d['avg_entropy']}")

    print("\n--- TXT Record Abuse ---")
    txt = detect_txt_abuse(demo_records, threshold=10)
    for t in txt:
        print(f"[!] {t['src_ip']}: {t['txt_queries']} TXT queries")

    print("\n--- Entropy Examples ---")
    examples = ["google", "x8kj2m9p4qw7n", "aGVsbG8gd29ybGQ"]
    for ex in examples:
        print(f"  '{ex}' -> entropy={shannon_entropy(ex)}")
Keep exploring