From 1767184533db5160719277c14df80eab4804928e Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Wed, 5 Apr 2023 15:33:47 -0700 Subject: [PATCH 1/5] fix: org record bug --- libs/dmarc.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/dmarc.py b/libs/dmarc.py index b675b4b..7d57236 100644 --- a/libs/dmarc.py +++ b/libs/dmarc.py @@ -2,6 +2,9 @@ import dns.resolver, tldextract def get_dmarc_record(domain, dns_server): """Returns the DMARC record for a given domain.""" + subdomain = tldextract.extract(domain).registered_domain + if subdomain != domain: + return get_dmarc_record(subdomain, dns_server) try: resolver = dns.resolver.Resolver() resolver.nameservers = [dns_server] From 968e5318a13984771096b1a62889324b06318fca Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Wed, 5 Apr 2023 15:34:37 -0700 Subject: [PATCH 2/5] formatting: auto pep8 --- libs/dmarc.py | 54 +++++++++++++++++++----------- libs/dns.py | 10 ++++-- libs/logic.py | 91 +++++++++++++++++++++++++++++++++----------------- libs/report.py | 43 ++++++++++++++++-------- libs/spf.py | 10 ++++-- spoofy.py | 57 ++++++++++++++++++++++--------- 6 files changed, 181 insertions(+), 84 deletions(-) diff --git a/libs/dmarc.py b/libs/dmarc.py index 7d57236..b9e8937 100644 --- a/libs/dmarc.py +++ b/libs/dmarc.py @@ -1,4 +1,6 @@ -import dns.resolver, tldextract +import dns.resolver +import tldextract + def get_dmarc_record(domain, dns_server): """Returns the DMARC record for a given domain.""" @@ -14,15 +16,11 @@ def get_dmarc_record(domain, dns_server): for dns_data in dmarc: if "DMARC1" in str(dns_data): - dmarc_record = str(dns_data).replace('"','') + dmarc_record = str(dns_data).replace('"', '') return dmarc_record - - subdomain = tldextract.extract(domain).registered_domain - if subdomain != domain: - return get_dmarc_record(subdomain, dns_server) - return None + def get_dmarc_details(dmarc_record): """Returns a tuple containing policy, pct, aspf, subdomain policy, forensic report uri, and aggregate report uri from a DMARC record""" @@ -34,32 +32,50 @@ def get_dmarc_details(dmarc_record): rua = get_dmarc_aggregate_reports(dmarc_record) return p, pct, aspf, sp, fo, rua + def get_dmarc_policy(dmarc_record): """Returns the policy value from a DMARC record.""" - if "p=" in str(dmarc_record): return str(dmarc_record).split("p=")[1].split(";")[0] - else: return None + if "p=" in str(dmarc_record): + return str(dmarc_record).split("p=")[1].split(";")[0] + else: + return None + def get_dmarc_pct(dmarc_record): """Returns the pct value from a DMARC record.""" - if "pct" in str(dmarc_record): return str(dmarc_record).split("pct=")[1].split(";")[0] - else: return None + if "pct=" in str(dmarc_record): + return str(dmarc_record).split("pct=")[1].split(";")[0] + else: + return None + def get_dmarc_aspf(dmarc_record): """Returns the aspf value from a DMARC record""" - if "aspf=" in str(dmarc_record): return str(dmarc_record).split("aspf=")[1].split(";")[0] - else: return None + if "aspf=" in str(dmarc_record): + return str(dmarc_record).split("aspf=")[1].split(";")[0] + else: + return None + def get_dmarc_subdomain_policy(dmarc_record): """Returns the policy to apply for subdomains from a DMARC record.""" - if "sp=" in str(dmarc_record):return str(dmarc_record).split("sp=")[1].split(";")[0] - else: return None + if "sp=" in str(dmarc_record): + return str(dmarc_record).split("sp=")[1].split(";")[0] + else: + return None + def get_dmarc_forensic_reports(dmarc_record): """Returns the email addresses to which forensic reports should be sent.""" - if "ruf=" in str(dmarc_record) and "fo=1" in str(dmarc_record): return str(dmarc_record).split("ruf=")[1].split(";")[0] - else: return None + if "ruf=" in str(dmarc_record) and "fo=1" in str(dmarc_record): + return str(dmarc_record).split("ruf=")[1].split(";")[0] + else: + return None + def get_dmarc_aggregate_reports(dmarc_record): """Returns the email addresses to which aggregate reports should be sent.""" - if "rua=" in str(dmarc_record): return str(dmarc_record).split("rua=")[1].split(";")[0] - else: return None \ No newline at end of file + if "rua=" in str(dmarc_record): + return str(dmarc_record).split("rua=")[1].split(";")[0] + else: + return None diff --git a/libs/dns.py b/libs/dns.py index 848837e..a1a9d72 100644 --- a/libs/dns.py +++ b/libs/dns.py @@ -1,6 +1,8 @@ -import dns.resolver, socket +import dns.resolver +import socket from . import spf, dmarc + def get_soa_record(domain): """Returns the SOA record of a given domain.""" resolver = dns.resolver.Resolver() @@ -15,6 +17,7 @@ def get_soa_record(domain): return socket.gethostbyname(dns_server) return None + def get_dns_server(domain): """Finds the DNS server that serves the domain and returns it, along with any SPF or DMARC records.""" SOA = get_soa_record(domain) @@ -31,10 +34,11 @@ def get_dns_server(domain): dmarc_record = dmarc.get_dmarc_record(domain, '8.8.8.8') if (spf_record is not None) or (dmarc_record is not None): return '8.8.8.8', spf_record, dmarc_record - # No SPF or DMARC record found using 3 different DNS providers. + # No SPF or DMARC record found using 3 different DNS providers. # Defaulting back to Cloudflare return '1.1.1.1', spf_record, dmarc_record + def get_txt_record(domain, record_type): """Returns the TXT record of a given type for a given domain.""" resolver = dns.resolver.Resolver() @@ -43,4 +47,4 @@ def get_txt_record(domain, record_type): query = resolver.query(domain, record_type) return str(query[0]) except: - return None \ No newline at end of file + return None diff --git a/libs/logic.py b/libs/logic.py index 9a850fc..43e6812 100644 --- a/libs/logic.py +++ b/libs/logic.py @@ -12,43 +12,72 @@ def is_spoofable(domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct): 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. 8: Indicates that spoofing is not possible for the domain. - """ + """ try: - if pct and int(pct) != 100: return 3 + if pct and int(pct) != 100: + return 3 elif spf_record is None: - if p is None: return 0 - else: return 8 + if p is None: + return 0 + else: + return 8 elif spf_includes > 10 and p is None: return 0 - elif spf_all == "2many": - if p == "none": return 3 - else: return 8 - elif spf_all and p is None: return 0 + elif spf_all == "2many": + if p == "none": + return 3 + else: + return 8 + elif spf_all and p is None: + return 0 elif spf_all == "-all": - if p and aspf and sp == "none": return 1 - elif aspf is None and sp == "none": return 1 - elif p == "none" and (aspf == "r" or aspf is None) and sp is None: return 4 - elif p == "none" and aspf == "r" and (sp == "reject" or sp == "quarentine"): return 2 - elif p == "none" and aspf is None and (sp == "reject" or sp == "quarentine"): return 5 - elif p == "none" and aspf is None and sp == "none": return 7 - else: return 8 + if p and aspf and sp == "none": + return 1 + elif aspf is None and sp == "none": + return 1 + elif p == "none" and (aspf == "r" or aspf is None) and sp is None: + return 4 + elif p == "none" and aspf == "r" and (sp == "reject" or sp == "quarentine"): + return 2 + elif p == "none" and aspf is None and (sp == "reject" or sp == "quarentine"): + return 5 + elif p == "none" and aspf is None and sp == "none": + return 7 + else: + return 8 elif spf_all == "~all": - if p == "none" and sp == "reject" or sp == "quarentine": return 2 - elif p == "none" and sp is None: return 0 - elif p == "none" and sp == "none": return 7 - elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": return 1 - elif (p == "reject" or p == "quarentine") and aspf and sp == "none": return 1 - else: return 8 + if p == "none" and sp == "reject" or sp == "quarentine": + return 2 + elif p == "none" and sp is None: + return 0 + elif p == "none" and sp == "none": + return 7 + elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": + return 1 + elif (p == "reject" or p == "quarentine") and aspf and sp == "none": + return 1 + else: + return 8 elif spf_all == "?all": - if (p == "reject" or p == "quarentine") and aspf and sp == "none": return 6 - elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": return 6 - elif p == "none" and aspf == "r" and sp is None: return 0 - elif p == "none" and aspf == "r" and sp == "none": return 7 - elif p == "none" and aspf == "s" or None and sp == "none": return 7 - elif p == "none" and aspf == "s" or None and sp is None: return 6 - elif p == "none" and aspf and (sp == "reject" or sp == "quarentine"):return 5 - elif p == "none" and aspf is None and sp == "reject": return 5 - else: return 8 - else: return 8 + if (p == "reject" or p == "quarentine") and aspf and sp == "none": + return 6 + elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": + return 6 + elif p == "none" and aspf == "r" and sp is None: + return 0 + elif p == "none" and aspf == "r" and sp == "none": + return 7 + elif p == "none" and aspf == "s" or None and sp == "none": + return 7 + elif p == "none" and aspf == "s" or None and sp is None: + return 6 + elif p == "none" and aspf and (sp == "reject" or sp == "quarentine"): + return 5 + elif p == "none" and aspf is None and sp == "reject": + return 5 + else: + return 8 + else: + return 8 except: print("If you hit this error message, Open an issue with your testcase.") diff --git a/libs/report.py b/libs/report.py index 957b884..f7296e3 100644 --- a/libs/report.py +++ b/libs/report.py @@ -1,27 +1,36 @@ from colorama import Fore, Style from colorama import init as color_init -import os, pandas as pd +import os +import pandas as pd color_init() + def output_good(line): print(Fore.GREEN + Style.BRIGHT + "[+]" + Style.RESET_ALL, line) + def output_warning(line): print(Fore.YELLOW + Style.BRIGHT + "[?]" + Style.RESET_ALL, line) + def output_bad(line): print(Fore.RED + Style.BRIGHT + "[-]" + Style.RESET_ALL, line) + def output_indifferent(line): print(Fore.BLUE + Style.BRIGHT + "[*]" + Style.RESET_ALL, line) + def output_error(line): - print(Fore.RED + Style.BRIGHT + "[-] !!! " + Style.NORMAL, line, Style.BRIGHT + "!!!\n") + print(Fore.RED + Style.BRIGHT + "[-] !!! " + + Style.NORMAL, line, Style.BRIGHT + "!!!\n") + def output_info(line): print(Fore.WHITE + Style.BRIGHT + "[*]" + Style.RESET_ALL, line) + def write_to_excel(data): """This function writes a DataFrame of data to an Excel file. If the file does not already exist, it creates the file, and if the file already exists, it appends the new data to the existing data.""" @@ -42,7 +51,7 @@ def write_to_excel(data): def printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dmarc_record, p, pct, aspf, sp, fo, rua, spoofable): """This function is a utility function that takes in various parameters related to the results of DMARC and SPF checks and outputs the results to the console in a human-readable format. - + Printer ID Handler: 0: Indicates that spoofing is possible for the domain. 1: Indicates that subdomain spoofing is possible for the domain. @@ -53,7 +62,7 @@ def printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dm 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. 8: Indicates that spoofing is not possible for the domain. - """ + """ output_indifferent(f"Domain: {domain}") output_indifferent(f"Is subdomain: {subdomain}") output_indifferent(f"DNS Server: {dns_server}") @@ -66,18 +75,25 @@ def printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dm output_warning("SPF record contains multiple `All` items.") else: output_info(f"SPF all record: {spf_all}") - output_info(f"SPF include count: {spf_includes}" if spf_includes <= 10 else f"Too many SPF include lookups {spf_includes}.") + output_info(f"SPF include count: {spf_includes}" if spf_includes <= + 10 else f"Too many SPF include lookups {spf_includes}.") else: output_warning("No SPF record found.") if dmarc_record: output_info(f"DMARC record: {dmarc_record}") - output_info(f"Found DMARC policy: {p}" if p else "No DMARC policy found.") - output_info(f"Found DMARC pct: {pct}" if pct else "No DMARC pct found.") - output_info(f"Found DMARC aspf: {aspf}" if aspf else "No DMARC aspf found.") - output_info(f"Found DMARC subdomain policy: {sp}" if sp else "No DMARC subdomain policy found.") - output_indifferent(f"Forensics reports will be sent: {fo}" if fo else "No DMARC foresnic report location found.") - output_indifferent(f"Aggregate reports will be sent to: {rua}" if rua else "No DMARC aggregate report location found.") + output_info( + f"Found DMARC policy: {p}" if p else "No DMARC policy found.") + output_info( + f"Found DMARC pct: {pct}" if pct else "No DMARC pct found.") + output_info( + f"Found DMARC aspf: {aspf}" if aspf else "No DMARC aspf found.") + output_info( + f"Found DMARC subdomain policy: {sp}" if sp else "No DMARC subdomain policy found.") + output_indifferent( + f"Forensics reports will be sent: {fo}" if fo else "No DMARC foresnic report location found.") + output_indifferent( + f"Aggregate reports will be sent to: {rua}" if rua else "No DMARC aggregate report location found.") else: output_warning("No DMARC record found.") @@ -85,5 +101,6 @@ def printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dm if spoofable == 8: output_bad("Spoofing not possible for " + domain) else: - output_good("Spoofing possible for " + domain if spoofable == 0 else "Subdomain spoofing possible for " + domain if spoofable == 1 else "Organizational domain spoofing possible for " + domain if spoofable == 2 else "Spoofing might be possible for " + domain if spoofable == 3 else "Spoofing might be possible (Mailbox dependant) for " + domain if spoofable == 4 else "Organizational domain spoofing may be possible for " + domain if spoofable == 5 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain if spoofable == 6 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain if spoofable == 7 else "") - print() # padding + output_good("Spoofing possible for " + domain if spoofable == 0 else "Subdomain spoofing possible for " + domain if spoofable == 1 else "Organizational domain spoofing possible for " + domain if spoofable == 2 else "Spoofing might be possible for " + domain if spoofable == 3 else "Spoofing might be possible (Mailbox dependant) for " + + domain if spoofable == 4 else "Organizational domain spoofing may be possible for " + domain if spoofable == 5 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain if spoofable == 6 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain if spoofable == 7 else "") + print() # padding diff --git a/libs/spf.py b/libs/spf.py index 80eaa5b..47776b6 100644 --- a/libs/spf.py +++ b/libs/spf.py @@ -1,6 +1,7 @@ import re import dns.resolver + def get_spf_record(domain, dns_server): """Returns the SPF record for a given domain.""" try: @@ -15,6 +16,7 @@ def get_spf_record(domain, dns_server): except: return None + def get_spf_all_string(spf_record): """Returns the string value of the all mechanism in the SPF record.""" all_matches = re.findall(r'[-~?]?all', spf_record) @@ -25,9 +27,11 @@ def get_spf_all_string(spf_record): else: return None + def get_spf_includes(domain, count=0): """Returns the number of includes in the SPF record for a given domain.""" - if count > 10: return count + if count > 10: + return count try: spf_record = get_spf_record(domain, '1.1.1.1') if spf_record: @@ -41,5 +45,5 @@ def get_spf_includes(domain, count=0): count = get_spf_includes(url, count + 1) except: pass - #print("Could not find SPF record for " + domain) - return count \ No newline at end of file + # print("Could not find SPF record for " + domain) + return count diff --git a/spoofy.py b/spoofy.py index 4197dea..316568c 100644 --- a/spoofy.py +++ b/spoofy.py @@ -1,9 +1,13 @@ #! /usr/bin/env python3 -import argparse, tldextract, threading +import argparse +import tldextract +import threading +import os from libs import dmarc, dns, logic, spf, report print_lock = threading.Lock() + def process_domain(domain, output): """This function takes a list of domains and an output format (either 'xls' or 'stdout') as arguments. It processes each domain, collects its relevant details, @@ -18,50 +22,73 @@ def process_domain(domain, output): spf_includes = spf.get_spf_includes(domain) if dmarc_record: p, pct, aspf, sp, fo, rua = dmarc.get_dmarc_details(dmarc_record) - spoofable = logic.is_spoofable(domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct) + spoofable = logic.is_spoofable( + domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct) if output == "xls": with print_lock: data = [{'DOMAIN': domain, 'SUBDOMAIN': subdomain, 'SPF': spf_record, 'SPF MULTIPLE ALLS': spf_all, 'SPF TOO MANY INCLUDES': spf_includes, 'DMARC': dmarc_record, 'DMARC POLICY': p, - 'DMARC PCT': pct, 'DMARC ASPF': aspf, 'DMARC SP': sp, 'DMARC FORENSIC REPORT': fo, - 'DMARC AGGREGATE REPORT': rua, 'SPOOFING POSSIBLE': spoofable}] - report.write_to_excel(data) + 'DMARC PCT': pct, 'DMARC ASPF': aspf, 'DMARC SP': sp, 'DMARC FORENSIC REPORT': fo, + 'DMARC AGGREGATE REPORT': rua, 'SPOOFING POSSIBLE': spoofable}] + report.write_to_excel(data) else: with print_lock: report.printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dmarc_record, p, pct, aspf, - sp, fo, rua, spoofable) + sp, fo, rua, spoofable) except: with print_lock: - report.output_error(f"Domain {domain} is offline or format cannot be interpreted.") + report.output_error( + f"Domain {domain} is offline or format cannot be interpreted.") + def process_domains(domains, output): """ This function is for multithreading woot woot! """ + num_threads = os.cpu_count() threads = [] + num_domains = len(domains) + num_threads = min(num_threads, num_domains) - for domain in domains: - thread = threading.Thread(target=process_domain, args=(domain, output)) + for i in range(num_threads): + start = i * num_domains // num_threads + end = (i + 1) * num_domains // num_threads + + thread = threading.Thread( + target=process_domains_worker, args=(domains[start:end], output)) thread.start() threads.append(thread) for thread in threads: thread.join() + +def process_domains_worker(domains, output): + for domain in domains: + process_domain(domain, output) + + if __name__ == "__main__": parser = argparse.ArgumentParser() group = parser.add_mutually_exclusive_group() - group.add_argument("-iL", type=str, required=False, help="Provide an input list.") - group.add_argument("-d", type=str, required=False, help="Provide an single domain.") - parser.add_argument("-o", type=str, choices=['xls', 'stdout'], required=True, help="Output format stdout or xls") + group.add_argument("-iL", type=str, required=False, + help="Provide an input list.") + group.add_argument("-d", type=str, required=False, + help="Provide an single domain.") + parser.add_argument( + "-o", type=str, choices=['xls', 'stdout'], required=True, help="Output format stdout or xls") options = parser.parse_args() - if not any(vars(options).values()): parser.error("No arguments provided. Usage: `spoofy.py -d [DOMAIN] -o [stdout or xls]` OR `spoofy.py -iL [DOMAIN_LIST] -o [stdout or xls]`") + if not any(vars(options).values()): + parser.error( + "No arguments provided. Usage: `spoofy.py -d [DOMAIN] -o [stdout or xls]` OR `spoofy.py -iL [DOMAIN_LIST] -o [stdout or xls]`") domains = [] if options.iL: try: with open(options.iL, "r") as f: - for line in f: domains.append(line.strip('\n')) - except IOError: report.output_error("File doesnt exist or cannot be read.") + for line in f: + domains.append(line.strip('\n')) + except IOError: + report.output_error("File doesnt exist or cannot be read.") if options.d: domains.append(options.d) process_domains(domains, options.o) From 5731e1f834050e10a189b0a56409f0d81993f6d1 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Wed, 5 Apr 2023 15:35:53 -0700 Subject: [PATCH 3/5] fix: threading bug --- spoofy.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/spoofy.py b/spoofy.py index 316568c..00b52fb 100644 --- a/spoofy.py +++ b/spoofy.py @@ -45,17 +45,10 @@ def process_domains(domains, output): """ This function is for multithreading woot woot! """ - num_threads = os.cpu_count() threads = [] - num_domains = len(domains) - num_threads = min(num_threads, num_domains) - for i in range(num_threads): - start = i * num_domains // num_threads - end = (i + 1) * num_domains // num_threads - - thread = threading.Thread( - target=process_domains_worker, args=(domains[start:end], output)) + for domain in domains: + thread = threading.Thread(target=process_domain, args=(domain, output)) thread.start() threads.append(thread) @@ -63,11 +56,6 @@ def process_domains(domains, output): thread.join() -def process_domains_worker(domains, output): - for domain in domains: - process_domain(domain, output) - - if __name__ == "__main__": parser = argparse.ArgumentParser() group = parser.add_mutually_exclusive_group() From 916912ee286ab0d1eee2af63180f25b87c05355f Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Wed, 5 Apr 2023 15:43:23 -0700 Subject: [PATCH 4/5] feat: default to stdout --- spoofy.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spoofy.py b/spoofy.py index 00b52fb..09c2281 100644 --- a/spoofy.py +++ b/spoofy.py @@ -62,9 +62,10 @@ if __name__ == "__main__": group.add_argument("-iL", type=str, required=False, help="Provide an input list.") group.add_argument("-d", type=str, required=False, - help="Provide an single domain.") - parser.add_argument( - "-o", type=str, choices=['xls', 'stdout'], required=True, help="Output format stdout or xls") + help="Provide a single domain.") + parser.add_argument("-o", type=str, choices=['xls', 'stdout'], + default='stdout', help="Output format: stdout or xls (default: stdout)") + options = parser.parse_args() options = parser.parse_args() if not any(vars(options).values()): parser.error( From a4758d91f467fc50c64696a10e376e61d29a383f Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Wed, 5 Apr 2023 15:52:16 -0700 Subject: [PATCH 5/5] fix: all string got removed somehow :( --- libs/spf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/spf.py b/libs/spf.py index 47776b6..c8923ef 100644 --- a/libs/spf.py +++ b/libs/spf.py @@ -19,7 +19,7 @@ def get_spf_record(domain, dns_server): def get_spf_all_string(spf_record): """Returns the string value of the all mechanism in the SPF record.""" - all_matches = re.findall(r'[-~?]?all', spf_record) + all_matches = re.findall(r'[-~?] ?a ?l ?l', spf_record) if len(all_matches) == 1: return all_matches[0] elif len(all_matches) > 1: