Skip to content

Commit 5aefbd0

Browse files
authored
Merge pull request #890 from blacklanternsecurity/dev
2.1 Release
2 parents 4d81259 + c558b1d commit 5aefbd0

19 files changed

Lines changed: 495 additions & 97 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Check subdomains for subdomain takeovers and other DNS tomfoolery
55
![License](https://img.shields.io/badge/license-GPLv3-f126ea.svg)
66
[![tests](https://github.com/blacklanternsecurity/baddns/actions/workflows/tests.yaml/badge.svg)](https://github.com/blacklanternsecurity/baddns/actions/workflows/tests.yaml)
77
[![codecov](https://codecov.io/gh/blacklanternsecurity/baddns/branch/main/graph/badge.svg)](https://codecov.io/gh/blacklanternsecurity/baddns)
8+
[![PyPI](https://img.shields.io/pypi/v/baddns)](https://pypi.org/project/baddns)
89
[![Pypi Downloads](https://img.shields.io/pypi/dm/baddns)](https://pypi.org/project/baddns)
910

1011
<p align="left"><img width="300" height="300" src="https://github.com/blacklanternsecurity/baddns/assets/24899338/2ca1fe25-e834-4df8-8b02-8bf8f60f6e31"></p>

baddns/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.3.0"
1+
__version__ = "2.4.0"

baddns/base.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def __init__(
2525
self.custom_nameservers = custom_nameservers
2626
self.parent_class = kwargs.get("parent_class", "self")
2727
self.cli = cli
28+
self.disable_negative_signatures = kwargs.get("disable_negative_signatures", False)
2829

2930
# hook to allow external manipulation of target assignment
3031
def set_target(self, target):
@@ -53,4 +54,15 @@ async def cleanup(self):
5354

5455

5556
def get_all_modules(*args, **kwargs):
56-
return [m for m in BadDNS_base.__subclasses__()]
57+
seen = []
58+
59+
def _walk(cls):
60+
for sub in cls.__subclasses__():
61+
# Only concrete modules have a `name` class attribute; intermediate
62+
# bases like BadDNS_email_base do not.
63+
if getattr(sub, "name", None) and sub not in seen:
64+
seen.append(sub)
65+
_walk(sub)
66+
67+
_walk(BadDNS_base)
68+
return seen

baddns/cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ async def execute_module(
109109
direct_mode=False,
110110
min_confidence=None,
111111
min_severity=None,
112+
disable_mx_gate=False,
113+
disable_negative_signatures=False,
112114
):
113115
findings = None
114116
try:
@@ -119,6 +121,8 @@ async def execute_module(
119121
dns_client=dns_client,
120122
cli=True,
121123
direct_mode=direct_mode,
124+
disable_mx_gate=disable_mx_gate,
125+
disable_negative_signatures=disable_negative_signatures,
122126
)
123127
except BadDNSSignatureException as e:
124128
log.error(f"Error loading signatures: {e}")
@@ -187,6 +191,18 @@ async def _main():
187191
help="Minimum severity level to report. Levels: CRITICAL, HIGH, MEDIUM, LOW (exclude INFO)",
188192
)
189193

194+
parser.add_argument(
195+
"--disable-mx-gate",
196+
action="store_true",
197+
help="Run email-related modules (DMARC, SPF, MTA-STS misconfigurations) even when the domain has no MX records at the target or its registered domain.",
198+
)
199+
200+
parser.add_argument(
201+
"--disable-negative-signatures",
202+
action="store_true",
203+
help="Disable negative signatures so generic dangling CNAME/NS findings are still reported for known non-exploitable services.",
204+
)
205+
190206
parser.add_argument("target", nargs="?", type=validate_target, help="subdomain to analyze")
191207
args = parser.parse_args()
192208

@@ -260,6 +276,8 @@ async def _main():
260276
direct_mode=direct_mode,
261277
min_confidence=args.min_confidence,
262278
min_severity=args.min_severity,
279+
disable_mx_gate=args.disable_mx_gate,
280+
disable_negative_signatures=args.disable_negative_signatures,
263281
)
264282

265283

baddns/lib/email_base.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import logging
2+
3+
import tldextract
4+
5+
from baddns.base import BadDNS_base
6+
from baddns.lib.dnsmanager import DNSManager
7+
8+
log = logging.getLogger(__name__)
9+
10+
11+
class BadDNS_email_base(BadDNS_base):
12+
"""Shared base for email-related modules (DMARC, SPF, MTA-STS).
13+
14+
Provides an MX-presence check so callers can skip checks that only matter
15+
for domains that actually receive mail. The target's own MX takes precedence;
16+
if absent, the registered (apex) domain is consulted, since email infra is
17+
typically organizational rather than per-leaf-subdomain.
18+
"""
19+
20+
def __init__(self, target, **kwargs):
21+
super().__init__(target, **kwargs)
22+
self.disable_mx_gate = kwargs.get("disable_mx_gate", False)
23+
self._mx_present_cache = None
24+
25+
async def has_email_infra(self):
26+
"""True if the target or its registered domain has any MX records."""
27+
if self._mx_present_cache is not None:
28+
return self._mx_present_cache
29+
30+
mx_omit = ["A", "AAAA", "CNAME", "NS", "SOA", "TXT", "NSEC"]
31+
32+
sub_dns = DNSManager(self.target, dns_client=self.dns_client, custom_nameservers=self.custom_nameservers)
33+
await sub_dns.dispatchDNS(omit_types=mx_omit)
34+
if sub_dns.answers.get("MX"):
35+
self._mx_present_cache = True
36+
return True
37+
38+
registered_domain = tldextract.extract(self.target).registered_domain
39+
if registered_domain and registered_domain != self.target:
40+
apex_dns = DNSManager(
41+
registered_domain, dns_client=self.dns_client, custom_nameservers=self.custom_nameservers
42+
)
43+
await apex_dns.dispatchDNS(omit_types=mx_omit)
44+
if apex_dns.answers.get("MX"):
45+
self._mx_present_cache = True
46+
return True
47+
48+
self._mx_present_cache = False
49+
return False
50+
51+
async def mx_gate_skips(self):
52+
"""True if the module should skip its email-config checks due to no MX.
53+
54+
Returns False when the gate is disabled via disable_mx_gate.
55+
"""
56+
if self.disable_mx_gate:
57+
return False
58+
if await self.has_email_infra():
59+
return False
60+
log.debug(
61+
f"Skipping email checks for [{self.target}] in [{self.__class__.__name__}] - no MX at target or apex"
62+
)
63+
return True

baddns/modules/cname.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def analyze(self):
104104
)
105105
break
106106
# Check negative signatures before falling back to generic
107-
if not signature_match:
107+
if not signature_match and not self.disable_negative_signatures:
108108
for sig in self.signatures:
109109
if sig.signature["mode"] == "dns_nxdomain" and sig.signature.get("negative_signature", False):
110110
sig_cnames = [c["value"] for c in sig.signature["identifiers"]["cnames"]]

baddns/modules/dmarc.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from baddns.base import BadDNS_base
1+
from baddns.lib.email_base import BadDNS_email_base
22
from baddns.lib.dnsmanager import DNSManager
33
from baddns.lib.findings import Finding
44

@@ -8,7 +8,7 @@
88
log = logging.getLogger(__name__)
99

1010

11-
class BadDNS_dmarc(BadDNS_base):
11+
class BadDNS_dmarc(BadDNS_email_base):
1212
name = "DMARC"
1313
description = "Check for missing or misconfigured DMARC records"
1414
skip_cloud_targets = True
@@ -40,6 +40,8 @@ def parse_dmarc_record(record):
4040
return tags
4141

4242
async def _dispatch(self):
43+
if await self.mx_gate_skips():
44+
return False
4345
# Step 1: Check _dmarc.<target> (RFC 7489 Section 6.6.3)
4446
await self.target_dnsmanager.dispatchDNS(omit_types=["A", "AAAA", "CNAME", "NS", "SOA", "MX", "NSEC"])
4547
txt_records = self.target_dnsmanager.answers["TXT"]

baddns/modules/mtasts.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from blasthttp import BlastHTTP
55

6-
from baddns.base import BadDNS_base
6+
from baddns.lib.email_base import BadDNS_email_base
77
from baddns.lib.dnsmanager import DNSManager
88
from baddns.lib.httpmanager import USER_AGENT
99
from baddns.lib.whoismanager import WhoisManager
@@ -13,7 +13,7 @@
1313
log = logging.getLogger(__name__)
1414

1515

16-
class BadDNS_mtasts(BadDNS_base):
16+
class BadDNS_mtasts(BadDNS_email_base):
1717
name = "MTA-STS"
1818
description = "Check for MTA-STS misconfigurations and dangling mta-sts subdomains"
1919

@@ -33,6 +33,7 @@ def __init__(self, target, **kwargs):
3333
self.policy_error = None
3434
self.mx_whois_results = {}
3535
self.mta_sts_host = f"mta-sts.{target}"
36+
self._has_mx = None
3637

3738
async def _dispatch(self):
3839
# Step 1: Check for _mta-sts TXT record
@@ -135,6 +136,9 @@ async def _dispatch(self):
135136
await whois_mgr.dispatchWHOIS()
136137
self.mx_whois_results[mx_entry] = whois_mgr
137138

139+
# Cache MX presence for analyze() to gate misconfig-only findings
140+
self._has_mx = await self.has_email_infra()
141+
138142
return True
139143

140144
@staticmethod
@@ -186,14 +190,19 @@ def _convert_cname_findings(self, finding_sets):
186190
def analyze(self):
187191
findings = []
188192

189-
# Finding 1: Dangling mta-sts subdomain via CNAME delegation
193+
# Finding 1: Dangling mta-sts subdomain via CNAME delegation — runs
194+
# regardless of MX presence; this is a takeover vector independent of mail flow.
190195
if self.cname_findings_direct:
191196
findings.extend(self._convert_cname_findings(self.cname_findings_direct))
192197
if self.cname_findings:
193198
findings.extend(self._convert_cname_findings(self.cname_findings))
194199

200+
# Misconfig findings (orphaned TXT, policy/MX mismatch) only matter if mail
201+
# actually flows. Skip them when the domain has no MX (gate honors disable_mx_gate).
202+
emit_misconfig = self.disable_mx_gate or self._has_mx
203+
195204
# Finding 2: Orphaned TXT record with unreachable policy
196-
if self.policy_error and not self.cname_findings_direct and not self.cname_findings:
205+
if emit_misconfig and self.policy_error and not self.cname_findings_direct and not self.cname_findings:
197206
findings.append(
198207
Finding(
199208
{
@@ -212,8 +221,8 @@ def analyze(self):
212221
if self.policy:
213222
actual_mx = self.mx_dnsmanager.answers.get("MX") or []
214223

215-
# Finding 3: Policy MX mismatch (only in enforce mode)
216-
if self.policy.get("mode") == "enforce" and actual_mx:
224+
# Finding 3: Policy MX mismatch (only in enforce mode) — gated on MX presence
225+
if emit_misconfig and self.policy.get("mode") == "enforce" and actual_mx:
217226
unmatched = []
218227
for mx_host in actual_mx:
219228
if not any(self._mx_matches(pattern, mx_host) for pattern in self.policy["mx"]):

baddns/modules/ns.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,16 @@ def analyze(self):
9999
)
100100
return findings
101101
# Check negative signatures before falling back to generic
102-
for sig in self.signatures:
103-
if sig.signature["mode"] == "dns_nosoa" and sig.signature.get("negative_signature", False):
104-
sig_nameservers = [ns for ns in sig.signature["identifiers"]["nameservers"]]
105-
r = self.get_substring_matches(target_nameservers, sig_nameservers)
106-
if r:
107-
log.debug(
108-
f"Negative signature match [{sig.signature['service_name']}] for nameservers {', '.join(target_nameservers)}, suppressing generic finding"
109-
)
110-
return findings
102+
if not self.disable_negative_signatures:
103+
for sig in self.signatures:
104+
if sig.signature["mode"] == "dns_nosoa" and sig.signature.get("negative_signature", False):
105+
sig_nameservers = [ns for ns in sig.signature["identifiers"]["nameservers"]]
106+
r = self.get_substring_matches(target_nameservers, sig_nameservers)
107+
if r:
108+
log.debug(
109+
f"Negative signature match [{sig.signature['service_name']}] for nameservers {', '.join(target_nameservers)}, suppressing generic finding"
110+
)
111+
return findings
111112
log.debug(
112113
f"No signature found, falling back to report generic dangling NS record for nameservers: [{', '.join(target_nameservers)}]]"
113114
)

baddns/modules/spf.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from baddns.base import BadDNS_base
1+
from baddns.lib.email_base import BadDNS_email_base
22
from baddns.lib.dnsmanager import DNSManager
33
from baddns.lib.whoismanager import WhoisManager
44
from baddns.lib.findings import Finding
@@ -9,7 +9,7 @@
99
log = logging.getLogger(__name__)
1010

1111

12-
class BadDNS_spf(BadDNS_base):
12+
class BadDNS_spf(BadDNS_email_base):
1313
name = "SPF"
1414
description = "Check for missing or misconfigured SPF records and hijackable include/redirect domains"
1515
skip_cloud_targets = True
@@ -93,6 +93,8 @@ def parse_spf_record(record):
9393
return result
9494

9595
async def _dispatch(self):
96+
if await self.mx_gate_skips():
97+
return False
9698
await self.target_dnsmanager.dispatchDNS(omit_types=["A", "AAAA", "CNAME", "NS", "SOA", "MX", "NSEC"])
9799
txt_records = self.target_dnsmanager.answers["TXT"]
98100

0 commit comments

Comments
 (0)