elastic / elastic/detection-rules

[New Rule] DNS Tunneling via NULL or Long TXT Queries

Open
#6,563 0 comments 0 reactions 1 assignee Claimed by @eric-forte-elastic View on GitHub
community
Dominant language
Python
Stars
2.7k
Forks
696
Avg merge
5d 1h
Merged PRs (30d)
72

Description

**Description**

Detects DNS query-shape patterns characteristic of DNS tunneling tools (e.g. iodine, dnscat2): `NULL`-type DNS queries, which are essentially unused in legitimate modern DNS traffic, or `TXT`-type queries with an unusually long question name.

Non-ML, query-shape-based DNS tunneling detection is not currently covered by any active rule in this repo. The one prior plain rule (`command_and_control_dns_directly_to_the_internet.toml`) was deprecated in 2022 with no non-ML replacement, and current coverage (`dga`/`ded`/`beaconing`) requires a paid ML license and trained models.

Built and validated against a real DNS tunnel, not synthetic packets: ran real Packetbeat (the actual Elastic Beat, not a hand-built approximation) against this host's live traffic with its DNS module, then set up a genuine `iodine` DNS tunnel (server + client). First attempt silently fell back to raw UDP once iodine detected it could reach the server directly, which was useless for this rule since it bypasses DNS entirely; caught by reading the client's own log output and fixed by forcing pure DNS-wire tunneling (`-r`). Ran two separate tunnel sessions carrying real `ping`-generated payload data, one at iodine's default `NULL` query type and one forced to `TXT` (`-T TXT`), alongside real benign DNS traffic (external site lookups, ambient system queries) as the false-positive baseline.

Real captured data directly shaped the design: 75 real `NULL`-type queries captured from the tunnel (23-759 characters), zero in an 84-query benign baseline (max 71 characters), confirming `NULL` records are essentially unused in legitimate traffic on their own. 58 real `TXT`-type queries were also captured, but not uniformly long: iodine's own short control/keepalive packets were as short as 24 characters, and only data-carrying packets ran long (up to 588 characters). This is why the `TXT` branch requires the question *name* itself to exceed 100 characters rather than matching on record type alone (TXT is common and legitimate for SPF/DKIM/domain verification). Verified the exact final query against the real captured data: 102/102 correct matches (75 `NULL` + 27 long `TXT`), 0 false positives against 84 benign queries and 31 short `TXT` control packets.

**Target Ruleset**: network

**Target Rule Type**: ES|QL

**Tested ECS Version**: 9.4.0

**Query**
```sql
FROM logs-network_traffic.dns-* metadata _id, _version, _index

| EVAL Esql.name_length = LENGTH(dns.question.name)
| EVAL Esql.is_null_type = dns.question.type == "NULL"
| EVAL Esql.is_long_txt = dns.question.type == "TXT" AND Esql.name_length > 100

| WHERE Esql.is_null_type OR Esql.is_long_txt

| KEEP _id, _version, _index, @timestamp, dns.question.name, dns.question.type,
Esql.name_length, source.ip, destination.ip
```

**New fields required in ECS/data sources for this rule?**

None. Uses only fields already populated by the standard `network_traffic` integration's DNS dataset (`dns.question.name`, `dns.question.type`, `source.ip`, `destination.ip`).

**Related issues or PRs**

None found. Related to the deprecated `command_and_control_dns_directly_to_the_internet.toml` (deprecated 2022, no replacement). This rule provides a non-ML alternative to the current `dga`/`ded`/`beaconing` ML-only coverage.

**References**
- https://code.kryo.se/iodine/
- https://github.com/iagox86/dnscat2
- https://attack.mitre.org/techniques/T1071/004/
- https://attack.mitre.org/techniques/T1048/003/

**Redacted Example Data**

Real DNS event captured from a genuine local `iodine` tunnel during testing (a data-carrying tunnel query, not the longest captured example, chosen for readability):
```json
{"dns": {"question": {"name": "z3miaA0123456789\\188\\189\\190\\191\\192\\193\\194\\195\\196\\197\\198\\199\\200\\201\\202\\203\\204\\205\\206\\207.t.detlab.internal", "type": "NULL"}, "response_code": "NOERROR"}, "source": {"ip": "127.0.0.1"}, "destination": {"ip": "127.0.0.1"}}
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.