feat(dns): detect CLASS=ANY queries as fingerprint_probe

qclass=255 in a standard query is unusual enough to be a fingerprinting probe
(fpdns, various scanner scripts).  Previously it was logged as a plain query
with qclass=ANY in the event field; now it emits fingerprint_probe with
probe=qclass_any and returns REFUSED — consistent with how we treat other
probe types.  Contributes to recon_burst.
This commit is contained in:
2026-05-21 21:16:47 -04:00
parent 521d77b28f
commit 35159419bb
2 changed files with 41 additions and 0 deletions

View File

@@ -715,6 +715,16 @@ def _handle(data: bytes, src_ip: str, src_port: int, transport: str) -> bytes |
return _chaos_txt_response(qid, rd, qname, answer_text) return _chaos_txt_response(qid, rd, qname, answer_text)
return _refused_response(qid, rd, qname, qtype, qclass) return _refused_response(qid, rd, qname, qtype, qclass)
# ── CLASS=ANY fingerprint probe ────────────────────────────────────────
if qclass == CLASS_ANY:
_log(
"fingerprint_probe", severity=4,
src=src_ip, src_port=src_port, transport=transport,
probe="qclass_any", qname=qname.rstrip("."), qtype=qtype_name,
)
_note_recon_event(src_ip, "fingerprint_probe")
return _refused_response(qid, rd, qname, qtype, qclass)
# ── Classify amp / tunneling ─────────────────────────────────────────── # ── Classify amp / tunneling ───────────────────────────────────────────
is_amp = qtype == TYPE_ANY or (edns_size is not None and edns_size > 1232) is_amp = qtype == TYPE_ANY or (edns_size is not None and edns_size > 1232)
is_tunnel = _is_tunneling(qname, qtype, src_ip) is_tunnel = _is_tunneling(qname, qtype, src_ip)

View File

@@ -476,6 +476,37 @@ class TestReconBurst:
assert bursts assert bursts
assert bursts[0]["distinct_types"] == 2 assert bursts[0]["distinct_types"] == 2
# ── CLASS=ANY fingerprint probe ───────────────────────────────────────────────
class TestClassAnyProbe:
def test_class_any_emits_fingerprint_probe(self):
mod, events = _load_dns()
pkt = _build_query("example.test.local", mod.TYPE_A, qclass=mod.CLASS_ANY)
resp = mod._handle(pkt, "9.9.9.9", 53, "udp")
assert resp is not None
assert _rcode(resp) == mod.RCODE_REFUSED
probes = _events_of(events, "fingerprint_probe")
assert len(probes) == 1
assert probes[0]["probe"] == "qclass_any"
assert probes[0]["qname"] == "example.test.local"
def test_class_any_counts_toward_recon_burst(self):
mod, events = _load_dns()
pkt = _build_query("x.test.local", mod.TYPE_A, qclass=mod.CLASS_ANY)
for _ in range(3):
mod._handle(pkt, "6.6.6.6", 53, "udp")
# Should accumulate; also trigger a second distinct probe type to fire burst
zxfr = _build_query("test.local", mod.TYPE_AXFR)
mod._handle(zxfr, "6.6.6.6", 53, "tcp")
assert len(_events_of(events, "recon_burst")) >= 1
def test_class_in_is_not_affected(self):
"""Regular CLASS_IN queries must NOT trigger qclass_any."""
mod, events = _load_dns()
pkt = _build_query("test.local", mod.TYPE_A, qclass=mod.CLASS_IN)
mod._handle(pkt, "1.1.1.1", 53, "udp")
assert not _events_of(events, "fingerprint_probe")
# ── Zone mode: open ─────────────────────────────────────────────────────────── # ── Zone mode: open ───────────────────────────────────────────────────────────
class TestZoneModeOpen: class TestZoneModeOpen: