Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions baddns/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def __init__(
self.custom_nameservers = custom_nameservers
self.parent_class = kwargs.get("parent_class", "self")
self.cli = cli
self.disable_negative_signatures = kwargs.get("disable_negative_signatures", False)

# hook to allow external manipulation of target assignment
def set_target(self, target):
Expand Down
9 changes: 9 additions & 0 deletions baddns/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ async def execute_module(
direct_mode=False,
min_confidence=None,
min_severity=None,
disable_negative_signatures=False,
):
findings = None
try:
Expand All @@ -119,6 +120,7 @@ async def execute_module(
dns_client=dns_client,
cli=True,
direct_mode=direct_mode,
disable_negative_signatures=disable_negative_signatures,
)
except BadDNSSignatureException as e:
log.error(f"Error loading signatures: {e}")
Expand Down Expand Up @@ -187,6 +189,12 @@ async def _main():
help="Minimum severity level to report. Levels: CRITICAL, HIGH, MEDIUM, LOW (exclude INFO)",
)

parser.add_argument(
"--disable-negative-signatures",
action="store_true",
help="Disable negative signatures so generic dangling CNAME/NS findings are still reported for known non-exploitable services.",
)

parser.add_argument("target", nargs="?", type=validate_target, help="subdomain to analyze")
args = parser.parse_args()

Expand Down Expand Up @@ -260,6 +268,7 @@ async def _main():
direct_mode=direct_mode,
min_confidence=args.min_confidence,
min_severity=args.min_severity,
disable_negative_signatures=args.disable_negative_signatures,
)


Expand Down
2 changes: 1 addition & 1 deletion baddns/modules/cname.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def analyze(self):
)
break
# Check negative signatures before falling back to generic
if not signature_match:
if not signature_match and not self.disable_negative_signatures:
for sig in self.signatures:
if sig.signature["mode"] == "dns_nxdomain" and sig.signature.get("negative_signature", False):
sig_cnames = [c["value"] for c in sig.signature["identifiers"]["cnames"]]
Expand Down
19 changes: 10 additions & 9 deletions baddns/modules/ns.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,16 @@ def analyze(self):
)
return findings
# Check negative signatures before falling back to generic
for sig in self.signatures:
if sig.signature["mode"] == "dns_nosoa" and sig.signature.get("negative_signature", False):
sig_nameservers = [ns for ns in sig.signature["identifiers"]["nameservers"]]
r = self.get_substring_matches(target_nameservers, sig_nameservers)
if r:
log.debug(
f"Negative signature match [{sig.signature['service_name']}] for nameservers {', '.join(target_nameservers)}, suppressing generic finding"
)
return findings
if not self.disable_negative_signatures:
for sig in self.signatures:
if sig.signature["mode"] == "dns_nosoa" and sig.signature.get("negative_signature", False):
sig_nameservers = [ns for ns in sig.signature["identifiers"]["nameservers"]]
r = self.get_substring_matches(target_nameservers, sig_nameservers)
if r:
log.debug(
f"Negative signature match [{sig.signature['service_name']}] for nameservers {', '.join(target_nameservers)}, suppressing generic finding"
)
return findings
log.debug(
f"No signature found, falling back to report generic dangling NS record for nameservers: [{', '.join(target_nameservers)}]]"
)
Expand Down
35 changes: 35 additions & 0 deletions tests/cname_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,41 @@ async def test_cname_dnsnxdomain_generic_with_negative_loaded(fs, mock_dispatch_
assert any(expected == finding.to_dict() for finding in findings)


@pytest.mark.asyncio
async def test_cname_dnsnxdomain_negative_signature_disabled(fs, mock_dispatch_whois, configure_mock_resolver):
"""With disable_negative_signatures, generic finding fires even when negative signature matches."""
mock_data = {
"bad.dns": {"CNAME": ["7mkvokl.ng.impervadns.net."]},
"_NXDOMAIN": ["7mkvokl.ng.impervadns.net"],
}
mock_resolver = configure_mock_resolver(mock_data)

target = "bad.dns"
mock_signature_load(fs, "nucleitemplates_azure-takeover-detection.yml")
mock_signature_load(fs, "negative_imperva_cname.yml")
signatures = load_signatures("/tmp/signatures")
baddns_cname = BadDNS_cname(
target, signatures=signatures, dns_client=mock_resolver, disable_negative_signatures=True
)

findings = None
if await baddns_cname.dispatch():
findings = baddns_cname.analyze()

assert findings
expected = {
"target": "bad.dns",
"description": "Dangling CNAME, possible subdomain takeover (NXDOMAIN technique)",
"confidence": "MEDIUM",
"severity": "MEDIUM",
"signature": "GENERIC",
"indicator": "Generic Dangling CNAME",
"trigger": "7mkvokl.ng.impervadns.net",
"module": "CNAME",
}
assert any(expected == finding.to_dict() for finding in findings)


@pytest.mark.asyncio
async def test_cname_dnsnxdomain_generic_negative(fs, mock_dispatch_whois, configure_mock_resolver):
mock_data = {"bad.dns": {"CNAME": ["baddns.somerandomthing.dns."]}, "_NXDOMAIN": ["baddns.somerandomthing.dns"]}
Expand Down
20 changes: 20 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ def cached_suffix_list(fs):
yield


# When pyfakefs is active, tldextract's HTTPS fetch of the public suffix list
# tries to read certifi's cacert.pem and fails with OSError (path not in fake fs).
# That OSError is not caught by tldextract's snapshot fallback, which only catches
# requests.exceptions.RequestException. Exposing the real cacert.pem lets the
# fetch fail at the network layer instead, so the bundled snapshot is used.
@pytest.fixture(autouse=True)
def _expose_certifi_to_pyfakefs(request):
if "fs" not in request.fixturenames:
yield
return
fs = request.getfixturevalue("fs")
try:
import certifi

fs.add_real_file(certifi.where(), read_only=True)
except (ImportError, FileExistsError, OSError):
pass
yield


@pytest.fixture()
def configure_mock_resolver(monkeypatch):
def mock_ns_trace_method_generator(return_list):
Expand Down
30 changes: 30 additions & 0 deletions tests/ns_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,36 @@ async def test_ns_nosoa_generic_with_negative_loaded(fs, configure_mock_resolver
assert any(expected == finding.to_dict() for finding in findings)


@pytest.mark.asyncio
async def test_ns_nosoa_negative_signature_disabled(fs, configure_mock_resolver):
"""With disable_negative_signatures, generic finding fires even when negative signature matches."""
mock_data = {"bad.dns": {"NS": ["pdns1.ultradns.net."]}, "_NXDOMAIN": ["baddns.azurewebsites.net"]}
mock_resolver = configure_mock_resolver(mock_data, mock_dnswalk_data=["pdns1.ultradns.net"])

target = "bad.dns"
mock_signature_load(fs, "dnsreaper_wordpress_com_ns.yml")
mock_signature_load(fs, "negative_ultradns_ns.yml")
signatures = load_signatures("/tmp/signatures")
baddns_ns = BadDNS_ns(target, signatures=signatures, dns_client=mock_resolver, disable_negative_signatures=True)

findings = None
if await baddns_ns.dispatch():
findings = baddns_ns.analyze()

assert findings
expected = {
"target": "bad.dns",
"description": "Dangling NS Records (NS records without SOA)",
"confidence": "LOW",
"severity": "MEDIUM",
"signature": "GENERIC",
"indicator": "DNSWalk Analysis",
"trigger": "pdns1.ultradns.net",
"module": "NS",
}
assert any(expected == finding.to_dict() for finding in findings)


@pytest.mark.asyncio
async def test_ns_label_too_long(fs, configure_mock_resolver):
mock_data = {}
Expand Down
Loading