Skip to content

Commit 1ad043f

Browse files
committed
Extract IPs and CIDRs from SPF records in extract_targets
extract_targets already pulled hostnames from TXT content; it now also extracts the ip4:/ip6: IPs and CIDR ranges using bbot's existing ip/cidr regexes, reading from the already-resolved TXT (no extra resolution). The mechanism prefix is stripped before matching so it doesn't pollute the IPv6 match, and a CIDR's bare network address is not emitted alongside it. Closes #2545
1 parent db3e012 commit 1ad043f

2 files changed

Lines changed: 50 additions & 9 deletions

File tree

bbot/core/helpers/dns/helpers.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import logging
2+
import re
23

3-
from bbot.core.helpers.regexes import dns_name_extraction_regex
4+
from bbot.core.helpers.regexes import dns_name_extraction_regex, ip_range_regexes, ipv4_regex, ipv6_regex
45
from bbot.core.helpers.misc import clean_dns_record
56

67
log = logging.getLogger("bbot.core.helpers.dns")
78

9+
# leading SPF qualifier (+-~?) and ip4:/ip6: mechanism prefix; stripped before IP
10+
# matching so the "ip6:" in e.g. "ip6:2001:db8::/48" doesn't pollute the match
11+
_spf_ip_mechanism_prefix = re.compile(r"^[+\-~?]?(?:ip[46]:)?", re.I)
12+
813

914
# Default rdtypes BBOT cares about during recursive resolution
1015
all_rdtypes = ["A", "AAAA", "SRV", "MX", "NS", "SOA", "CNAME", "TXT"]
@@ -16,8 +21,9 @@ def extract_targets(record):
1621
For structured rdata (A/AAAA/CNAME/NS/PTR/MX/SOA/SRV/etc), blastdns has
1722
already extracted the embedded names in Rust -- we just hand those back.
1823
19-
For TXT records we additionally apply a hostname regex to the text content,
20-
since SPF / DKIM / similar TXT payloads commonly embed hostnames worth
24+
For TXT records we additionally apply hostname/IP/CIDR regexes to the text
25+
content, since SPF / DKIM / similar TXT payloads commonly embed hostnames, and
26+
SPF in particular embeds IPs and CIDR ranges (ip4:/ip6: mechanisms) worth
2127
pivoting on. That regex extraction is BBOT-specific and stays here.
2228
"""
2329
results = set()
@@ -26,14 +32,25 @@ def extract_targets(record):
2632
if cleaned:
2733
results.add((rdtype, cleaned))
2834

29-
# TXT: pull additional hostnames out of the free-form text content
35+
# TXT: pull additional targets out of the free-form text content, token by token
3036
is_txt = "TXT" in record.rdata
3137
if is_txt:
32-
text = record.to_text()
33-
for match in dns_name_extraction_regex.finditer(text):
34-
cleaned = clean_dns_record(text[match.start() : match.end()])
35-
if cleaned:
36-
results.add(("TXT", cleaned))
38+
for token in record.to_text().split():
39+
# strip a leading SPF qualifier and ip4:/ip6: mechanism prefix, if any
40+
candidate = _spf_ip_mechanism_prefix.sub("", token)
41+
# CIDR range, e.g. SPF "ip4:1.2.3.0/24" -> IP_NETWORK
42+
if any(regex.fullmatch(candidate) for regex in ip_range_regexes):
43+
results.add(("TXT", candidate))
44+
continue
45+
# individual IP, e.g. SPF "ip4:1.2.3.4" -> IP_ADDRESS
46+
if ipv4_regex.fullmatch(candidate) or ipv6_regex.fullmatch(candidate):
47+
results.add(("TXT", candidate))
48+
continue
49+
# hostnames, e.g. SPF "include:cloudprovider.com", DKIM selectors, etc.
50+
for match in dns_name_extraction_regex.finditer(token):
51+
cleaned = clean_dns_record(token[match.start() : match.end()])
52+
if cleaned:
53+
results.add(("TXT", cleaned))
3754

3855
return results
3956

bbot/test/test_step_1/test_dns.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,30 @@ async def test_dns_engine(bbot_scanner):
7070
assert not extracted
7171

7272

73+
@pytest.mark.asyncio
74+
async def test_extract_targets_spf_ips(bbot_scanner):
75+
"""SPF TXT records: extract IPs and CIDRs (ip4:/ip6:) alongside hostnames."""
76+
scan = bbot_scanner()
77+
await scan._prep()
78+
await scan.helpers.dns._mock_dns(
79+
{
80+
"evilcorp.com": {
81+
"TXT": ['"v=spf1 ip4:1.2.3.4 ip4:5.6.7.0/24 ip6:2001:db8::/48 include:cloudprovider.com -all"'],
82+
},
83+
}
84+
)
85+
response = await scan.helpers.dns.resolve_full("evilcorp.com", "TXT")
86+
extracted = set()
87+
for ans in response.response.answers:
88+
extracted.update(extract_targets(ans))
89+
hosts = {host for _, host in extracted}
90+
assert "1.2.3.4" in hosts, "single IPv4 from ip4: mechanism not extracted"
91+
assert "5.6.7.0/24" in hosts, "IPv4 CIDR from ip4: mechanism not extracted"
92+
assert "2001:db8::/48" in hosts, "IPv6 CIDR from ip6: mechanism not extracted"
93+
assert "cloudprovider.com" in hosts, "include: hostname not extracted"
94+
assert "5.6.7.0" not in hosts, "bare network address must not be emitted alongside its CIDR"
95+
96+
7397
@pytest.mark.asyncio
7498
async def test_dns_resolution(bbot_scanner):
7599
"""Multi-rdtype resolution + SPF affiliate tagging end-to-end."""

0 commit comments

Comments
 (0)