|
| 1 | +# dnsspf.py |
| 2 | +# |
| 3 | +# Checks for and parses SPF (Sender Policy Framework) DNS TXT records, e.g. "v=spf1 ...". |
| 4 | +# |
| 5 | +# SPF records (RFC 7208) enumerate the hosts permitted to send mail for a domain. They |
| 6 | +# routinely disclose target-owned infrastructure: individual addresses (ip4:/ip6:), whole |
| 7 | +# subnets such as ip4:198.51.100.0/24, and related domains referenced via include:, a:, |
| 8 | +# mx:, redirect= and exists:. The subnets in particular are frequently owned by the target |
| 9 | +# and worth inspecting, which is the motivation for this module. |
| 10 | +# |
| 11 | +# Example record, |
| 12 | +# example.com TXT "v=spf1 ip4:198.51.100.10 ip4:203.0.113.0/24 include:_spf.example.net -all" |
| 13 | + |
| 14 | +import re |
| 15 | +import ipaddress |
| 16 | +from contextlib import suppress |
| 17 | + |
| 18 | +from bbot.modules.base import BaseModule |
| 19 | +from bbot.core.helpers.dns.helpers import record_to_text, service_record |
| 20 | + |
| 21 | +# An SPF record always begins with the version token "v=spf1" (case-insensitive). |
| 22 | +spf_regex = re.compile(r"^v=spf1\b", re.I) |
| 23 | + |
| 24 | +# ip4:/ip6: mechanisms carrying a single address or a CIDR subnet. |
| 25 | +spf_ip_regex = re.compile(r"^ip[46]:(?P<addr>\S+)$", re.I) |
| 26 | + |
| 27 | +# Mechanisms/modifiers referencing a domain name. A trailing dual-cidr-length |
| 28 | +# (e.g. "a:example.com/24") and any macro expansion are handled by the caller. |
| 29 | +spf_domain_regex = re.compile(r"^(?:include|exists|redirect|a|mx)[:=](?P<domain>[^/\s]+)", re.I) |
| 30 | + |
| 31 | + |
| 32 | +class dnsspf(BaseModule): |
| 33 | + watched_events = ["DNS_NAME"] |
| 34 | + produced_events = ["IP_ADDRESS", "IP_NETWORK", "DNS_NAME", "RAW_DNS_RECORD"] |
| 35 | + flags = ["safe", "passive", "subdomain-enum"] |
| 36 | + meta = { |
| 37 | + "description": "Parse SPF records for IPs, subnets, and related domains", |
| 38 | + "author": "@thunderstornX", |
| 39 | + "created_date": "2026-06-06", |
| 40 | + } |
| 41 | + options = { |
| 42 | + "emit_addresses": True, |
| 43 | + "emit_networks": True, |
| 44 | + "emit_dns_names": True, |
| 45 | + "emit_raw_dns_records": False, |
| 46 | + } |
| 47 | + options_desc = { |
| 48 | + "emit_addresses": "Emit IP_ADDRESS events for individual IPs in SPF records", |
| 49 | + "emit_networks": "Emit IP_NETWORK events for subnets in SPF records", |
| 50 | + "emit_dns_names": "Emit DNS_NAME events for domains referenced via include/a/mx/redirect/exists", |
| 51 | + "emit_raw_dns_records": "Emit RAW_DNS_RECORD events", |
| 52 | + } |
| 53 | + |
| 54 | + async def setup(self): |
| 55 | + self.emit_addresses = self.config.get("emit_addresses", True) |
| 56 | + self.emit_networks = self.config.get("emit_networks", True) |
| 57 | + self.emit_dns_names = self.config.get("emit_dns_names", True) |
| 58 | + self.emit_raw_dns_records = self.config.get("emit_raw_dns_records", False) |
| 59 | + return await super().setup() |
| 60 | + |
| 61 | + def _incoming_dedup_hash(self, event): |
| 62 | + # only inspect each host's SPF record once |
| 63 | + return hash(event.host), "already processed host" |
| 64 | + |
| 65 | + async def filter_event(self, event): |
| 66 | + if "_wildcard" in str(event.host).split("."): |
| 67 | + return False, "event is wildcard" |
| 68 | + # service records (e.g. _dmarc, _domainkey) don't carry SPF policies |
| 69 | + if service_record(event.host) is True: |
| 70 | + return False, "service record detected" |
| 71 | + return True |
| 72 | + |
| 73 | + async def handle_event(self, event): |
| 74 | + rdtype = "TXT" |
| 75 | + hostname = str(event.host) |
| 76 | + |
| 77 | + response = await self.helpers.dns.resolve_full(hostname, rdtype) |
| 78 | + if response is None: |
| 79 | + return |
| 80 | + |
| 81 | + for answer in response.response.answers: |
| 82 | + # record_to_text joins multi-string TXT records and omits dnspython-style quoting |
| 83 | + text_answer = record_to_text(answer).strip() |
| 84 | + |
| 85 | + if not spf_regex.match(text_answer): |
| 86 | + continue |
| 87 | + |
| 88 | + if self.emit_raw_dns_records: |
| 89 | + await self.emit_event( |
| 90 | + {"host": hostname, "type": rdtype, "answer": text_answer}, |
| 91 | + "RAW_DNS_RECORD", |
| 92 | + parent=event, |
| 93 | + tags=["spf-record", "txt-record"], |
| 94 | + context=f"{rdtype} lookup on {hostname} produced {{event.type}}", |
| 95 | + ) |
| 96 | + |
| 97 | + await self._parse_spf(event, text_answer) |
| 98 | + |
| 99 | + async def _parse_spf(self, event, spf): |
| 100 | + for term in spf.split(): |
| 101 | + # strip the optional qualifier (+, -, ~, ?) |
| 102 | + if term[:1] in "+-~?": |
| 103 | + term = term[1:] |
| 104 | + |
| 105 | + ip_match = spf_ip_regex.match(term) |
| 106 | + if ip_match: |
| 107 | + await self._emit_ip(event, ip_match.group("addr")) |
| 108 | + continue |
| 109 | + |
| 110 | + if not self.emit_dns_names: |
| 111 | + continue |
| 112 | + domain_match = spf_domain_regex.match(term) |
| 113 | + if domain_match: |
| 114 | + domain = domain_match.group("domain") |
| 115 | + # SPF macros (e.g. %{i}) don't resolve to a real name; skip them |
| 116 | + if "%" in domain: |
| 117 | + continue |
| 118 | + await self.emit_event( |
| 119 | + domain, |
| 120 | + "DNS_NAME", |
| 121 | + parent=event, |
| 122 | + tags=["spf-record"], |
| 123 | + context=f"SPF record for {event.host} referenced {{event.type}}: {domain}", |
| 124 | + ) |
| 125 | + |
| 126 | + async def _emit_ip(self, event, addr): |
| 127 | + # ip4:/ip6: values may be a bare address or a CIDR subnet |
| 128 | + with suppress(ValueError): |
| 129 | + network = ipaddress.ip_network(addr, strict=False) |
| 130 | + if network.num_addresses == 1: |
| 131 | + if self.emit_addresses: |
| 132 | + await self.emit_event( |
| 133 | + str(network.network_address), |
| 134 | + "IP_ADDRESS", |
| 135 | + parent=event, |
| 136 | + tags=["spf-record"], |
| 137 | + context=f"SPF record for {event.host} contained {{event.type}}: {network.network_address}", |
| 138 | + ) |
| 139 | + elif self.emit_networks: |
| 140 | + await self.emit_event( |
| 141 | + str(network), |
| 142 | + "IP_NETWORK", |
| 143 | + parent=event, |
| 144 | + tags=["spf-record"], |
| 145 | + context=f"SPF record for {event.host} contained {{event.type}}: {network}", |
| 146 | + ) |
0 commit comments