Skip to content

Commit 430e1e8

Browse files
committed
Add dnsspf module for parsing SPF records
Resolves a host's SPF (v=spf1) TXT record and emits the infrastructure it discloses: IP_NETWORK for ip4:/ip6: subnets, IP_ADDRESS for single IPs, and DNS_NAME for include:/a:/mx:/redirect=/exists: domains. Follows the existing dnscaa/dnstlsrpt/dnsbimi DNS-TXT parser pattern; each output type is configurable. Includes a module test. Closes #2545
1 parent db3e012 commit 430e1e8

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

bbot/modules/dnsspf.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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+
)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from .base import ModuleTestBase
2+
3+
# Mock SPF TXT record (zone-file format: quoted to keep the value as a single
4+
# character-string, otherwise the zone-file lexer splits on whitespace).
5+
mock_spf_txt = (
6+
'"v=spf1 ip4:198.51.100.10 ip4:203.0.113.0/24 ip6:2001:db8::/48 '
7+
'include:_spf.blacklanternsecurity.notreal a:mail.blacklanternsecurity.notreal -all"'
8+
)
9+
10+
# What the module emits in RAW_DNS_RECORD events (no surrounding quotes).
11+
raw_spf_txt = (
12+
"v=spf1 ip4:198.51.100.10 ip4:203.0.113.0/24 ip6:2001:db8::/48 "
13+
"include:_spf.blacklanternsecurity.notreal a:mail.blacklanternsecurity.notreal -all"
14+
)
15+
16+
17+
class TestDNSSPF(ModuleTestBase):
18+
targets = ["blacklanternsecurity.notreal"]
19+
modules_overrides = ["dnsspf", "speculate"]
20+
config_overrides = {"modules": {"dnsspf": {"emit_raw_dns_records": True}}, "scope": {"report_distance": 1}}
21+
22+
async def setup_after_prep(self, module_test):
23+
await module_test.mock_dns(
24+
{
25+
"blacklanternsecurity.notreal": {
26+
"A": ["127.0.0.11"],
27+
"TXT": [mock_spf_txt],
28+
},
29+
# the include:/a: domains must resolve for bbot to emit them as DNS_NAME
30+
"_spf.blacklanternsecurity.notreal": {
31+
"A": ["127.0.0.22"],
32+
},
33+
"mail.blacklanternsecurity.notreal": {
34+
"A": ["127.0.0.33"],
35+
},
36+
}
37+
)
38+
39+
def check(self, module_test, events):
40+
assert any(e.type == "RAW_DNS_RECORD" and e.data["answer"] == raw_spf_txt for e in events), (
41+
"Failed to emit RAW_DNS_RECORD"
42+
)
43+
assert any(e.type == "IP_ADDRESS" and e.data == "198.51.100.10" for e in events), (
44+
"Failed to extract single IPv4 from ip4: mechanism"
45+
)
46+
assert any(e.type == "IP_NETWORK" and e.data == "203.0.113.0/24" for e in events), (
47+
"Failed to extract IPv4 subnet from ip4: mechanism"
48+
)
49+
assert any(e.type == "IP_NETWORK" and e.data == "2001:db8::/48" for e in events), (
50+
"Failed to extract IPv6 subnet from ip6: mechanism"
51+
)
52+
assert any(e.type == "DNS_NAME" and e.data == "_spf.blacklanternsecurity.notreal" for e in events), (
53+
"Failed to extract include: domain as DNS_NAME"
54+
)
55+
assert any(e.type == "DNS_NAME" and e.data == "mail.blacklanternsecurity.notreal" for e in events), (
56+
"Failed to extract a: domain as DNS_NAME"
57+
)

docs/modules/list_of_modules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
| dnsbimi | scan | No | Check DNS_NAME's for BIMI records to find image and certificate hosting URL's | cloud-enum, passive, safe, subdomain-enum | DNS_NAME | RAW_DNS_RECORD, URL_UNVERIFIED | @colin-stubbs | 2024-11-15 |
7777
| dnscaa | scan | No | Check for CAA records | email-enum, passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, EMAIL_ADDRESS, URL_UNVERIFIED | @colin-stubbs | 2024-05-26 |
7878
| dnsdumpster | scan | No | Query dnsdumpster for subdomains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME | @TheTechromancer | 2022-03-12 |
79+
| dnsspf | scan | No | Parse SPF records for IPs, subnets, and related domains | passive, safe, subdomain-enum | DNS_NAME | DNS_NAME, IP_ADDRESS, IP_NETWORK, RAW_DNS_RECORD | @thunderstornX | 2026-06-06 |
7980
| dnstlsrpt | scan | No | Check for TLS-RPT records | cloud-enum, email-enum, passive, safe, subdomain-enum | DNS_NAME | EMAIL_ADDRESS, RAW_DNS_RECORD, URL_UNVERIFIED | @colin-stubbs | 2024-07-26 |
8081
| docker_pull | scan | No | Download images from a docker repository | code-enum, download, passive, safe, slow | CODE_REPOSITORY | FILESYSTEM | @domwhewell-sage | 2024-03-24 |
8182
| dockerhub | scan | No | Search for docker repositories of discovered orgs/usernames | code-enum, passive, safe | ORG_STUB, SOCIAL | CODE_REPOSITORY, SOCIAL, URL_UNVERIFIED | @domwhewell-sage | 2024-03-12 |

0 commit comments

Comments
 (0)