feat(dns): count NULL/CNAME/AAAA/PRIVATE in tunneling burst window
Rename _txt_times -> _tunnel_times. Add TYPE_CNAME=5, TYPE_NULL=10, TYPE_PRIVATE=65399 constants. Guard burst counter with _TUNNEL_QTYPES frozenset instead of TYPE_TXT only. Mixed-type queries from one source now share a single burst window, closing iodine NULL/CNAME downlink and AAAA-encoded uplink evasion gaps.
This commit is contained in:
@@ -7,7 +7,7 @@ event_type values emitted:
|
||||
fingerprint_probe — version.bind / hostname.bind / id.server / opcode / flag / qclass probes
|
||||
zone_transfer — AXFR or IXFR (always REFUSED)
|
||||
amp_probe — qtype=ANY or EDNS requestor udp_size > 1232
|
||||
tunneling_suspect — long high-entropy labels, high-entropy subdomain, or rapid burst from same src
|
||||
tunneling_suspect — long high-entropy labels, high-entropy subdomain, or rapid burst (TXT/NULL/CNAME/AAAA/PRIVATE) from same src
|
||||
flood_suspect — source exceeding QPS threshold within rolling window
|
||||
tracking_evicted — LRU state evicted (signals IP-rotation evasion)
|
||||
recon_burst — same source hit ≥2 distinct high-signal event types within 60s
|
||||
@@ -121,16 +121,19 @@ for _line in _EXTRA_RAW.splitlines():
|
||||
|
||||
# ── DNS wire constants ────────────────────────────────────────────────────────
|
||||
|
||||
TYPE_A = 1
|
||||
TYPE_NS = 2
|
||||
TYPE_SOA = 6
|
||||
TYPE_MX = 15
|
||||
TYPE_TXT = 16
|
||||
TYPE_AAAA = 28
|
||||
TYPE_OPT = 41
|
||||
TYPE_IXFR = 251
|
||||
TYPE_AXFR = 252
|
||||
TYPE_ANY = 255
|
||||
TYPE_A = 1
|
||||
TYPE_NS = 2
|
||||
TYPE_CNAME = 5
|
||||
TYPE_SOA = 6
|
||||
TYPE_NULL = 10
|
||||
TYPE_MX = 15
|
||||
TYPE_TXT = 16
|
||||
TYPE_AAAA = 28
|
||||
TYPE_OPT = 41
|
||||
TYPE_IXFR = 251
|
||||
TYPE_AXFR = 252
|
||||
TYPE_ANY = 255
|
||||
TYPE_PRIVATE = 65399
|
||||
|
||||
CLASS_IN = 1
|
||||
CLASS_CH = 3
|
||||
@@ -144,9 +147,10 @@ RCODE_NOTIMP = 4
|
||||
RCODE_REFUSED = 5
|
||||
|
||||
_TYPE_NAMES = {
|
||||
TYPE_A: "A", TYPE_NS: "NS", TYPE_SOA: "SOA", TYPE_MX: "MX",
|
||||
TYPE_TXT: "TXT", TYPE_AAAA: "AAAA", TYPE_IXFR: "IXFR",
|
||||
TYPE_AXFR: "AXFR", TYPE_OPT: "OPT", TYPE_ANY: "ANY",
|
||||
TYPE_A: "A", TYPE_NS: "NS", TYPE_CNAME: "CNAME", TYPE_SOA: "SOA",
|
||||
TYPE_NULL: "NULL", TYPE_MX: "MX", TYPE_TXT: "TXT", TYPE_AAAA: "AAAA",
|
||||
TYPE_IXFR: "IXFR", TYPE_AXFR: "AXFR", TYPE_OPT: "OPT", TYPE_ANY: "ANY",
|
||||
TYPE_PRIVATE: "PRIVATE",
|
||||
}
|
||||
_CLASS_NAMES = {CLASS_IN: "IN", CLASS_CH: "CH", CLASS_ANY: "ANY"}
|
||||
_OPCODE_NAMES = {0: "query", 1: "iquery", 2: "status", 4: "notify", 5: "update"}
|
||||
@@ -345,13 +349,15 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
# ── Tunables ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Tunneling heuristic
|
||||
_SHANNON_THRESHOLD = 4.0
|
||||
_LABEL_LEN_THRESHOLD = 30
|
||||
_SHANNON_THRESHOLD = 4.0
|
||||
_LABEL_LEN_THRESHOLD = 30
|
||||
_QNAME_TOTAL_LEN_THRESHOLD = 50
|
||||
_QNAME_ENTROPY_THRESHOLD = 3.5
|
||||
_TXT_BURST_WINDOW = 10.0 # seconds
|
||||
_TXT_BURST_COUNT = 5
|
||||
_MAX_TRACKED_SRCS = 1000
|
||||
_QNAME_ENTROPY_THRESHOLD = 3.5
|
||||
_TXT_BURST_WINDOW = 10.0 # seconds
|
||||
_TXT_BURST_COUNT = 5
|
||||
_MAX_TRACKED_SRCS = 1000
|
||||
# iodine and dnscat2 use these qtypes for data exfiltration in addition to TXT
|
||||
_TUNNEL_QTYPES = frozenset({TYPE_TXT, TYPE_CNAME, TYPE_NULL, TYPE_PRIVATE, TYPE_AAAA})
|
||||
|
||||
# Flood detection
|
||||
_QPS_WINDOW_SEC = 10.0
|
||||
@@ -373,8 +379,8 @@ _FORWARD_BUDGET_WIN = float(os.environ.get("DNS_FORWARD_WINDOW", "1.0"))
|
||||
|
||||
# ── Per-src state ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Tunneling: src_ip -> deque of recent TXT timestamps
|
||||
_txt_times: collections.OrderedDict[str, collections.deque] = collections.OrderedDict()
|
||||
# Tunneling: src_ip -> deque of recent timestamps (all _TUNNEL_QTYPES counted)
|
||||
_tunnel_times: collections.OrderedDict[str, collections.deque] = collections.OrderedDict()
|
||||
|
||||
# Flood: src_ip -> deque of recent query timestamps
|
||||
_qps_window: collections.OrderedDict[str, collections.deque] = collections.OrderedDict()
|
||||
@@ -452,12 +458,12 @@ def _is_tunneling(qname: str, qtype: int, src: str) -> str | None:
|
||||
and _shannon_entropy(subdomain_str) >= _QNAME_ENTROPY_THRESHOLD
|
||||
):
|
||||
return "qname_entropy"
|
||||
if qtype == TYPE_TXT:
|
||||
if qtype in _TUNNEL_QTYPES:
|
||||
now = time.monotonic()
|
||||
if src not in _txt_times:
|
||||
_txt_times[src] = collections.deque()
|
||||
_track_lru(_txt_times, src, "txt_times")
|
||||
q = _txt_times[src]
|
||||
if src not in _tunnel_times:
|
||||
_tunnel_times[src] = collections.deque()
|
||||
_track_lru(_tunnel_times, src, "tunnel_times")
|
||||
q = _tunnel_times[src]
|
||||
q.append(now)
|
||||
while q and now - q[0] > _TXT_BURST_WINDOW:
|
||||
q.popleft()
|
||||
|
||||
@@ -71,7 +71,7 @@ def _load_dns(extra_env: dict | None = None):
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
|
||||
# Reset per-src state between tests
|
||||
mod._txt_times.clear()
|
||||
mod._tunnel_times.clear()
|
||||
mod._qps_window.clear()
|
||||
mod._flood_cooldown.clear()
|
||||
mod._recon_window.clear()
|
||||
@@ -438,6 +438,43 @@ class TestTunnelingHeuristic:
|
||||
mod._handle(_build_query("test.local", mod.TYPE_A), "4.4.4.6", 1234, "udp")
|
||||
assert not _events_of(events, "tunneling_suspect")
|
||||
|
||||
@pytest.mark.parametrize("qtype_attr,expected_name", [
|
||||
("TYPE_NULL", "NULL"),
|
||||
("TYPE_PRIVATE", "PRIVATE"),
|
||||
("TYPE_AAAA", "AAAA"),
|
||||
("TYPE_CNAME", "CNAME"),
|
||||
])
|
||||
def test_non_txt_burst_triggers_tunneling(self, qtype_attr, expected_name):
|
||||
"""NULL/PRIVATE/AAAA/CNAME bursts must count toward the tunnel burst window."""
|
||||
mod, events = _load_dns()
|
||||
src = "5.5.5.10"
|
||||
qtype = getattr(mod, qtype_attr)
|
||||
for i in range(mod._TXT_BURST_COUNT):
|
||||
mod._handle(_build_query(f"probe{i}.test.local", qtype), src, 1234, "udp")
|
||||
suspects = _events_of(events, "tunneling_suspect")
|
||||
assert suspects, f"expected tunneling_suspect for qtype {expected_name}"
|
||||
assert suspects[0]["tunnel_method"] == "burst"
|
||||
|
||||
def test_qtype_name_present_in_burst_event(self):
|
||||
"""tunneling_suspect event must carry a qtype field."""
|
||||
mod, events = _load_dns()
|
||||
src = "5.5.5.11"
|
||||
for i in range(mod._TXT_BURST_COUNT):
|
||||
mod._handle(_build_query(f"q{i}.test.local", mod.TYPE_NULL), src, 1234, "udp")
|
||||
suspects = _events_of(events, "tunneling_suspect")
|
||||
assert suspects and "qtype" in suspects[0]
|
||||
|
||||
def test_mixed_tunnel_qtypes_share_burst_window(self):
|
||||
"""TXT + NULL queries from the same src aggregate in one counter."""
|
||||
mod, events = _load_dns()
|
||||
src = "5.5.5.12"
|
||||
# 3 TXT + 2 NULL = 5 → should trip the burst
|
||||
for i in range(3):
|
||||
mod._handle(_build_query(f"t{i}.test.local", mod.TYPE_TXT), src, 1234, "udp")
|
||||
for i in range(2):
|
||||
mod._handle(_build_query(f"n{i}.test.local", mod.TYPE_NULL), src, 1234, "udp")
|
||||
assert _events_of(events, "tunneling_suspect")
|
||||
|
||||
# ── Flood detection ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestFloodDetection:
|
||||
|
||||
Reference in New Issue
Block a user