#!/usr/bin/python3 # # SMTP Server configuration black-box testing/audit tool, capable of auditing # SPF/Accepted Domains, DKIM, DMARC, SSL/TLS, SMTP services, banner, Authentication (AUTH, X-EXPS) # user enumerations (VRFY, EXPN, RCPT TO), and others. # # Currently supported tests: # 01) 'spf' - SPF DNS record test # - 'spf-version' - Checks whether SPF record version is valid # - 'all-mechanism-usage' - Checks whether 'all' mechanism is used correctly # - 'allowed-hosts-list' - Checks whether there are not too many allowed hosts # 02) 'dkim' - DKIM DNS record test # - 'public-key-length' - Tests whether DKIM Public Key is at least 1024 bits long # 03) 'dmarc' - DMARC DNS record test # - 'dmarc-version' - Checks whether DMARC record version is valid # - 'policy-rejects-by-default' - Checks whether DMARC uses reject policy # - 'number-of-messages-filtered' - Checks whether there are at least 20% messages filtered. # 04) 'banner-contents' - SMTP Banner sensitive informations leak test # - 'not-contains-version' - Contains version information # - 'not-contains-prohibited-words'- Contains software/OS/or other prohibited name # - 'is-not-long-or-complex' - Seems to be long and/or complex # - 'contains-hostname' - Checks whether SMTP banner contains valid hostname # 05) 'open-relay' - Open-Relay misconfiguration test # - 'internal-internal' # - 'internal-external' # - 'external-internal' # - 'external-external' # - And about 19 other variants # - (the above is very effective against Postfix) # 06) 'vrfy' - VRFY user enumeration vulnerability test # 07) 'expn' - EXPN user enumeration vulnerability test # 08) 'rcpt-to' - RCPT TO user enumeration vulnerability test # 09) 'secure-ciphers' - SSL/TLS ciphers security weak configuration # 10) 'starttls-offering' - STARTTLS offering (opportunistic) weak configuration # 11) 'auth-over-ssl' - STARTTLS before AUTH/X-EXPS enforcement weak configuration # 12) 'auth-methods-offered' - Test against unsecure AUTH/X-EXPS PLAIN/LOGIN methods. # 13) 'tls-key-len' - Checks private key length of negotiated or offered SSL/TLS cipher suites. # 14) 'spf-validation' - Checks whether SMTP Server has been configured to validate sender's SPF # or if it's Microsoft Exchange - that is uses Accepted Domains # # Tests obtain results in tri-state boolean, acordingly: # - 'secure' - The test has succeeded and proved GOOD and SECURE configuration. # - 'unsecure'- The test has succeeded and proved BAD and UNSECURE configuration. # - 'unknown' - The test has failed and did not prove anything. # # ATTACKS offered (--attack option): # Currently the tool offers functionality to lift up user emails enumeration, by the use of # RCPT TO, MAIL FROM and VRFY methods. # # Requirements: # - Python 3.5+ # - dnspython # # TODO: # - refactor all the code cause it's a mess at the moment # - modularize the code # - add support for Outlook's OWA, AutoDiscover, MAPI-over-HTTP, Exchange ActiveSync (EAC) # - add support for NTLM/Kerberos (GSSAPI) authentication when used from Domain-joined Windows box # - BUG: if smtpAudit.py connects with SMTP over non-encrypted channel (ssl: False) it should be alerted as 'unsecure', it is not atm # - test it more thoroughly against various SMTP setups and configurations # - fix the issue with hanged jobs doing DKIM lookup when they reach 99% # - introduce general program timeout # - improve output informations/messages, explanations # - implement options parsing, files passing, verbosity levels, etc # - add more options specifying various parameters, thresholds # - research other potential tests to implement # - add test for 'reject_multi_recipient_bounce' a.k.a. multi RCPT TO commands # - add more options and improve code for penetration-testing oriented usage (active attacks) # # Tested against: # - postfix 3.x # - Microsoft Exchange Server 2013 # # Author: # Mariusz B. / mgeeky, '17-19, # <mb@binary-offensive.com> # import re import sys import ssl import time import json import math import base64 import string import socket import pprint import random import inspect import smtplib import argparse import datetime import threading import multiprocessing from collections import Counter try: from dns import name, resolver, exception except ImportError: print('[!] Module "dnspython" not installed. Try: python3 -m pip install dnspython') sys.exit(-1) if float(sys.version[:3]) < 3.5: print('[!] This program must be run with Python 3.5+') sys.exit(-1) # # =================================================== # GLOBAL PROGRAM CONFIGURATION # VERSION = '0.7.7' config = { # Enable script's output other than tests results. 'verbose' : False, # Turn on severe debugging facilities 'debug' : False, 'smtp_debug': False, # Connection timeout threshold 'timeout' : 5.0, # Delay between consequent requests and connections. 'delay' : 2.0, # During the work of the program - the SMTP server will receive many of our incoming # connections. In such situation, the server may block our new connections due to # exceeding conns limit/rate (like it does Postfix/anvil=count). Therefore it is crucial # to set up long enough interconnection-delay that will take of as soon as server # responds with: "421 Too many connections". For most situations - 60 seconds will do fine. 'too_many_connections_delay' : 60, # Perform full-blown, long-time taking DNS records enumeration (for SPF, DKIM, DMARC) # Accepted values: # - 'always' # - 'on-ip' - do full enumeration only when given with server's IP address # - 'never' 'dns_full' : 'on-ip', # Specifies whether to do full, long-time taking DKIM selectors review. 'dkim_full_enumeration' : True, # External domain used in Open-Relay and other tests 'smtp_external_domain': 'gmail.com', # Pretend to be the following client host: 'pretend_client_hostname': 'smtp.gmail.com', # Specifies whether to show results JSON unfolded (nested) or only when needed 'always_unfolded_results': False, # Num of enumeration tries until test is considered completed (whether it succeeds or not). # Value -1 denotes to go with full spectrum of the test. 'max_enumerations' : -1, # Use threading - may cause some issues with responsiveness, or cause program to hang. 'threads' : True, # Uncommon words to have in DKIM selectors permutations list 'uncommon_words' : (), # DO NOT CHANGE THIS ONE. 'tests_to_carry' : 'all', 'tests_to_skip' : '', # Maximum number of parallel process in DKIM enumeration test 'parallel_processes' : 10, # When DNS resolver becomes busy handling thousands of DKIM queries, # we can delay asking for more selectors iteratively. 'delay_dkim_queries' : True, # Output format. Possible values: json, text 'format' : 'text', # Colorize output 'colors': True, # Attack mode 'attack': False, # Minimal key length to consider it secure 'key_len' : 2048, # Maximum hosts in SPF considered secure: 'spf_maximum_hosts' : 32, } # # =================================================== # PROGRAM IMPLEMENTATION # class colors: '''Colors class: reset all colors with colors.reset two subclasses fg for foreground and bg for background. use as colors.subclass.colorname. i.e. colors.fg.red or colors.bg.green also, the generic bold, disable, underline, reverse, strikethrough, and invisible work with the main class i.e. colors.bold ''' reset = '\033[0m' bold = '\033[01m' disable = '\033[02m' underline = '\033[04m' reverse = '\033[07m' strikethrough = '\033[09m' invisible = '\033[08m' class fg: black = '\033[30m' red = '\033[31m' green = '\033[32m' orange = '\033[33m' blue = '\033[34m' purple = '\033[35m' cyan = '\033[36m' lightgrey = '\033[37m' darkgrey = '\033[90m' lightred = '\033[91m' lightgreen = '\033[92m' yellow = '\033[93m' lightblue = '\033[94m' pink = '\033[95m' lightcyan = '\033[96m' class bg: black = '\033[40m' red = '\033[41m' green = '\033[42m' orange = '\033[43m' blue = '\033[44m' purple = '\033[45m' cyan = '\033[46m' lightgrey = '\033[47m' # # Output routines. # def _out(x, toOutLine = False, col = colors.reset): if config['colors']: text = '{}{}{}\n'.format( col, x, colors.reset ) else: text = x + '\n' if config['debug'] or config['verbose']: if config['debug']: caller = (inspect.getouterframes(inspect.currentframe(), 2))[2][3] if x.startswith('['): x = x[:4] + ' ' + caller + '(): ' + x[4:] sys.stderr.write(text) elif config['format'] == 'text' and \ (toOutLine or 'SECURE: ' in x or 'UNKNOWN: ' in x): if config['attack']: sys.stderr.write(text) else: sys.stdout.write(text) def dbg(x): if config['debug']: caller2 = (inspect.getouterframes(inspect.currentframe(), 2))[1][3] caller1 = (inspect.getouterframes(inspect.currentframe(), 2))[2][3] caller = '{}() -> {}'.format(caller1, caller2) text = x if config['colors']: text = '{}{}{}'.format(colors.fg.lightblue, x, colors.reset) sys.stderr.write('[dbg] ' + caller + '(): ' + text + '\n') def out(x, toOutLine = False): _out('[.] ' + x, toOutLine) def info(x, toOutLine = False):_out('[?] ' + x, toOutLine, colors.fg.yellow) def err(x, toOutLine = False): _out('[!] ' + x, toOutLine, colors.bg.red + colors.fg.black) def fail(x, toOutLine = False):_out('[-] ' + x, toOutLine, colors.fg.red + colors.bold) def ok(x, toOutLine = False): _out('[+] ' + x, toOutLine, colors.fg.green + colors.bold) class BannerParser: softwareWeight = 3 osWeight = 2 # MTAs prohibitedSoftwareWords = ( 'Exim', 'Postfix', 'Maildrop', 'Cyrus', 'Sendmail', 'Exchange', 'Lotus Domino', ) prohibitedOSWords = ( 'Windows', 'Linux', 'Debian', 'Fedora', 'Unix', '/GNU)', 'SuSE', 'Mandriva', 'Centos', 'Gentoo', 'Red Hat', 'Microsoft(R) Windows(R)', ) # Certain words will have greater weight since they are more important to hide in banner. # Every word must be in it's own list. prohibitedWords = prohibitedSoftwareWords + prohibitedOSWords + ( 'Microsoft ESMTP', 'MAIL service ready at ', 'Version:', 'qmail', 'Ver.', '(v.', 'build:', ) wellKnownDefaultBanners = { 'Microsoft Exchange' : 'Microsoft ESMTP MAIL service ready at ', 'IBM Lotus Domino' : 'ESMTP Service (Lotus Domino ', } # Statistical banner's length characteristics lengthCharacteristics = { 'mean': 66.08, 'median': 58.5, 'std.dev': 27.27 } # Reduced entropy statistical characteristics after removing potential timestamp # (as being added by e.g. Exim and Exchange) reducedEntropyCharacteristics = { 'mean': 3.171583046, 'median': 3.203097614, 'std.dev': 0.191227689 } weights = { 'prohibitedWord': 1, 'versionFound': 2, 'versionNearProhibitedWord': 3, } # Max penalty score to consider banner unsecure. maxPenaltyScore = 4.0 localHostnameRegex = r'(?:[0-9]{3}\s)?([\w\-\.]+).*' def __init__(self): self.results = { 'not-contains-version' : True, 'not-contains-prohibited-words' : True, 'is-not-long-or-complex' : True, 'contains-hostname' : False, } @staticmethod def entropy(data, unit='natural'): ''' Source: https://stackoverflow.com/a/37890790 ''' base = { 'shannon' : 2., 'natural' : math.exp(1), 'hartley' : 10. } if len(data) <= 1: return 0 counts = Counter() for d in data: counts[d] += 1 probs = [float(c) / len(data) for c in counts.values()] probs = [p for p in probs if p > 0.] ent = 0 for p in probs: if p > 0.: ent -= p * math.log(p, base[unit]) return ent @staticmethod def removeTimestamp(banner): rex = r'\w{3}, \d{1,2} \w{3} \d{4} \d{2}:\d{2}:\d{2}(?: .\d{4})?' return re.sub(rex, '', banner) def parseBanner(self, banner): if not banner: if config['always_unfolded_results']: return dict.fromkeys(self.results, None) else: return None penalty = 0 versionFound = '' for service, wellKnownBanner in BannerParser.wellKnownDefaultBanners.items(): if wellKnownBanner.lower() in banner.lower(): fail('UNSECURE: Default banner found for {}: "{}"'.format( service, banner )) return False penalty += self.analyseBannerEntropy(banner) penalty += self.checkForProhibitedWordsAndVersion(banner) penalty += self.checkHostnameInBanner(banner) ret = (penalty < BannerParser.maxPenaltyScore) if not ret: fail('UNSECURE: Banner considered revealing sensitive informations (penalty: {}/{})!'.format( penalty, BannerParser.maxPenaltyScore )) _out('\tBanner: ("{}")'.format(banner), toOutLine = True) return self.results else: ok('SECURE: Banner was not found leaking anything. (penalty: {}/{})'.format( penalty, BannerParser.maxPenaltyScore )) _out('\tBanner: ("{}")'.format(banner), toOutLine = True) if all(self.results.values()) and not config['always_unfolded_results']: return True else: return self.results def analyseBannerEntropy(self, banner): penalty = 0 reducedBanner = BannerParser.removeTimestamp(banner) bannerEntropy = BannerParser.entropy(reducedBanner) dbg('Analysing banner: "{}"'.format(banner)) dbg('Length: {}, reduced banner Entropy: {:.6f}'.format(len(banner), bannerEntropy)) if len(reducedBanner) > (BannerParser.lengthCharacteristics['mean'] \ + 1 * BannerParser.lengthCharacteristics['std.dev']): info('Warning: Banner seems to be very long. Consider shortening it.', toOutLine = True) self.results['is-not-long-or-complex'] = False penalty += 1 if bannerEntropy > (BannerParser.reducedEntropyCharacteristics['mean'] \ + 1 * BannerParser.reducedEntropyCharacteristics['std.dev']): info('Warning: Banner seems to be complex in terms of entropy.' ' Consider generalising it.', toOutLine = True) self.results['is-not-long-or-complex'] = False penalty += 1 return penalty def checkForProhibitedWordsAndVersion(self, banner): penalty = 0 versionFound = '' regexVersionNumber = r'(?:(\d+)\.)?(?:(\d+)\.)?(?:(\d+)\.\d+)' match = re.search(regexVersionNumber, banner) if match: versionFound = match.group(0) fail('Sensitive software version number found in banner: "{}"'.format( versionFound ), toOutLine = True) self.results['not-contains-version'] = False penalty += BannerParser.weights['versionFound'] alreadyFound = set() for word in BannerParser.prohibitedWords: if word.lower() in banner.lower(): if not word.lower() in alreadyFound: info('Prohibited word found in banner: "{}"'.format( word ), toOutLine = True) self.results['not-contains-prohibited-words'] = True alreadyFound.add(word.lower()) mult = 1 if word.lower() in BannerParser.prohibitedSoftwareWords: mult = BannerParser.softwareWeight elif word.lower() in BannerParser.prohibitedOSWords: mult = BannerParser.prohibitedOSWords penalty += (float(mult) * BannerParser.weights['prohibitedWord']) # Does the word immediately follow or precede version number? if versionFound: surrounds = ( '{}{}'.format(word, versionFound), '{}{}'.format(versionFound, word), '{} {}'.format(word, versionFound), '{} {}'.format(versionFound, word), '{}/{}'.format(word, versionFound), '{}/{}'.format(versionFound, word), ) for surr in surrounds: if surr in banner: info('Word was found lying around version: "{}". '\ 'Consider removing it.'.format( surr ), toOutLine = True) penalty += BannerParser.weights['versionNearProhibitedWord'] break return penalty def checkHostnameInBanner(self, banner): penalty = 0 matched = re.search(BannerParser.localHostnameRegex, banner) if matched: localHostname = matched.group(1) self.results['contains-hostname'] = True info('Extracted hostname from banner: "{}"'.format(localHostname)) else: fail('SMTP Banner does not contain server\'s hostname. This may cause SPAM reports.', toOutLine = True) penalty = 1 return penalty class DmarcParser: def __init__(self): self.results = { 'dmarc-version' : False, 'policy-rejects-by-default': False, 'number-of-messages-filtered': True, } def processDmarc(self, record): if not record: if config['always_unfolded_results']: return dict.fromkeys(self.results, None) else: return None for keyValue in record.split(' '): if not keyValue: break k, v = keyValue.split('=') k = k.strip() v = v.strip() if v.endswith(';'): v = v[:-1] if k == 'v': self.results['dmarc-version'] = v.lower() == 'dmarc1' if not self.results['dmarc-version']: fail('UNSECURE: Unknown version of DMARC stated: {}'.format(v)) elif k == 'p': if v.lower() not in ('none', 'reject', 'quarantine'): fail('UNSECURE: Unknown policy stated: {}'.format(v)) self.results['policy-rejects-by-default'] = False else: self.results['policy-rejects-by-default'] = v.lower() == 'reject' if not self.results['policy-rejects-by-default']: fail('UNSECURE: DMARC policy does not reject unverified messages ({}).'.format( v )) elif k == 'pct': try: perc = int(v) self.results['number-of-messages-filtered'] = perc >= 20 if self.results['number-of-messages-filtered']: info('Percentage of filtered messages is satisfiable ({})'.format( perc )) else: fail('UNSECURE: Unsatisfiable percentage of messages filtered: {}!'.format( perc )) except ValueError: fail('Defined "pct" is not a valid percentage!') self.results['number-of-messages-filtered'] = False if not config['always_unfolded_results'] and all(self.results.values()): return True else: return self.results class DkimParser: minimumDkimKeyLength = 1024 def __init__(self): self.results = { 'public-key-length': True, } def process(self, record): self.testKeyLength(record) if not config['always_unfolded_results'] and all(self.results.values()): return True else: return self.results def testKeyLength(self, txt): tags = txt.split(';') dkim = {} for t in tags: k, v = t.strip().split('=') dkim[k] = v if 'p' not in dkim.keys(): return False pubkey = base64.b64decode(dkim['p']) keyLen = (len(pubkey) - 38) * 8 # 38 bytes is for key's metadata if keyLen < 0: fail('Incorrect Public Key in DKIM!') keyLen = 0 dbg('DKIM: version = {}, algorithm = {}, key length = {}'.format( dkim['v'], dkim['k'], keyLen )) if keyLen < DkimParser.minimumDkimKeyLength: fail('UNSECURE: DKIM Public Key length is insufficient: {}. ' \ 'Recommended at least {}'.format( keyLen, DkimParser.minimumDkimKeyLength )) self.results['public-key-length'] = False else: ok('SECURE: DKIM Public key is of sufficient length: {}'.format(keyLen)) self.results['public-key-length'] = True return self.results['public-key-length'] class SpfParser: #maxAllowedNetworkMask = 28 maxNumberOfDomainsAllowed = 3 allowedHostsNumber = 0 allowSpecifiers = 0 mechanisms = ('all', 'ip4', 'ip6', 'a', 'mx', 'ptr', 'exists', 'include') qualifiers = ('+', '-', '~', '?') def __init__(self): self.results = { 'spf-version': True, 'all-mechanism-usage': True, 'allowed-hosts-list': True, } self.addressBasedMechanism = 0 def process(self, record): if not record: if config['always_unfolded_results']: return dict.fromkeys(self.results, None) else: return None record = record.lower() tokens = record.split(' ') dbg('Processing SPF record: "{}"'.format(record)) for token in tokens: qualifier = '' if not token: continue dbg('SPF token: {}'.format(token)) if token.startswith('v=spf'): self.results['spf-version'] = self.processVersion(token) continue if token[0] not in string.ascii_letters and token[0] not in SpfParser.qualifiers: fail('SPF record contains unknown qualifier: "{}". Ignoring it...'.format( token[0] )) qualifier = token[0] token = token[1:] else: qualifier = '+' if 'all' in token: self.results['all-mechanism-correctly-used'] = \ self.processAllMechanism(token, record, qualifier) continue if len(list(filter(lambda x: token.startswith(x), SpfParser.mechanisms))) >= 1: self.processMechanism(record, token, qualifier) if not self.results['allowed-hosts-list']: #maxAllowed = 2 ** (32 - SpfParser.maxAllowedNetworkMask) maxAllowed = config['spf_maximum_hosts'] fail('UNSECURE: SPF record allows more than {} max allowed hosts: {} in total.'.format( maxAllowed, self.allowedHostsNumber )) _out('\tRecord: ("{}")'.format(record)) if not self.results['allowed-hosts-list']: fail('There are too many allowed domains/CIDR ranges specified in SPF record: {}.'.format( self.allowSpecifiers )) if not config['always_unfolded_results'] and all(self.results.values()): dbg('All tests passed.') return True else: if not all(self.results.values()): dbg('Not all tests passed.: {}'.format(self.results)) else: dbg('All tests passed.') return self.results def areThereAnyOtherMechanismsThan(self, mechanism, record): tokens = record.split(' ') otherMechanisms = 0 for token in tokens: if not token: continue if token.startswith('v='): continue if token[0] in SpfParser.qualifiers: token = token[1:] if token == mechanism: continue if ':' in token: for s in token.split(':'): if s in SpfParser.mechanisms: otherMechanisms += 1 break if '/' in token: for s in token.split('/'): if s in SpfParser.mechanisms: otherMechanisms += 1 break if token in SpfParser.mechanisms: otherMechanisms += 1 dbg('Found {} other mechanisms than "{}"'.format(otherMechanisms, mechanism)) return (otherMechanisms > 0) def processVersion(self, token): v, ver = token.split('=') validVersions = ('1') for version in validVersions: if 'spf{}'.format(version) == ver: dbg('SPF version was found valid.') return True fail('SPF version is invalid.') return False def processAllMechanism(self, token, record, qualifier): if not record.endswith(token): fail('SPF Record wrongly stated - "{}" mechanism must be placed at the end!'.format( token )) return False if token == 'all' and qualifier == '+': fail('UNSECURE: SPF too permissive: "The domain owner thinks that SPF is useless and/or doesn\'t care.": "{}"'.format(record)) return False if not self.areThereAnyOtherMechanismsThan('all', record): fail('SPF "all" mechanism is too restrictive: "The domain sends no mail at all.": "{}"'.format(record), toOutLine = True) return False return True def getNetworkSize(self, net): dbg('Getting network size out of: {}'.format(net)) m = re.match(r'[\w\.-:]+\/(\d{1,2})', net) if m: mask = int(m.group(1)) return 2 ** (32 - mask) # Assuming any other value is a one host. return 1 def processMechanism(self, record, token, qualifier): key, value = None, None addressBasedMechanisms = ('ip4', 'ip6', 'a', 'mx') numOfAddrBasedMechanisms = len(list(filter(lambda x: token.startswith(x), addressBasedMechanisms))) # Processing address-based mechanisms. if numOfAddrBasedMechanisms >= 1: if self.addressBasedMechanism >= SpfParser.maxNumberOfDomainsAllowed: self.results['allowed-hosts-list'] = False self.allowSpecifiers += 1 else: if qualifier == '+': self.addressBasedMechanism += 1 self.checkTooManyAllowedHosts(token, record, qualifier) else: dbg('Mechanism: "{}" not being passed.'.format(token)) def checkTooManyAllowedHosts(self, token, record, qualifier): if self.results['allowed-hosts-list'] != True: return tok, val = None, None if ':' in token: tok, val = token.split(':') elif '/' in token and not ':' in token: tok, val = token.split('/') val = '0/{}'.format(val) elif token in SpfParser.mechanisms: tok = token val = '0/32' else: err('Invalid address-based mechanism: {}!'.format(token)) return dbg('Processing SPF mechanism: "{}" with value: "{}"'.format( tok, val )) size = self.getNetworkSize(val) #maxAllowed = 2 ** (32 - SpfParser.maxAllowedNetworkMask) maxAllowed = config['spf_maximum_hosts'] self.allowedHostsNumber += size if size > maxAllowed: self.results['minimum-allowed-hosts-list'] = False fail('UNSECURE: Too many hosts allowed in directive: {} - total: {}'.format( token, size )) class SmtpTester: testsConducted = { 'spf' : 'SPF DNS record test', 'dkim' : 'DKIM DNS record test', 'dmarc' : 'DMARC DNS record test', 'banner-contents': 'SMTP Banner sensitive informations leak test', 'starttls-offering': 'STARTTLS offering (opportunistic) weak configuration', 'secure-ciphers': 'SSL/TLS ciphers security weak configuration', 'tls-key-len': 'Checks private key length of negotiated or offered SSL/TLS cipher suites.', 'auth-methods-offered': 'Test against unsecure AUTH/X-EXPS PLAIN/LOGIN methods.', 'auth-over-ssl': 'STARTTLS before AUTH/X-EXPS enforcement weak configuration', 'vrfy': 'VRFY user enumaration vulnerability test', 'expn': 'EXPN user enumaration vulnerability test', 'rcpt-to': 'RCPT TO user enumaration vulnerability test', 'open-relay': 'Open-Relay misconfiguration test', 'spf-validation': 'Checks whether SMTP Server has been configured to validate sender\'s SPF or Accepted Domains in case of MS Exchange', } connectionLessTests = ( 'spf', 'dkim', 'dmarc' ) # 25 - plain text SMTP # 465 - SMTP over SSL # 587 - SMTP-AUTH / Submission commonSmtpPorts = (25, 465, 587, ) # Common AUTH X methods with sample Base64 authentication data. commonSmtpAuthMethods = { 'PLAIN' : base64.b64encode('\0user\0password'.encode()), 'LOGIN' : ( (base64.b64encode('user'.encode()), base64.b64encode('password'.encode())), ('user@DOMAIN.COM', base64.b64encode('password'.encode())) ), 'NTLM' : ( 'TlRMTVNTUAABAAAABzIAAAYABgArAAAACwALACAAAABXT1JLU1RBVElPTkRPTUFJTg==', 'TlRMTVNTUAABAAAAB4IIogAAAAAAAAAAAAAAAAAAAAAGAbEdAAAADw==', ), 'MD5' : '', 'DIGEST-MD5' : '', 'CRAM-MD5' : '', } smtpAuthServices = ('AUTH', 'X-EXPS') authMethodsNotNeedingStarttls = ('NTLM', 'GSSAPI') # Pretend you are the following host: pretendLocalHostname = config['pretend_client_hostname'] maxStarttlsRetries = 5 # Source: SSLabs research: # https://github.com/ssllabs/research/wiki/SSL-and-TLS-Deployment-Best-Practices secureCipherSuitesList = ( 'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES128-SHA', 'ECDHE-ECDSA-AES256-SHA', 'ECDHE-ECDSA-AES128-SHA256', 'ECDHE-ECDSA-AES256-SHA384', 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES128-SHA', 'ECDHE-RSA-AES256-SHA', 'ECDHE-RSA-AES128-SHA256', 'ECDHE-RSA-AES256-SHA384', 'DHE-RSA-AES128-GCM-SHA256', 'DHE-RSA-AES256-GCM-SHA384', 'DHE-RSA-AES128-SHA', 'DHE-RSA-AES256-SHA', 'DHE-RSA-AES128-SHA256', 'DHE-RSA-AES256-SHA256', 'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256', 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384', 'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA', 'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA', 'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256', 'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384', 'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256', 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA', 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA', 'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256', 'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384', 'TLS_DHE_RSA_WITH_AES_128_GCM_SHA256', 'TLS_DHE_RSA_WITH_AES_256_GCM_SHA384', 'TLS_DHE_RSA_WITH_AES_128_CBC_SHA', 'TLS_DHE_RSA_WITH_AES_256_CBC_SHA', 'TLS_DHE_RSA_WITH_AES_128_CBC_SHA256', 'TLS_DHE_RSA_WITH_AES_256_CBC_SHA256', ) def __init__(self, hostname, port = None, forceSSL = False, dkimSelectorsList = None, userNamesList = None, openRelayParams = ('', ''), connect = True, mailDomain = '' ): self.originalHostname = hostname self.hostname = hostname self.remoteHostname = self.localHostname = self.domain = self.resolvedIPAddress = '' self.port = port self.mailDomain = mailDomain self.ssl = None if not forceSSL else True self.forceSSL = forceSSL self.server = None self.starttlsFailures = 0 self.starttlsSucceeded = False self.dkimSelectorsList = dkimSelectorsList self.userNamesList = userNamesList self.availableServices = set() self.banner = '' self.connected = False self.dumpTlsOnce = False self.connectionErrors = 0 self.connectionErrorCodes = {} self.results = {} self.threads = {} self.stopEverything = False self.server_tls_params = {} self.openRelayParams = openRelayParams self.spfValidated = False if not hostname: fail('No hostname specified!') return assert config['dns_full'] in ('always', 'on-ip', 'never'), \ "config['dns_full'] wrongly stated." if re.match(r'[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}', hostname) and not mailDomain: spf = SmtpTester.checkIfTestToRun('spf') dkim = SmtpTester.checkIfTestToRun('dkim') dmarc = SmtpTester.checkIfTestToRun('dmarc') if spf or dkim or dmarc: out('Server\'s IP specified and no mail domain: SPF/DKIM/DMARC results may be inaccurate.', toOutLine = True) out('You may want to specify \'--domain\' and repeat those tests for greater confidence.', toOutLine = True) self.resolvedIPAddress = hostname needsConnection = False for test in SmtpTester.testsConducted.keys(): if self.checkIfTestToRun(test) and test not in SmtpTester.connectionLessTests: needsConnection = True break try: if needsConnection and connect and not self.connect(): sys.exit(-1) except KeyboardInterrupt: fail('Premature program interruption. Did not even obtained connection.') sys.exit(-1) self.connected = True if not self.resolveDomainName(): sys.exit(-1) @staticmethod def getTests(): return SmtpTester.testsConducted def stop(self): err('Stopping everything.') config['max_enumerations'] = 0 self.stopEverything = True self.disconnect() def resolveDomainName(self): if self.hostname: resolutionFailed = False if re.match('^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$', self.hostname): resolved = None try: resolved = socket.gethostbyaddr(self.hostname) self.remoteHostname = repr(resolved[0]).replace("'", '') info('Resolved DNS (A) name: "{}"'.format( self.remoteHostname )) except socket.herror as e: dbg('IP address could not be resolved into hostname.') resolutionFailed = True else: try: resolved = socket.gethostbyname(self.hostname) info('Resolved IP address / PTR: "{}"'.format( resolved )) self.resolvedIPAddress = resolved except socket.herror as e: dbg('DNS name could not be resolved into IP address.') matched = None if self.banner: matched = re.search(BannerParser.localHostnameRegex, self.banner) if matched: self.localHostname = matched.group(1) info('SMTP banner revealed server name: "{}".'.format( self.localHostname )) if resolutionFailed and not matched: fail("Could not obtain server's hostname from neither IP nor banner!") return False elif not resolutionFailed and not matched: info("Resolved IP but could not obtain server's hostname from the banner.") return True elif resolutionFailed and matched: info("It was possible to obtain server's hostname from the banner but not to resolve IP address.") return True return True def printDNS(getDNSValidHostname): def wrapper(self, noRemote = True): out = getDNSValidHostname(self, noRemote) if config['smtp_debug']: dbg('Using hostname: "{}" for DNS query.'.format(out)) return out return wrapper @printDNS def getDNSValidHostname(self, noRemote = False): if self.localHostname: return self.localHostname elif not noRemote and self.remoteHostname: return self.remoteHostname else: return self.hostname def getMailDomain(self): if self.mailDomain: return self.mailDomain hostname = self.getDNSValidHostname(noRemote = True) return '.'.join(hostname.split('.')[1:]) def getAllPossibleDomainNames(self): allOfThem = [ self.originalHostname, # 0 self.hostname, # 1 self.localHostname, # 2 self.getMailDomain(), # 3 self.remoteHostname, # 4 # 5. FQDN without first LLD '.'.join(self.originalHostname.split('.')[1:]) ] uniq = set() ret = [] # Workaround for having OrderedSet() alike collection w/o importing such modules for host in allOfThem: if host not in uniq: ret.append(host) uniq.add(host) return ret def getDomainsToReviewDNS(self): if self.mailDomain: return [self.mailDomain,] domainsToReview = [self.originalHostname] doFullReview = False ipRex = r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}' if config['dns_full'] == 'always' or \ (config['dns_full'] == 'on-ip' and re.match(ipRex, self.originalHostname)): doFullReview = True if doFullReview: domainsToReview = list(filter( lambda x: not re.match(ipRex, x), self.getAllPossibleDomainNames() )) # Get only domains, not subdomains. domainsToReview = set(map( lambda x: '.'.join(x.split('.')[-2:]), domainsToReview )) out = list(filter(None, domainsToReview)) out = [x.replace('"', '').replace("'", "") for x in out] return out def disconnect(self): if self.server: try: self.server.quit() del self.server self.server = None time.sleep(0.5) except: pass def connect(self, quiet = False, sayHello = False): ret = False noBannerPreviously = self.banner == '' if self.stopEverything: return False self.disconnect() if self.port == None: ret = self.tryToConnectOnDifferentPorts(quiet) else: ret = self.reconnect(quiet) if noBannerPreviously and self.banner: _out('SMTP banner: "{}"'.format(self.banner), True, colors.fg.pink) if ret and sayHello: dbg('Saying HELO/EHLO to the server...') out = self.sendcmd('EHLO ' + SmtpTester.pretendLocalHostname) dbg('Server responded to HELO/EHLO with: {}'.format(out)) if out[0]: self.parseHelpOutputAndUpdateServicesList(out[1].decode()) else: err('Could not obtain response to EHLO/HELO. Fatal error.', toOutLine = True) sys.exit(-1) return ret def connectSocket(self, port, ssl, sayHello = True): if ssl: self.server = smtplib.SMTP_SSL( local_hostname = SmtpTester.pretendLocalHostname, timeout = config['timeout'] ) else: self.server = smtplib.SMTP( local_hostname = SmtpTester.pretendLocalHostname, timeout = config['timeout'] ) if config['smtp_debug']: self.server.set_debuglevel(9) if config['delay'] > 0.0: time.sleep(config['delay']) out = self.server.connect(self.hostname, port) if out[0] in (220, 250, ): dbg('Connected over {} to {}:{}'.format( 'SSL' if ssl else 'Non-SSL', self.hostname, port )) self.banner = out[1].decode() self.port = port self.ssl = ssl if ssl: self.performedStarttls = True self.server_tls_params = { 'cipher' : self.server.sock.cipher(), 'version': self.server.sock.version(), 'shared_ciphers': self.server.sock.shared_ciphers(), 'compression': self.server.sock.compression(), 'DER_peercert': self.server.sock.getpeercert(True), 'selected_alpn_protocol': self.server.sock.selected_alpn_protocol(), 'selected_npn_protocol': self.server.sock.selected_npn_protocol(), } if sayHello: dbg('Saying HELO/EHLO to the server...') out = self.sendcmd('EHLO ' + SmtpTester.pretendLocalHostname) dbg('Server responded to HELO/EHLO with: {}'.format(out)) self.parseHelpOutputAndUpdateServicesList(self.banner) else: if out[0] not in self.connectionErrorCodes.keys(): self.connectionErrorCodes[out[0]] = 0 else: self.connectionErrorCodes[out[0]] += 1 if out[0] == 421: # 421 - Too many connections error pass elif out[0] == 450: # 450 - 4.3.2 try again later if self.connectionErrorCodes[out[0]] > 5: err("We have sent too many connection requests and were temporarily blocked.\nSorry. Try again later.", toOutLine = True) sys.exit(-1) else: fail('Waiting 30s for server to cool down after our flooding...') time.sleep(30) elif out[0] == 554: # 554 - 5.7.1 no reverse DNS out = False if self.connectionErrors > 0 else True err('Our host\'s IP does not have reverse DNS records - what makes SMTP server reject us.', toOutLine = out) if self.connectionErrors > 5: err('Could not make the SMTP server, ccept us without reverse DNS record.', toOutLine = True) sys.exit(-1) else: err('Unexpected response after connection, from {}:{}:\n\tCode: {}, Message: {}.'.format( self.hostname, port, out[0], out[1] )) dbg('-> Got response: {}'.format(out)) self.connectionErrors += 1 if self.connectionErrors > 20: err('Could not connect to the SMTP server!') sys.exit(-1) return out def tryToConnectOnSSLandNot(self, port): try: # Try connecting over Non-SSL socket if self.forceSSL: raise Exception('forced ssl') dbg('Trying non-SSL over port: {}'.format(port)) self.connectSocket(port, False) return True except Exception as e: # Try connecting over SSL socket dbg('Exception occured: "{}"'.format(str(e))) try: dbg('Trying SSL over port: {}'.format(port)) self.connectSocket(port, True) self.starttlsSucceeded = True return True except Exception as e: dbg('Both non-SSL and SSL connections failed: "{}"'.format(str(e))) return False def tryToConnectOnDifferentPorts(self, quiet): # # No previous connection. # Enumerate common SMTP ports and find opened one. # succeeded = False for port in SmtpTester.commonSmtpPorts: if self.stopEverything: break if self.tryToConnectOnSSLandNot(port): succeeded = True break if not quiet: if not succeeded: err('Could not connect to the SMTP server!') else: ok('Connected to the server over port: {}, SSL: {}'.format( self.port, self.ssl ), toOutLine = True) return succeeded def reconnect(self, quiet, sayHello = True): # # The script has previously connected or knows what port to choose. # multiplier = 0 for i in range(4): try: out = self.connectSocket(self.port, self.ssl, sayHello = sayHello) if out[0] == 421: multiplier += 1 delay = multiplier * config['too_many_connections_delay'] info('Awaiting {} secs for server to close some of our connections...'.format( delay )) time.sleep(delay) continue else: dbg('Reconnection succeeded ({})'.format(out)) return True except (socket.gaierror, socket.timeout, smtplib.SMTPServerDisconnected, ConnectionResetError) as e: dbg('Reconnection failed ({}/3): "{}"'.format(i, str(e))) dbg('Server could not reconnect after it unexpectedly closed socket.') return False def setSocketTimeout(self, timeout = config['timeout']): try: self.server.sock.settimeout(timeout) except (AttributeError, OSError): dbg('Socket lost somehow. Reconnecting...') if self.connect(True): try: self.server.sock.settimeout(timeout) except (AttributeError, OSError): pass else: dbg('FAILED: Could not reconnect to set socket timeout.') def processOutput(sendcmd): def wrapper(self, command, nowrap = False): out = sendcmd(self, command, nowrap) if nowrap: return out if out and (out[0] == 530 and b'STARTTLS' in out[1]): if self.starttlsFailures >= SmtpTester.maxStarttlsRetries: dbg('Already tried STARTTLS and it have failed too many times.') return (False, False) dbg('STARTTLS reconnection after wrapping command ({})...'.format(command)) if not self.performStarttls(): dbg('STARTTLS wrapping failed.') return (False, 'Failure') dbg('Wrapping succeeded. Retrying command "{}" after STARTTLS.'.format( command )) return sendcmd(self, command) elif out and (out[0] == 421): # 'Exceeded bad SMTP command limit, disconnecting.' dbg('Reconnecting due to exceeded number of SMTP connections...') if self.connect(quiet = True): return sendcmd(self, command) else: dbg('Could not reconnect after exceeded number of connections!') return (False, False) self.checkIfSpfEnforced(out) return out return wrapper def performStarttls(self, sendEhlo = True): ret = True if self.ssl == True: dbg('The connection is already carried through SSL Socket.') return True if self.starttlsFailures > SmtpTester.maxStarttlsRetries: fail('Giving up on STARTTLS. There were too many failures...') return False out = self.sendcmd('STARTTLS') if out[0] == 220: dbg('STARTTLS engaged. Wrapping socket around SSL layer.') context = ssl.create_default_context() # Allow unsecure ciphers like SSLv2 and SSLv3 context.options &= ~(ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3) context.check_hostname = False context.verify_mode = ssl.CERT_NONE if self.server and self.server.sock: self.setSocketTimeout(5 * config['timeout']) try: newsock = context.wrap_socket( self.server.sock, server_hostname = SmtpTester.pretendLocalHostname ) # Re-initializing manually the smtplib instance self.server.sock = newsock self.server.file = None self.server.helo_resp = None self.server.ehlo_resp = None self.server.esmtp_features = {} self.server.does_esmtp = 0 self.starttlsSucceeded = True self.server_tls_params = { 'cipher' : newsock.cipher(), 'version': newsock.version(), 'shared_ciphers': newsock.shared_ciphers(), 'compression': newsock.compression(), 'DER_peercert': newsock.getpeercert(True), 'selected_alpn_protocol': newsock.selected_alpn_protocol(), 'selected_npn_protocol': newsock.selected_npn_protocol(), } dbg('Connected to the SMTP Server via SSL/TLS.') if not self.dumpTlsOnce: dbg('SSL Socket parameters:\n{}'.format(pprint.pformat(self.server_tls_params))) self.dumpTlsOnce = True if sendEhlo: dbg('Sending EHLO after STARTTLS...') out = self.sendcmd('EHLO ' + SmtpTester.pretendLocalHostname) if out[0]: dbg('EHLO after STARTTLS returned: {}'.format(out)) else: err('EHLO after STARTTLS failed: {}'.format(out)) except (socket.timeout, ConnectionResetError) as e: err('SSL Handshake timed-out (Firewall filtering?). Fall back to plain channel.') dbg('STARTTLS exception: "{}"'.format(str(e))) self.starttlsFailures += 1 if not self.connect(quiet = True, sayHello = False): ret = False self.setSocketTimeout() elif out[0] == 500: info('The server is not offering STARTTLS.') else: fail('The server has not reacted for STARTTLS: ({}). Try increasing timeout.'.format(str(out))) return ret @processOutput def sendcmd(self, command, nowrap = False): out = (False, False) dbg('Sending command: "{}"'.format(command)) self.setSocketTimeout(3 * config['timeout']) for j in range(3): try: if config['delay'] > 0.0: time.sleep(config['delay']) out = self.server.docmd(command) dbg('Command resulted with: {}.'.format(out)) if out[0] in (503,) and b'hello first' in out[1].lower(): # 503: 5.5.2 Send hello first dbg('Ok, ok - sending Hello first...') if self.connect(quiet = True, sayHello = True): dbg('Ok, reconnected and said hello. Trying again...') else: dbg('Failed reconnecting and saying hello.') return (False, False) continue break except (smtplib.SMTPServerDisconnected, socket.timeout) as e: if str(e) == 'Connection unexpectedly closed': # smtplib.getreply() returns this error in case of reading empty line. #dbg('Server returned empty line / did not return anything.') #return (False, '') dbg('Connection unexpectedly closed: {}'.format(str(e))) if self.connect(quiet = True, sayHello = False): continue else: dbg('Server has disconnected ({}).'.format(str(e))) if 'connect' in str(e).lower(): dbg('Attempting to reconnect and resend command...') if self.connect(quiet = True, sayHello = False): continue else: break if not out[0]: dbg('Could not reconnect after failure.') self.setSocketTimeout() return out[0], out[1] def parseHelpOutput(self, output): if len(output.split('\n')) >= 2: output = output.replace('\t', '\n') dbg('Parsing potential HELP output: "{}"'.format( output.replace('\n', '\\n') )) helpMultilineCommandsRegexes = ( r'(?:\\n)([a-zA-Z- 0-9]{3,})', r'(?:\n)([a-zA-Z- 0-9]{3,})' ) for rex in helpMultilineCommandsRegexes: out = re.findall(rex, output) if len([x for x in out if x != None]) > 0: return out else: return '' def parseHelpOutputAndUpdateServicesList(self, out): outlines = self.parseHelpOutput(out) if outlines: self.availableServices.update(set(map(lambda x: x.strip(), outlines))) outlines = set() dbg('SMTP available services: {}'.format(pprint.pformat(self.availableServices))) return True return False def getAvailableServices(self): dbg('Acquiring list of available services...') out = False outlines = set() if self.banner: if self.parseHelpOutputAndUpdateServicesList(self.banner): return True out = self.sendcmd('EHLO ' + SmtpTester.pretendLocalHostname) if out[0]: dbg('EHLO returned: {}'.format(out)) if self.parseHelpOutputAndUpdateServicesList(out[1].decode()): return True # We are about to provoke SMTP server sending us the HELP listing in result # of sending one of below collected list of commands. for cmd in ('HELP', '\r\nHELP', 'TEST'): try: out = self.sendcmd(cmd) if out[0] in (214, 220, 250): ret = out[1].decode() if self.parseHelpOutputAndUpdateServicesList(ret): return True outlines = self.parseHelpOutput(ret) if len(outlines) < 2: for line in ret.split('\\n'): m = re.findall(r'([A-Z-]{3,})', line) pos = ret.find(line) if m and (pos > 0 and ret[pos-1] == '\n'): dbg('Following line was found by 2nd method HELP parsing: "{}"'.format( line )) outlines = m break if outlines: break except Exception as e: continue if outlines: self.availableServices.update(set(map(lambda x: x.strip(), outlines))) dbg('SMTP available services: {}'.format(pprint.pformat(self.availableServices))) return True info('Could not collect available services list (HELP)') return False def getAuthMethods(self, service): if not self.availableServices: self.getAvailableServices() if not self.availableServices: fail('UNKNOWN: Could not collect available SMTP services') return None authMethods = set() authMethodsList = list(filter( lambda x: x.lower().startswith(service.lower()) and x.lower() != service.lower(), self.availableServices )) # Conform following HELP format: "250-AUTH=DIGEST-MD5 CRAM-MD5 PLAIN LOGIN" if authMethodsList: dbg('List of candidates for {} methods: {}'.format(service, authMethodsList)) for auth in authMethodsList: auth = auth.strip().replace('=', ' ') auth = auth.replace(service + ' ', '') if auth.count(' ') > 0: s = set(['{}'.format(a) for a in auth.split(' ') \ if a.lower() != service.lower()]) authMethods.update(s) else: authMethods.add(auth) else: dbg('The server does not offer any {} methods.'.format(service)) if authMethods: dbg('List of {} methods to test: {}'.format(service, authMethods)) return authMethods @staticmethod def ifMessageLike(out, codes = None, keywords = None, keywordsAtLeast = 0): codeCheck = False keywordCheck = False if not codes and not keywords: return False keywords2 = [k.lower() for k in keywords] msg = out[1].decode() found = 0 for word in msg.split(' '): if word.lower() in keywords2: found += 1 if codes != None and len(codes) > 0: codeCheck = out[0] in codes else: codeCheck = True if keywords != None and len(keywords) > 0: if keywordsAtLeast == 0: keywordCheck = found == len(keywords) else: keywordCheck = found >= keywordsAtLeast else: keywordCheck = True return codeCheck and keywordCheck @staticmethod def checkIfTestToRun(test): if (test in config['tests_to_skip']): return False if ('all' in config['tests_to_carry'] or test in config['tests_to_carry']): return True else: if config['smtp_debug']: dbg('Test: "{}" being skipped as it was marked as disabled.'.format(test)) return False def runTests(self): dkimTestThread = None if SmtpTester.checkIfTestToRun('dkim'): dkimTestThread = self.dkimTestThread() results = [ ('spf', None), ('dkim', None), ('dmarc', None), ('banner-contents', self.bannerSnitch), ('starttls-offering', self.starttlsOffer), ('secure-ciphers', self.testSecureCiphers), ('tls-key-len', self.testSSLKeyLen), ('auth-methods-offered', self.testSecureAuthMethods), ('auth-over-ssl', self.testSSLAuthEnforcement), ('vrfy', self.vrfyTest), ('expn', self.expnTest), ('rcpt-to', self.rcptToTests), ('open-relay', self.openRelayTest), ('spf-validation', self.spfValidationTest), ] if SmtpTester.checkIfTestToRun('spf'): self.results['spf'] = self.spfTest() once = True for res in results: test, func = res assert test in SmtpTester.testsConducted.keys(), \ "The test: '{}' has not been added to SmtpTester.testsConducted!".format(test) if self.stopEverything: break if not SmtpTester.checkIfTestToRun(test): continue if not func: continue if config['delay'] > 0.0: time.sleep(config['delay']) if once: if not self.connected and not self.connect(): sys.exit(-1) else: self.connected = True once = False dbg('Starting test: "{}"'.format(test)) self.results[test] = func() if SmtpTester.checkIfTestToRun('auth-over-ssl') and \ test == 'auth-over-ssl': dbg('Reconnecting after SSL AUth enforcement tests.') if self.stopEverything: break self.reconnect(quiet = True) testDmarc = False if SmtpTester.checkIfTestToRun('dkim') and \ SmtpTester.checkIfTestToRun('spf') and \ SmtpTester.checkIfTestToRun('dmarc'): testDmarc = True self.results['dmarc'] = None if SmtpTester.checkIfTestToRun('dmarc') and not testDmarc: err('To test DMARC following tests must be run also: SPF, DKIM.') if self.threads or dkimTestThread: if not self.stopEverything: info("Awaiting for threads ({}) to finish. Pressing CTRL-C will interrupt lookup process.".format( ', '.join(self.threads.keys()) ), toOutLine = True) try: while (self.threads and all(self.threads.values())): if self.stopEverything: break time.sleep(2) if config['smtp_debug']: dbg('Threads wait loop has finished iterating.') if testDmarc: self.results['dmarc'] = self.evaluateDmarc( self.dmarcTest(), self.results['spf'], self.results['dkim'] ) except KeyboardInterrupt: err('User has interrupted threads wait loop. Returning results w/o DKIM and DMARC.') else: if testDmarc: self.results['dmarc'] = self.evaluateDmarc( self.dmarcTest(), self.results['spf'], self.results['dkim'] ) # Translate those True and False to 'Secure' and 'Unsecure' self.results.update(SmtpTester.translateResultsDict(self.results)) indent = 2 return json.dumps(self.results, indent = indent) def runAttacks(self): attacksToBeLaunched = { 'vrfy': self.vrfyTest, 'expn': self.expnTest, 'rcpt-to': self.rcptToTests, } results = [] info('Attacks will be launched against domain: @{}'.format(self.getMailDomain()), toOutLine = True) info('If that\'s not correct, specify another one with \'--domain\'') for attack, func in attacksToBeLaunched.items(): if not SmtpTester.checkIfTestToRun(attack): continue info('Launching attack: {} enumeration.'.format(attack), toOutLine = True) out = func(attackMode = True) if out and isinstance(out, list): info('Attack result: {} users found.'.format(len(out)), toOutLine = True) results.extend(out) elif out: info('Attack most likely failed {}, result: {}'.format(attack, str(out)), toOutLine = True) else: fail('Attack {} failed.'.format(attack), toOutLine = True) return list(set(results)) @staticmethod def translateResultsDict(results): for k, v in results.items(): if isinstance(v, dict): results[k] = SmtpTester.translateResultsDict(v) else: if v == True: results[k] = 'secure' elif v == False:results[k] = 'unsecure' else: results[k] = 'unknown' return results # # =========================== # BANNER REVEALING SENSITIVIE INFORMATIONS TEST # def bannerSnitch(self): if not self.banner: info('Cannot process server\'s banner - as it was not possible to obtain one.') parser = BannerParser() return parser.parseBanner(self.banner) # # =========================== # SPF TESTS # def enumerateSpfRecords(self, domain): records = set() numberOfSpfRecords = 0 once = True resv = resolver.Resolver() resv.timeout = config['timeout'] / 2.0 info('Queried domain for SPF: "{}"'.format(domain)) try: for txt in resv.query(domain, 'TXT'): txt = txt.to_text().replace('"', '') if txt.lower().startswith('v=spf') and txt not in records: numberOfSpfRecords += 1 records.add(txt) if numberOfSpfRecords > 1 and once: err('Found more than one SPF record. One should stick to only one SPF record.') once = False except (resolver.NoAnswer, resolver.NXDOMAIN, name.EmptyLabel, resolver.NoNameservers) as e: pass return records def spfTest(self): records = {} txts = [] for domain in self.getDomainsToReviewDNS(): for txt in self.enumerateSpfRecords(domain): if txt not in records.keys(): txts.append(txt) records[txt] = self.processSpf(txt) success = True if len(records): results = {} for txt, rec in records.items(): origTxt, results = rec if isinstance(results, dict) and all(results.values()): pass elif isinstance(results, bool) and results: pass else: fail('UNSECURE: SPF record exists, but not passed tests.') _out('\tRecord: ("{}")'.format(origTxt)) return results ok('SECURE: SPF test passed.') _out('\tRecords: ("{}")'.format('", "'.join(txts))) if config['always_unfolded_results']: return results else: fail('UNSECURE: SPF record is missing.') success = False return success def processSpf(self, txt, recurse = 0): ''' Code processing, parsing and evaluating SPF record's contents. ''' maxRecursion = 3 info('Found SPF record: "{}"'.format(txt)) if recurse > maxRecursion: err('Too many SPF redirects, breaking recursion.') return None pos = txt.lower().find('redirect=') if pos > 0: for tok in txt.lower().split(' '): k, v = tok.split('=') if v.endswith(';'): v = v[:-1] if k == 'redirect': info('SPF record redirects to: "{}". Following...'.format(v)) for txt in self.enumerateSpfRecords(v): return (txt, self.processSpf(txt, recurse + 1)) spf = SpfParser() return (txt, spf.process(txt)) # # =========================== # DKIM TESTS # @staticmethod def _job(jid, domains, data, syncDkimThreadsStop, results, totalTested, dkimQueryDelay): try: if (results and sum([x != None for x in results]) > 0) or \ SmtpTester.stopCondition(totalTested, syncDkimThreadsStop): return results.append(SmtpTester.dkimTestWorker(domains, data, syncDkimThreadsStop, dkimQueryDelay, False, totalTested)) except (ConnectionResetError, FileNotFoundError, BrokenPipeError, EOFError, KeyboardInterrupt): pass def dkimTestThread(self): self.results['dkim'] = None if not config['threads']: return self.dkimTest() poolNum = config['parallel_processes'] t = threading.Thread(target = self._dkimTestThread, args = (poolNum, )) t.daemon = True t.start() return t def stopCondition(totalTested, syncDkimThreadsStop): if syncDkimThreadsStop.value: return True if config['max_enumerations'] > 0 and \ totalTested.value >= config['max_enumerations']: return True return False def _dkimTestThread(self, poolNum): def _chunks(l, n): return [l[i:i+n] for i in range(0, len(l), n)] self.threads['dkim'] = True dbg('Launched DKIM test in a new thread running with {} workers.'.format(poolNum)) selectors = self.generateListOfCommonDKIMSelectors() info('Selectors to review: {}'.format(len(selectors))) jobs = [] mgr = multiprocessing.Manager() totalTested = multiprocessing.Value('i', 0) syncDkimThreadsStop = multiprocessing.Value('i', 0) dkimQueryDelay = multiprocessing.Value('d', 0.0) results = mgr.list() slice = _chunks(selectors, len(selectors) // poolNum) domains = self.getDomainsToReviewDNS() try: for i, s in enumerate(slice): if SmtpTester.stopCondition(totalTested, syncDkimThreadsStop) or self.stopEverything: break proc = multiprocessing.Process( target = SmtpTester._job, args = (i, domains, s, syncDkimThreadsStop, results, totalTested, dkimQueryDelay) ) proc.start() jobs.append(proc) num = len(domains) * len(selectors) totals = [] lastTotal = 0 maxDelay = 4.0 delayStep = 0.5 smallStepToDelay = 50 while totalTested.value < len(selectors) - 50: if SmtpTester.stopCondition(totalTested, syncDkimThreadsStop) or self.stopEverything: break totals.append(totalTested.value) js = '(jobs running: {})'.format(len(jobs)) SmtpTester.dkimProgress(totalTested.value, selectors, num, syncDkimThreadsStop, True, js, dkimQueryDelay.value) if config['delay_dkim_queries']: if totalTested.value - lastTotal < smallStepToDelay and dkimQueryDelay.value < maxDelay: dkimQueryDelay.value += delayStep elif totalTested.value - lastTotal >= smallStepToDelay and dkimQueryDelay.value > 0: dkimQueryDelay.value -= delayStep lastTotal = totalTested.value # Wait 5*2 seconds for another DKIM progress message for i in range(15): if SmtpTester.stopCondition(totalTested, syncDkimThreadsStop) or self.stopEverything: break time.sleep(2) if totals.count(totalTested.value) > 1: syncDkimThreadsStop.value = 1 err('Stopping DKIM thread cause it seems to have stuck.', toOutLine = True) break info('DKIM selectors enumerated. Stopping jobs...') for j in jobs: if SmtpTester.stopCondition(totalTested, syncDkimThreadsStop) or self.stopEverything: break for i in range(30): if SmtpTester.stopCondition(totalTested, syncDkimThreadsStop) or self.stopEverything: break j.join(2 * 60 / 30) except (KeyboardInterrupt, BrokenPipeError): pass try: if results and sum([x != None for x in results]) > 0: dbg('DKIM thread found valid selector.') self.results['dkim'] = [x for x in results if x != None][0] else: fail('UNSECURE: DKIM record is most likely missing, as proved after {} tries.'.format( totalTested.value )) except FileNotFoundError: pass self.threads['dkim'] = False return self.results['dkim'] def dkimTest(self, selectors = None): if not selectors: selectors = self.generateListOfCommonDKIMSelectors() ret = self.dkimTestWorker(self.getDomainsToReviewDNS(), selectors) self.results['dkim'] = ret return ret @staticmethod def dkimProgress(total, selectors, num, syncDkimThreadsStop, unconditional = False, extra = None, dkimQueryDelay = 0): if total < 100 or SmtpTester.stopCondition(total, syncDkimThreadsStop): return progressStr = 'DKIM: Checked {:02.0f}% ({:05}/{:05}) selectors. Query delay: {:0.2f} sec.'.format( 100.0 * (float(total) / float(len(selectors))), total, len(selectors), dkimQueryDelay ) if extra: progressStr += ' ' + extra progressStr += '...' N = 10 if (not config['debug'] and (unconditional or ((total % int(num // N)) == 0))): info(progressStr, toOutLine = True) elif (config['debug'] and (unconditional or (total % 250 == 0))): if config['threads']: dbg(progressStr) else: sys.stderr.write(progressStr + '\r') sys.stderr.flush() @staticmethod def dkimTestWorker(domainsToReview, selectors, syncDkimThreadsStop, dkimQueryDelay = None, reportProgress = True, totalTested = None): ret = False stopIt = False total = 0 maxTimeoutsToAccept = int(0.3 * len(selectors)) timeoutsSoFar = 0 if SmtpTester.stopCondition(totalTested, syncDkimThreadsStop): return None num = len(domainsToReview) * len(selectors) if reportProgress: info('Checking around {} selectors. Please wait - this will take a while.'.format( num )) resv = resolver.Resolver() resv.timeout = 1.2 for domain in domainsToReview: if stopIt or SmtpTester.stopCondition(totalTested, syncDkimThreadsStop): break if reportProgress: info('Enumerating selectors for domain: {}...'.format(domain)) for sel in selectors: if stopIt or SmtpTester.stopCondition(totalTested, syncDkimThreadsStop): break dkimRecord = '{}._domainkey.{}'.format(sel, domain) total += 1 if totalTested: totalTested.value += 1 if reportProgress: SmtpTester.dkimProgress(total, selectors, num) try: if not dkimRecord: continue if dkimQueryDelay and dkimQueryDelay.value > 0: time.sleep(dkimQueryDelay.value) for txt in resv.query(dkimRecord, 'TXT'): if stopIt or SmtpTester.stopCondition(totalTested, syncDkimThreadsStop): break txt = txt.to_text().replace('"', '') if config['max_enumerations'] > -1 and \ total >= config['max_enumerations']: stopIt = True break if txt.lower().startswith('v=dkim'): info('DKIM found at selector: "{}"'.format(sel)) ret = SmtpTester.processDkim(txt) if ret: ok('SECURE: DKIM test passed.') else: fail('UNSECURE: DKIM test not passed') syncDkimThreadsStop.value = 1 return ret except (exception.Timeout) as e: if timeoutsSoFar >= maxTimeoutsToAccept: err('DNS enumeration failed: Maximum number of timeouts from DNS server reached.') break timeoutsSoFar += 1 except (AttributeError, resolver.NoAnswer, resolver.NXDOMAIN, resolver.NoNameservers, name.EmptyLabel, name.NameTooLong) as e: continue except KeyboardInterrupt: dbg('User has interrupted DKIM selectors enumeration test.') return None if reportProgress: if total >= num: fail('UNSECURE: DKIM record is most likely missing. Exhausted list of selectors.') else: fail('UNSECURE: DKIM record is most likely missing. Process interrupted ({}/{}).'.format( total, num )) return None @staticmethod def processDkim(txt): ''' Code processing, parsing and evaluating DKIM record's contents. ''' dkim = DkimParser() return dkim.process(txt) def generateListOfCommonDKIMSelectors(self): ''' Routine responsible for generating list of DKIM selectors based on various permutations of the input words (like common DKIM selectors or other likely selector names). ''' months = ('styczen', 'luty', 'marzec', 'kwiecien', 'maj', 'czerwiec', 'lipiec', 'sierpien', 'wrzesien', 'pazdziernik', 'listopad', 'grudzien', 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'october', 'november', 'september', 'december', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre', 'januar', 'februar', 'marz', 'mai', 'juni', 'juli', 'oktober', 'dezember') domains = self.domain.split('.') words = ('default', 'dkim', 'dk', 'domain', 'domainkey', 'test', 'selector', 'mail', 'smtp', 'dns', 'key', 'sign', 'signing', 'auth', 'sel', 'google', 'shopify.com' ) + tuple(domains) + config['uncommon_words'] selectors = [] # Set 0: All collected domains selectors.extend(self.getAllPossibleDomainNames()) # Set 1: User-defined try: if self.dkimSelectorsList: with open(self.dkimSelectorsList, 'r') as f: for l in f.readlines(): selectors.append(l.strip()) except IOError: err('Could not open DKIM selectors list file.') sys.exit(-1) # Set 2: Common words permutations for w in words: selectors.append('{}'.format(w)) selectors.append('_{}'.format(w)) selectors.append('{}_'.format(w)) for i in range(0, 11): if not config['dkim_full_enumeration']: break selectors.append('{}{}'.format(w, i)) selectors.append('{}{:02d}'.format(w, i)) if config['dkim_full_enumeration']: nowTime = datetime.datetime.now() currYear = nowTime.year yearsRange = range(currYear - 2, currYear + 1) # Set 3: Year-Month text permutations for m in months: for yr in yearsRange: ms = ( m[:3], m, '%d' % yr, '%s%d' % (m, yr), '%s%d' % (m[:3], yr), '%s%d' % (m, (yr - 2000)), '%s%d' % (m[:3], (yr - 2000)), '%d%s' % (yr, m), '%d%s' % (yr, m[:3]), '%d%s' % ((yr - 2000), m), '%d%s' % ((yr - 2000), m[:3]), ) selectors.extend(ms) currTimeFormats = ( '%Y%m%d', '%Y%d%m', '%d%m%Y', '%m%d%Y', '%Y', '%m', '%Y%m', '%m%Y' ) # Set 4: Year-Month-Day date permutations for f in currTimeFormats: selectors.append(nowTime.strftime(f)) for yr in yearsRange: for j in range(1,13): for k in range(1, 32): try: t = datetime.datetime(yr, j, k) selectors.append(t.strftime(f)) selectors.append('%d' % (time.mktime(t.timetuple()))) except: pass dbg('Generated: {} selectors to review.'.format(len(selectors))) return selectors # # =========================== # DMARC TESTS # def evaluateDmarc(self, dmarc, spf, dkim): lack = [] if not spf: lack.append('SPF') if not dkim: lack.append('DKIM') if dmarc and lack: fail('UNSECURE: DMARC cannot work without {} being set.'.format(', '.join(lack))) # Return anyway... #return False return dmarc def dmarcTest(self): ret = False found = False records = [] for domain in self.getDomainsToReviewDNS(): domain = '_dmarc.' + domain try: for txt in resolver.query(domain, 'TXT'): txt = txt.to_text().replace('"', '') if txt.lower().startswith('v=dmarc'): info('Found DMARC record: "{}"'.format(txt)) ret = self.processDmarc(txt) records.append(txt) found = True break except (resolver.NXDOMAIN, resolver.NoAnswer, resolver.NoNameservers): pass if ret: break if ret: ok('SECURE: DMARC test passed.') _out('\tRecords: "{}"'.format('", "'.join(records))) elif found and not ret: fail('UNSECURE: DMARC tets not passed.') else: fail('UNSECURE: DMARC record is missing.') return ret def processDmarc(self, record): parser = DmarcParser() return parser.processDmarc(record) def generateUserNamesList(self, permute = True): users = [] common_ones = ('all', 'admin', 'mail', 'test', 'guest', 'root', 'spam', 'catchall', 'abuse', 'contact', 'administrator', 'email', 'help', 'post', 'postmaster', 'rekrutacja', 'recruitment', 'pomoc', 'ayuda', 'exchange', 'relay', 'hilfe', 'nobody', 'anonymous', 'security', 'press', 'media', 'user', 'foo', 'robot', 'av', 'antivirus', 'gate', 'gateway', 'job', 'praca', 'it', 'auto', 'account', 'hr', 'db', 'web') if not permute: return common_ones words = common_ones + config['uncommon_words'] # Set 1: User-defined try: if self.userNamesList: with open(self.userNamesList, 'r') as f: for l in f.readlines(): users.append(l.strip()) info('Read {} lines from users list.'.format(len(users)), toOutLine = True) return users except IOError: err('Could not open user names list file.', toOutLine = True) sys.exit(-1) # Set 2: Common words permutations for w in words: users.append('{}'.format(w)) for i in range(0, 11): users.append('{}{}'.format(w, i)) users.append('{}{:02d}'.format(w, i)) dbg('Generated list of {} user names to test.'.format(len(users))) return users # # =========================== # EXPN TESTS # def expnTest(self, attackMode = False): i = 0 maxFailures = 64 failures = 0 secureConfigurationCodes = (252, 500, 502) unsecureConfigurationCodes = (250, 251, 550, 551, 553) userNamesList = set(self.generateUserNamesList(permute = attackMode)) foundUserNames = set() info('Attempting EXPN test, be patient - it may take a longer while...') try: for user in userNamesList: if config['max_enumerations'] > -1 and i >= config['max_enumerations']: dbg('Max enumerations exceeded accepted limit.') if not attackMode: return False else: return list(foundUserNames ) if not attackMode and failures >= maxFailures: err('FAILED: EXPN test failed too many times.') return None out = self.sendcmd('EXPN {}'.format(user)) if out[0] in secureConfigurationCodes \ or (out[0] == 550 and 'access denied' in out[1].lower()): ok('SECURE: EXPN could not be used for user enumeration.') _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) if not attackMode: return True else: return list(foundUserNames) elif out[0] in unsecureConfigurationCodes: if not attackMode: fail('UNSECURE: "EXPN {}": allows user enumeration!'.format( user )) _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) return False else: ok('Found new user: {}@{}'.format(rcptTo, self.getMailDomain()), toOutLine = True) _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) foundUserNames.add(rcptTo) elif (out[0] == False and out[1] == False) or not out[1]: info('UNKNOWN: During EXPN test the server disconnected. This might be secure.') if not attackMode: return None else: return list(foundUserNames) else: dbg('Other return code: {}'.format(out[0])) failures += 1 i += 1 except KeyboardInterrupt: info('EXPN Attack interrupted.', toOutLine = True) if not attackMode: ok('SECURE: EXPN test succeeded, yielding secure configuration.') return True else: ok('EXPN Attack finished. Found: {} / {}'.format( len(foundUserNames), len(userNamesList) ), toOutLine = True) return list(foundUserNames) # # =========================== # RCPT TO TESTS # def rcptToTests(self, attackMode = False): i = 0 maxFailures = 256 failures = 0 unsecureConfigurationCodes = (250, ) secureConfigurationCodes = (530, 553, 550) userNamesList = set(self.generateUserNamesList(permute = attackMode)) foundUserNames = set() info('Attempting RCPT TO test, be patient - it takes a longer while...') for mailFrom in userNamesList: if not attackMode and failures >= maxFailures: err('FAILED: RCPT TO test failed too many times.') return None if config['max_enumerations'] > -1 and i >= config['max_enumerations']: dbg('Max enumerations exceeded accepted limit.') if not attackMode: return False else: return list(foundUserNames ) out = self.sendcmd('MAIL FROM: <{}@{}>'.format( mailFrom, self.getMailDomain() )) dbg('MAIL FROM returned: ({})'.format(out)) if out and out[0] in (250,): dbg('Sender ok. Proceeding...') elif out[0] in (530, ): # 530: 5.7.1 Client was not authenticated ok('SECURE: SMTP server requires prior authentication when using RCPT TO.') _out('\tReturned: ("{}")'.format(out[1].decode())) if not attackMode: return True else: return list(foundUserNames) elif (out[0] == 503 and '5.5.1' in out[1] and 'sender' in out[1].lower() and 'specified' in out[1].lower()): # 503, 5.5.1 Sender already specified failures += 1 continue elif out[0] in (503, ): # 503: 5.5.2 Send Hello first self.connect(quiet = True, sayHello = True) failures += 1 continue elif (out[0] == False and out[1] == False) or not out[1]: info('UNKNOWN: During RCPT TO the server has disconnected. This might be secure.') if not attackMode: return None else: return list(foundUserNames) else: dbg('Server returned unexpected response in RCPT TO: {}'.format(out)) failures += 1 continue i = 0 failures = 0 try: for rcptTo in userNamesList: if mailFrom == rcptTo: continue if attackMode: perc = float(i) / float(len(userNamesList)) * 100.0 if i % (len(userNamesList) / 10) == 0 and i > 0: info('RCPT TO test progress: {:02.2f}% - {:04} / {:04}'.format( perc, i, len(userNamesList)), toOutLine = True) if config['max_enumerations'] > -1 and i >= config['max_enumerations']: dbg('Max enumerations exceeded accepted limit.') if not attackMode: return None else: return list(foundUserNames) if not attackMode and failures >= maxFailures: err('FAILED: RCPT TO test failed too many times.') return None out = self.sendcmd('RCPT TO: <{}@{}>'.format( rcptTo, self.getMailDomain() )) dbg('RCTP TO returned: ({})'.format(out)) if out and out[0] in unsecureConfigurationCodes: if not attackMode: fail('UNSECURE: "RCPT TO" potentially allows user enumeration: ({}, {})'.format( out[0], out[1].decode() )) return False elif rcptTo not in foundUserNames: ok('Found new user: {}@{}'.format(rcptTo, self.getMailDomain()), toOutLine = True) _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) foundUserNames.add(rcptTo) elif out and out[0] in secureConfigurationCodes: if SmtpTester.ifMessageLike(out, (550, ), ('user', 'unknown', 'recipient', 'rejected'), 2): if not attackMode: info('Warning: RCPT TO may be possible: {} ({})'.format(out[0], out[1].decode())) # # Can't decided, whether error code shall be treated as RCPT TO disabled message or # as an implication that wrong recipient's address was tried. Therefore, we disable the below # logic making it try every user name in generated list, until something pops up. # #else: # ok('SECURE: Server disallows user enumeration via RCPT TO method.') # _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) # if not attackMode: return False # else: return list(foundUserNames) elif (out[0] == False and out[1] == False) or not out[1]: info('UNKNOWN: During RCPT TO test the server has disconnected. This might be secure.') if not attackMode: return None else: return list(foundUserNames) else: dbg('Other return code: {}'.format(out[0])) failures += 1 i += 1 if attackMode: break except KeyboardInterrupt: info('RCPT TO Attack interrupted.', toOutLine = True) break if not attackMode: ok('SECURE: RCPT TO test succeeded, yielding secure configuration.') return True else: ok('RCPT TO Attack finished. Found: {} / {}'.format( len(foundUserNames), len(userNamesList) ), toOutLine = True) return list(foundUserNames) # # =========================== # VRFY TESTS # def vrfyTest(self, attackMode = False): i = 0 maxFailures = 64 failures = 0 unsecureConfigurationCodes = (250, 251, 550, 551, 553) secureConfigurationCodes = (252, 500, 502, 535) userNamesList = set(self.generateUserNamesList(permute = attackMode)) foundUserNames = set() info('Attempting VRFY test, be patient - it may take a longer while...') try: for user in userNamesList: if config['max_enumerations'] > -1 and i >= config['max_enumerations']: dbg('Max enumerations exceeded accepted limit.') if not attackMode: return False else: return list(foundUserNames) if not attackMode and failures >= maxFailures: dbg('Failures exceeded maximum failures limit.') return None out = self.sendcmd('VRFY {}'.format(user)) if out[0] in secureConfigurationCodes \ or (out[0] == 550 and 'access denied' in out[1].lower()): comm = '' if out[0] == 535: comm = 'unauthenticated ' ok('SECURE: VRFY disallows {}user enumeration.'.format(comm)) _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) if not attackMode: return True else: return list(foundUserNames) elif out[0] in unsecureConfigurationCodes: if not attackMode: fail('UNSECURE: "VRFY {}": allows user enumeration!'.format( user )) _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) return False else: ok('Found new user: {}@{}'.format(rcptTo, self.getMailDomain()), toOutLine = True) _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) foundUserNames.add(rcptTo) elif (out[0] == False and out[1] == False) or not out[1]: info('UNKNOWN: During VRFY test the server has disconnected. This might be secure.') if not attackMode: return None else: return list(foundUserNames) else: dbg('Other return code: {}'.format(out[0])) failures += 1 i += 1 except KeyboardInterrupt: info('Attack interrupted.', toOutLine = True) if not attackMode: ok('SECURE: VRFY test succeeded, yielding secure configuration.') return True else: ok('VRFY Attack finished. Found: {} / {}'.format( len(foundUserNames), len(userNamesList) ), toOutLine = True) return list(foundUserNames) # # =========================== # OPEN-RELAY TESTS # def openRelayTest(self): if self.connect(quiet = True, sayHello = True): results = {} internalDomain = self.getMailDomain() externalDomain = config['smtp_external_domain'] ip = '[{}]'.format(self.resolvedIPAddress) if not self.resolvedIPAddress: ip = '[{}]'.format(self.originalHostname) srvname = self.localHostname domain = self.originalHostname if domain == srvname: domain = self.getMailDomain() dbg('Attempting open relay tests. Using following parameters:\n\tinternalDomain = {}\n\texternalDomain = {}\n\tdomain = {}\n\tsrvname = {}\n\tip = {}'.format( internalDomain, externalDomain, domain, srvname, ip )) domains = { 'internal -> internal' : [internalDomain, internalDomain], 'srvname -> internal' : [srvname, internalDomain], 'internal -> external' : [internalDomain, externalDomain], 'external -> internal' : [externalDomain, internalDomain], 'external -> external' : [externalDomain, externalDomain], 'user@localhost -> external' : ['localhost', externalDomain], #'empty -> empty' : ['', ''], 'empty -> internal' : ['', internalDomain], 'empty -> external' : ['', externalDomain], 'ip -> internal' : [ip, internalDomain], 'ip -> to%domain@[ip]' : [ip, '<USER>%{}@{}'.format(domain, ip)], 'ip -> to%domain@srvname': [ip, '<USER>%{}@{}'.format(domain, srvname)], 'ip -> to%domain@[srvname]': [ip, '<USER>%{}@[{}]'.format(domain, srvname)], 'ip -> "to@domain"' : [ip, '"<USER>@{}"'.format(domain)], 'ip -> "to%domain"' : [ip, '"<USER>%{}"'.format(domain)], 'ip -> to@domain@[ip]' : [ip, '<USER>@{}@{}'.format(domain, ip)], 'ip -> to@domain@' : [ip, '<USER>@{}@'.format(domain)], 'ip -> "to@domain"@[ip]': [ip, '"<USER>@{}"@{}'.format(domain, ip)], 'ip -> to@domain@srvname': [ip, '<USER>@{}@{}'.format(domain,srvname)], 'ip -> @[ip]:to@domain' : [ip, '@{}:<USER>@{}'.format(ip, domain)], 'ip -> @srvname:to@domain': [ip, '@{}:<USER>@{}'.format(srvname, domain)], 'ip -> domain!to' : [ip, '{}!<USER>'.format(domain)], 'ip -> domain!to@[ip]' : [ip, '{}!<USER>@{}'.format(domain, ip)], 'ip -> domain!to@srvname': [ip, '{}!<USER>@{}'.format(domain,srvname)], } dbg('Performing Open-Relay tests...') interrupted = False try: if (self.openRelayParams[0] != '' and self.openRelayParams[1] != '') and \ ('@' in self.openRelayParams[0] and '@' in self.openRelayParams[1]): info('Running custom test: (from: <{}>) => (to: <{}>)'.format( self.openRelayParams[0], self.openRelayParams[1] ), toOutLine = True) results['custom'] = self._openRelayTest('custom', self.openRelayParams) else: avoidMailFrom = False rollBackSenderOnce = False num = 0 for k, v in domains.items(): if self.stopEverything: break num += 1 results[k] = False retry = 0 for i in range(2): if self.stopEverything: break dbg('Attempting Open-Relay test #{}: "{}"'.format(num, k)) results[k] = self._openRelayTest(k, v, avoidMailFrom, num) if results[k] == 554 and not rollBackSenderOnce: dbg('Rolling back to traditional sender\'s address: @{}'.format(internalDomain)) rollBackSenderOnce = True for d, v in domains.items(): if d.startswith('ip -> '): domains[d] = [internalDomain, v[1]] #elif (results[k] == 503 or results[k] == 501) and not avoidMailFrom: # dbg('Will not send MAIL FROM anymore.') # avoidMailFrom = True elif (results[k] == 501 or results[k] == 503): results[k] = False dbg('Reconnecting as SMTP server stuck in repeated/invalid MAIL FROM envelope.') if self.stopEverything: break self.reconnect(quiet = True) results[k] = self._openRelayTest(k, v, avoidMailFrom, num) continue break except KeyboardInterrupt: interrupted = True info('Open-Relay tests interrupted by user!') if not config['always_unfolded_results'] and all(results.values()): ok('SECURE: Open-Relay seems not to be possible as proved after {} tests.'.format(len(results))) return True else: sumOfValues = 0 for k, v in results.items(): dbg('Open-Relay test ({}) resulted with: {}'.format( k, v )) if v == False: sumOfValues += 1 appendix = '' if sumOfValues != len(results): appendix = '\tThe rest of tests failed at some point, without any status.' if interrupted: sumOfValues = 1 if sumOfValues < 1 else sumOfValues appendix = '\tTests were interrupted thus dunno whether the server is open-relaying or not.' _out('[?] UNKNOWN: Open-Relay were interrupted after {}/{} carried tests.'.format( sumOfValues - 1, len(results) ), True, colors.fg.pink) else: fail('UNSECURE: Open-Relay MAY BE possible as turned out after {}/{} successful tests.'.format( sumOfValues, len(results) )) if appendix: _out(appendix, True, colors.fg.pink) return results else: fail('FAILED: Could not reconnect for Open-Relay testing purposes.') return None @staticmethod def _extractMailAddress(param, baseName = ''): ''' @param param - specifies target SMTP domain @param baseName - specifies target mail username ''' surnames = ['John Doe', 'Mike Smith', 'William Dafoe', 'Henry Mitchell'] if not param: return '', '' base = 'test{}'.format(random.randint(0, 9)) if baseName: base = baseName # Format: test@test.com m = re.match(r"(^[a-zA-Z0-9_.+-]+)@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", baseName) if m: base = m.group(1) if '<USER>' in param: param = param.replace('<USER>', base) addr = '{}@{}'.format(base, param) if '@' in param and param.count('@') == 1: addr = param param = param.split('@')[1] elif '@' in param and param.count('@') > 1: return param, param mail = '"{}" <{}>'.format(random.choice(surnames), addr) # Format: test@test.com m = re.match(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", param) if m: addr = m.group(1) mail = '"{}" <{}>'.format(random.choice(surnames), addr) return addr, mail # Format: "John Doe" <test@test.com> m = re.match(r'(^\"([^\"]+)\"[\s,]+<([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)>$)', param) if m: addr = m.group(3) mail = '"{}" <{}>'.format(m.group(2), addr) return addr, mail return addr, mail @staticmethod def extractMailAddress(param, baseName = ''): dbg('Extracting mail address from parameter: "{}", according to base: "{}"'.format( param, baseName )) addr, mail = SmtpTester._extractMailAddress(param, baseName) dbg('After extraction: addr="{}", mail="{}"'.format( addr, mail )) return addr, mail def _openRelayTest(self, testName, twoDomains, avoidMailFrom = False, num = 0, doNotSendAndTest = False): secureConfigurationCodes = (221, 454, 500, 501, 503, 504, 530, 550, 554, ) now = datetime.datetime.now() # If True - secure configuration, could not send via open-relay result = None fromAddr, fromMail = SmtpTester.extractMailAddress(twoDomains[0], self.openRelayParams[0]) toAddr, toMail = SmtpTester.extractMailAddress(twoDomains[1], self.openRelayParams[1]) if testName == 'custom': info('Performing custom Open-Relay test from: {}, to: {}'.format( fromMail, toMail )) dateNow = now.strftime("%a, %d %b %Y %H:%m:%S") subject = 'Open-Relay test #{}: {}'.format(num, testName) mailFromReturn = '' rcptToReturn = '' dataReturn = '' mailCommands = ( 'MAIL From: ' + fromAddr, 'RCPT To: ' + toAddr, 'DATA', '<HERE-COMES-MESSAGE>' ) message = '''From: {fromMail} To: {toMail} Subject: {subject} Date: {dateNow} Warning! This is a test mail coming from 'smtpAudit.py' tool. If you see this message it means that your SMTP server is *vulnerable* to Open-Relay spam technique (https://en.wikipedia.org/wiki/Open_mail_relay). Unauthorized users will be able to make your server send messages in a name of other mail users. You may want to contact with your mail administrator and pass him with the following informations: --------------------8<-------------------- Open-Relay test name: "{testName}" MAIL From: {fromAddr} Server response: {mailFromReturn} RCPT To: {toAddr} Server response: {rcptToReturn} DATA Server response: {dataReturn} Subject: "{subject}" Date: {dateNow} --------------------8<-------------------- smtpAudit.py ({VERSION}) - SMTP Server penetration testing / audit tool, (https://gist.github.com/mgeeky/ef49e5fb6c3479dd6a24eb90b53f9baa) by Mariusz B. / mgeeky (<mb@binary-offensive.com>) . ''' n = 0 out = None for line in mailCommands: if self.stopEverything: break if avoidMailFrom and line.startswith('MAIL From:'): dbg('Skipping MAIL From: line.') continue n += 1 if line.startswith('DATA') and doNotSendAndTest: break if line == '<HERE-COMES-MESSAGE>': line = message.format( fromMail = fromMail, toMail = toMail, subject = subject, dateNow = dateNow, fromAddr = fromAddr, toAddr = toAddr, testName = testName, VERSION = VERSION, mailFromReturn = mailFromReturn, rcptToReturn = rcptToReturn, dataReturn = dataReturn ) out = self.sendcmd(line) msg = out[1].decode().lower() if line.startswith('MAIL From'): mailFromReturn = '{} ({})'.format(out[0], out[1].decode()) if line.startswith('RCPT To'): rcptToReturn = '{} ({})'.format(out[0], out[1].decode()) if line.startswith('DATA'): dataReturn = '{} ({})'.format(out[0], out[1].decode()) if 'rcpt to' in line.lower(): _out('[>] Open-Relay test (from: <{}>) => (to: <{}>); returned: {} ({})'.format( fromAddr, toAddr, out[0], out[1].decode() ), False, colors.fg.pink) elif out[0] == 221 and 'can' in msg and 'break' in msg and 'rules' in msg: # 221 (2.7.0 Error: I can break rules, too. Goodbye.) result = True if out[0] == 501 and 'mail from' in msg and 'already' in msg: # 501 (5.5.1 MAIL FROM already established) return 501 elif out[0] == 503 and 'nested' in msg and 'mail' in msg: # 503 (5.5.1 Error: nested MAIL command) return 503 elif out[0] == 503 and 'already' in msg and 'specified' in msg: # 503 (5.5.1 Sender already specified) #return 503 continue elif out[0] == 554 and 'bad' in msg and 'sender' in msg and 'addr' in msg: # 554 (5.7.1 Bad senders system address) dbg('Bad sender\'s address. Rolling back.') return 554 elif (out[0] == 550 or out[0] == 530) and self.processResponseForAcceptedDomainsFailure(out): # 530 (5.7.1 Client was not authenticated). # 550 (5.7.1 Client does not have permissions to send as this sender). info('Microsoft Exchange Accepted Domains mechanism properly rejects us from relaying. Splendid.') result = True elif out[0] == 550 and self.processResponseForSpfFailure(out): # 550 (5.7.1 Recipient address rejected: Message rejected due to: SPF fail - not authorized). info('SPF properly rejects us from relaying. Splendid.') result = True elif not out or not out[0] or not out[1] or out[0] in secureConfigurationCodes: if line.startswith('From: '): info('Open-Relay {} MAY be possible: the server hanged up on us after invalid "From:" (step: {})'.format( testName, n ), toOutLine = True) info('\tThis means, that upon receiving existing From/To addresses - server could allow for Open-Relay.', toOutLine = True) info('\tTo further analyse this issue - increase verbosity and choose another "--from" or "--to" parameters.', toOutLine = True) result = None else: dbg('Open-Relay {} test failed at step {}: {}.'.format( testName, n, line.strip() )) result = True break dbg('Open-Relay {} test DID NOT failed at step {}: {}. Response: {}'.format( testName, n, line.strip(), str(out) )) verdict = 'most likely' if out[0] == 250: verdict = 'TOTALLY' if doNotSendAndTest: return True if result != True and out[0] < 500: fail('UNSECURE: Open-Relay {} is {} possible.'.format( testName, verdict )) _out('\tReturned: {} ("{}")'.format(out[0], out[1].decode())) result = False elif (result == False and not out[0]) or result == None: fail('UNKNOWN: Server has disconnected after the Open-Relay ({}) test. Most likely secure.'.format(testName)) result = None else: if 'relaying denied' in out[1].decode().lower(): # (550, b'5.7.1 Relaying denied') ok('SECURE: Open-Relay attempt "{}" was denied.'.format(testName)) else: info('Open-Relay "{}" seems not to be possible.'.format( testName )) try: _out('\tReturned: {} ({})'.format(out[0], out[1].decode())) except: _out('\tReturned: ({})'.format(str(out))) result = True return result # # =========================== # SSL AUTH ENFORCEMENT TESTS # def starttlsOffer(self): if not self.availableServices: self.getAvailableServices() if not self.availableServices: fail('UNKNOWN: Could not collect available SMTP services') return None ret = ('starttls' in map(lambda x: x.lower(), self.availableServices)) if ret or self.ssl: ok('SECURE: STARTTLS is offered by SMTP server.') else: dbg('Trying to send STARTTLS by hand') out = self.sendcmd('STARTTLS', nowrap = True) if out[0] == 220: ok('SECURE: STARTTLS is supported, but not offered at first sight.') ret = True self.connect(quiet = True) else: fail('UNSECURE: STARTTLS is NOT offered by SMTP server.') return ret # # =========================== # SSL AUTH ENFORCEMENT TESTS # def testSSLAuthEnforcement(self): for service in SmtpTester.smtpAuthServices: ret = self.testSSLAuthEnforcementForService(service) if ret == False: return ret return True def testSSLAuthEnforcementForService(self, service): authMethods = self.getAuthMethods(service) ret = True emptyMethods = False notSupportedCodes = (500, 502, 503, 504, 535) unsecureConfigurationCodes = () for authMethod in authMethods: if authMethod.upper() == 'NTLM': _out('[?] This may be a Microsoft Exchange receive connector offering Integrated Windows Authentication service.', True, colors.fg.pink) if authMethod.upper() == 'GSSAPI': _out('[?] This may be a Microsoft Exchange receive connector offering Exchange Server authentication service over Generic Security Services application programming interface (GSSAPI) and Mutual GSSAPI authentication.', True, colors.fg.pink) if not authMethods: emptyMethods = True authMethods = SmtpTester.commonSmtpAuthMethods.keys() for authMethod in authMethods: dbg("Checking authentication method: {}".format(authMethod)) if authMethod.upper() in SmtpTester.authMethodsNotNeedingStarttls: dbg('Method {} does not need to be issued after STARTTLS.'.format( authMethod.upper() )) #continue auths = [] _auth = '{} {}'.format(service, authMethod) if authMethod in SmtpTester.commonSmtpAuthMethods.keys(): param = SmtpTester.commonSmtpAuthMethods[authMethod] if isinstance(param, bytes): param = param.decode() if isinstance(param, str): _auth += ' ' + param auths.append(_auth) elif isinstance(param, list) or isinstance(param, tuple): for n in param: if isinstance(param, bytes): n = n.decode() if isinstance(n, str): auths.append(_auth) n = base64.b64encode(n.replace('DOMAIN.COM', self.originalHostname).encode()) auths.append(n) elif isinstance(n, list) or isinstance(n, tuple): auths.append(_auth) for m in n: if isinstance(m, bytes): m = m.decode() if 'DOMAIN.COM' in m: m = base64.b64encode(m.replace('DOMAIN.COM', self.originalHostname).encode()) auths.append(m) index = 0 for index in range(len(auths)): auth = auths[index] out = self.sendcmd(auth, nowrap = True) dbg('The server responded for {} command with: ({})'.format(auth, str(out))) if not out or out[0] == False: dbg('Something gone wrong along the way.') elif out and out[0] in notSupportedCodes: dbg('The {} {} method is either not supported or not available.'.format( service, authMethod )) index += 1 elif not out[0] and not out[1]: info('The server disconnected during {} {}, this might be secure.'.format( service, authMethod )) elif out[0] == 454: # 4.7.0 TLS not available due to local problem fail('UNSECURE: STARTTLS seems to be not available on the server side.') _out('\tReturned: {} ("{}")'.format(out[0], out[1].decode())) return False elif out[0] == 334: # 334 base64 encoded User then Password prompt if out[1].decode() == 'VXNlcm5hbWU6': dbg('During LOGIN process the server enticed to carry on') elif out[1].decode() == 'UGFzc3dvcmQ6': if not self.ssl: fail('UNSECURE: Server allowed authentication over non-SSL channel via "{} {}"!'.format( service, authMethod )) _out('\tReturned: {} ("{}")'.format(out[0], out[1].decode())) return False else: dbg('The {} {} method is not understood.: ({})'.format( service, authMethod, str(out) )) elif out and not (out[0] in (530, ) and b'starttls' in out[1].lower()): fail('UNSECURE: For method "{} {}" the server did not required STARTTLS!'.format( service, authMethod )) _out('\tReturned: {} ("{}")'.format(out[0], out[1].decode())) return False elif out and (out[0] == 530 and b'STARTTLS' in out[1]): ok('SECURE: Server enforces SSL/TLS channel negotation before {}.'.format( service )) _out('\tReturned: {} ("{}")'.format(out[0], out[1].decode())) return True if set(authMethods) <= set(SmtpTester.authMethodsNotNeedingStarttls): ok('SECURE: There were no {} methods requiring STARTTLS.'.format(service)) return True if emptyMethods: info('The server does not offer any {} methods to enforce.'.format( service )) else: info('UNKNOWN: None of tested {} methods yielded any result (among: {}).'.format( service, ', '.join(authMethods) )) return None # # =========================== # SSL/TLS UNSECURE CIPHERS TESTS # def testSecureCiphers(self): performedStarttls = False if not self.starttlsSucceeded: dbg('STARTTLS session has not been set yet. Setting up...') performedStarttls = self.performStarttls() if not self.ssl and not performedStarttls and not self.starttlsSucceeded: err('Could not initiate successful STARTTLS session. Failure') return None try: cipherUsed = self.server_tls_params['cipher'] version = self.server_tls_params['version'] except (KeyError, AttributeError): err('Could not initiate successful STARTTLS session. Failure') return None dbg('Offered cipher: {} and version: {}'.format(cipherUsed, version)) if cipherUsed[0].upper() in SmtpTester.secureCipherSuitesList: ok('SECURE: Offered cipher is considered secure.') _out('\tCipher: {}'.format(cipherUsed[0])) return True for secureCipher in SmtpTester.secureCipherSuitesList: ciphers = set(secureCipher.split('-')) cipherUsedSet = set(cipherUsed[0].upper().split('-')) intersection = ciphers.intersection(cipherUsedSet) minWords = min(len(ciphers), len(cipherUsedSet)) if minWords >= 3 and len(intersection) >= (minWords - 1): ok('SECURE: Offered cipher is having secure structure.') _out('\tCipher: {}'.format(cipherUsed)) return True unsecureCiphers = ('RC4', '3DES', 'DES', ) usedUnsecureCipher = '' for cipher in unsecureCiphers: if cipher in cipherUsed[0].upper(): fail('SMTP Server offered unsecure cipher.') _out('\tCipher: {}'.format(cipher)) return False usedSSL = 'ssl' in version.lower() unsecureSSLs = ('sslv2', 'sslv3') if 'shared_ciphers' in self.server_tls_params.keys(): unsecureProtocolsOffered = set() for s in self.server_tls_params['shared_ciphers']: dbg('Offered cipher (22222): {}'.format(s[1])) if s[1].lower() in unsecureSSLs: unsecureProtocolsOffered.add(s[1]) if len(unsecureProtocolsOffered) > 0: out = ', '.join(unsecureProtocolsOffered) fail('SMTP Server offered unsecure SSL/TLS protocols: {}'.format(out)) return False else: fail('No server TLS parameters obtained yet.') if not usedSSL and not usedUnsecureCipher: ok('SECURE: SMTP Server did not offered unsecure encryption suite.') return True else: fail('UNSECURE: SMTP Server offered unsecure encryption suite.') _out('\tCipher: {}'.format(usedUnsecureCipher)) return False # # =========================== # UNSECURE AUTH METHODS TESTS # def testSecureAuthMethods(self): success = None for service in SmtpTester.smtpAuthServices: ret = self.testSecureAuthMethodsForService(service) if ret == False: return ret elif ret == True: # ret may be also 'None' success = True return success def testSecureAuthMethodsForService(self, service): authMethods = self.getAuthMethods(service) unsecureAuthMethods = ('PLAIN', 'LOGIN') ret = True methods = set() if not authMethods: authMethods = SmtpTester.commonSmtpAuthMethods foundMethods = [] dbg('The server is not offering any {} method. Going to try to discover ones.'.format( service )) for authMethod in authMethods: if authMethod in SmtpTester.authMethodsNotNeedingStarttls: dbg('Method: {} {} is considered not needing STARTTLS.'.format( service, authMethod )) continue auth = '{} {}'.format(service, authMethod) out = self.sendcmd(auth) if out[0] == (500, 503) or \ (out[1] and (b'not available' in out[1].lower() or \ b'not recognized' in out[1].lower())): info('UNKNOWN: {} method not available at all.'.format(service)) return None elif out and out[0] in (334, ): dbg('Authentication via {} is supported'.format(auth)) foundMethods.append(authMethod) if authMethod.upper() in unsecureAuthMethods: if not self.ssl: fail('UNSECURE: SMTP offers plain-text authentication method: {}!'.format( auth )) else: ok('SECURE: SMTP offered plain-text authentication method over SSL: {}!'.format( auth )) _out('\tOffered reply: {} ("{}")'.format(out[0], out[1].decode())) ret = False break if out[0] == False and out[1] == False: info('UNKNOWN: The server has disconnected while checking'\ ' {}. This might be secure'.format( auth )) return None methods = foundMethods else: for authMethod in authMethods: if authMethod.upper() in unsecureAuthMethods: if not self.ssl: fail('UNSECURE: SMTP server offers plain-text authentication method: {}.'.format( authMethod )) else: ok('SECURE: SMTP server offered plain-text authentication method over SSL: {}.'.format( authMethod )) ret = False break methods = authMethods if ret and methods: ok('SECURE: Among found {} methods ({}) none was plain-text.'.format( service, ', '.join(methods) )) elif not ret: pass elif not methods: info('UNKNOWN: The server does not offer any {} methods.'.format( service )) return None dbg('ret = {}, methods = {}'.format(ret, methods)) return ret # # =========================== # SSL/TLS PRIVATE KEY LENGTH # def testSSLKeyLen(self): performedStarttls = False if not self.server_tls_params or not self.starttlsSucceeded: dbg('STARTTLS session has not been set yet. Setting up...') performedStarttls = self.performStarttls() if not performedStarttls and not self.starttlsSucceeded: err('Could not initiate successful STARTTLS session. Failure') return None try: cipherUsed = self.server_tls_params['cipher'] version = self.server_tls_params['version'] sharedCiphers = self.server_tls_params['shared_ciphers'] except (KeyError, AttributeError): err('Could not initiate successful STARTTLS session. Failure') return None dbg('Offered cipher: {} and version: {}'.format(cipherUsed, version)) keyLen = cipherUsed[2] * 8 if keyLen < config['key_len']: fail('UNSECURE: SSL/TLS negotiated cipher\'s ({}) key length is insufficient: {} bits'.format( cipherUsed[0], keyLen )) elif sharedCiphers != None and len(sharedCiphers) > 0: for ciph in sharedCiphers: name, ver, length = ciph if length * 8 < 1024: fail('UNSECURE: SMTP server offers SSL/TLS cipher suite ({}) which key length is insufficient: {} bits'.format( name, keyLen )) return False ok('SECURE: SSL/TLS negotiated key length is sufficient ({} bits).'.format( keyLen )) else: fail('UNKNOWN: Something went wrong during SSL/TLS shared ciphers negotiation.') return None return keyLen >= config['key_len'] # # =========================== # SPF VALIDATION CHECK # def spfValidationTest(self): if not self.spfValidated: dbg('Sending half-mail to domain: "{}" to trigger SPF/Accepted Domains'.format(self.mailDomain)) self._openRelayTest('spf-validation', ['test@' + self.getMailDomain(), 'admin@' + self.getMailDomain()], False, 0, True) if self.spfValidated: ok('SECURE: SMTP Server validates sender\'s SPF record') info('\tor is using MS Exchange\'s Accepted Domains mechanism.') _out('\tReturned: {}'.format(self.spfValidated)) return True else: fail("UNKNOWN: SMTP Server has not been seen validating sender's SPF record.") info("\tIf it is Microsoft Exchange - it could have reject us via Accepted Domains mechanism using code 550 5.7.1") return None def processResponseForAcceptedDomainsFailure(self, out): try: msg = out[1].lower() #if out[0] == 530 and '5.7.1' in msg and 'was not authenticated' in msg: # info('Looks like we might be dealing with Microsoft Exchange') # return True if out[0] == 550 and '5.7.1' in msg and 'does not have permissions to send as this sender' in msg: info('Looks like we might be dealing with Microsoft Exchange') return True except: pass return False def processResponseForSpfFailure(self, out): spfErrorCodes = (250, 451, 550, 554, ) spfErrorKnownSentences = ( 'Client host rejected: Access denied', ) spfErrorKeywords = ('validat', 'host rejected', 'fail', 'reject', 'check', 'soft', 'not auth', 'openspf.net/Why') if out[0] in spfErrorCodes: msg = out[1].decode().strip() # Maybe this error is already known? for knownSentence in spfErrorKnownSentences: if knownSentence in msg: dbg('SPF validation found when received well-known SPF failure error: {} ({})'.format( out[0], msg )) return True found = 0 for word in msg.split(' '): for k in spfErrorKeywords: if k.lower() in word: found += 1 break if 'spf' in msg.lower() and found >= 2: return True if found > 0: dbg('SPF validation possibly found but unsure ({} keywords related): {} ({})'.format( found, out[0], msg )) return False def checkIfSpfEnforced(self, out): if self.spfValidated: return True if self.processResponseForSpfFailure(out): info('SPF validation found: {} ({})'.format(out[0], out[1].decode())) self.spfValidated = '{} ({})'.format(out[0], out[1].decode()) return True if self.processResponseForAcceptedDomainsFailure(out): info('SPF validation not found but found enabled Microsoft Exchange Accepted Domains mechanism: {} ({})'.format(out[0], out[1].decode())) self.spfValidated = '{} ({})'.format(out[0], out[1].decode()) return False return False class ParseOptions: def __init__(self, argv): self.argv = argv self.domain = '' self.port = None self.userslist = '' self.selectors = '' self.forceSSL = False self.fromAddr = '' self.toAddr = '' self.parser = argparse.ArgumentParser(prog = argv[0], usage='%(prog)s [options] <hostname[:port]|ip[:port]>') self.parser.add_argument('hostname', metavar='<domain|ip>', type=str, help='Domain address (server name, or IPv4) specifying SMTP server to scan (host:port).') self.parser.add_argument('-d', '--domain', metavar='DOMAIN', dest='maildomain', default='', help = 'This option can be used to specify proper and valid mail (MX) domain (what comes after @, like: example.com). It helps avoid script confusion when it automatically tries to find that mail domain and it fails (like in case IP was passed in first argument).') self.parser.add_argument('-v', '--verbose', dest='verbose', action = 'count', default = 0, help='Increase verbosity level (use -vv or more for greater effect)') self.parser.add_argument('-T', '--list-tests', dest='testsHelp', action='store_true', help='List available tests.') self.parser.add_argument('-u', '--unfolded', dest='unfolded', default=False, action='store_true', help = 'Always display unfolded JSON results even if they were "secure".') self.parser.add_argument('-C', '--no-colors', dest = 'colors', default = True, action = 'store_false', help = 'Print without colors.') self.parser.add_argument('-f', '--format', metavar='FORMAT', dest='format', default = 'text', choices = ['text', 'json'], help = 'Specifies output format. Possible values: text, json. Default: text.') self.parser.add_argument('-m', '--tests', metavar='TEST', dest='testToCarry', type=str, default = 'all', help = 'Select specific tests to conduct. For a list of tests'\ ', launch the program with option: "{} -T tests". Add more tests after colon. (Default: run all tests).'.format( argv[0] )) self.parser.add_argument('-M', '--skip-test', metavar='TEST', dest='testToSkip', type=str, default = '', help = 'Select specific tests to skip. For a list of tests'\ ', launch the program with option: "{} -T tests". Add more tests after colon. (Default: run all tests).'.format( argv[0] )) self.parser.add_argument('-t', '--timeout', metavar="TIMEOUT", type=float, dest='timeout', default = config['timeout'], help='Socket timeout. (Default: {})'.format( config['timeout'] )) self.parser.add_argument('--delay', metavar="DELAY", dest='delay', type=float, default = config['delay'], help='Delay introduced between subsequent requests and connections. '\ '(Default: {} secs)'.format( config['delay'] )) # Attack options attack = self.parser.add_argument_group('Attacks') attack.add_argument('--attack', dest='attack', action='store_true', help = 'Switch to attack mode in which only enumeration techniques will be pulled off (vrfy, expn, rcpt to). You can use --tests option to specify which of them to launch.') attack.add_argument('-U', '--users', metavar="USERS", type=str, dest='userslist', default = '', help='Users list file used during enumeration tests.') # DKIM options dkim = self.parser.add_argument_group('DKIM Tests') dkim.add_argument('-w', '--wordlist', dest='words', default='', type=str, help = 'Uncommon words to be used in DKIM selectors dictionary generation. Comma separated.') dkim.add_argument('-D', '--selectors', metavar="SELECTORS", type=str, dest='selectors', default = '', help='DKIM selectors list file with custom selectors list to review.') dkim.add_argument('-y', '--tries', metavar="TRIES", type=int, dest='tries', default = -1, help='Maximum number of DNS tries/enumerations in DKIM test. (Default: all of them)') dkim.add_argument('--dkim-enumeration', metavar="TYPE", type=str, choices = ['never', 'on-ip', 'full'], dest = 'dnsenum', default = config['dns_full'], help='When to do full-blown DNS records enumeration. Possible values: '\ 'always, on-ip, never. When on-ip means when DOMAIN was IP address. '\ '(Default: "{}")'.format( config['dns_full'] )) # Open-Relay options openRelay = self.parser.add_argument_group('Open-Relay Tests') openRelay.add_argument('-x', '--external-domain', dest='external_domain', metavar='DOMAIN', default = config['smtp_external_domain'], type=str, help = 'External domain to use in Open-Relay tests. (Default: "{}")'.format( config['smtp_external_domain'] )) openRelay.add_argument('--from', dest='fromAddr', default='', type=str, help = 'Specifies "From:" address to be used in Open-Relay test. Possible formats: (\'test\', \'test@test.com\', \'"John Doe" <test@test.com>\'). If you specify here and in \'--to\' full email address, you are going to launch your own custom test. Otherwise, those values will be passed into username part <USER>@domain.') openRelay.add_argument('--to', dest='toAddr', default='', type=str, help = 'Specifies "To:" address to be used in Open-Relay test. Possible formats: (\'test\', \'test@test.com\', \'"John Doe" <test@test.com>\'). If you specify here and in \'--from\' full email address, you are going to launch your own custom test. Otherwise, those values will be passed into username part <USER>@domain.') if len(sys.argv) < 2: self.usage() sys.exit(-1) if config['verbose']: ParseOptions.banner() if not self.parse(): sys.exit(-1) @staticmethod def banner(): sys.stderr.write(''' :: SMTP Black-Box Audit tool. v{}, Mariusz B. / mgeeky, '17 '''.format(VERSION)) def usage(self): ParseOptions.banner() self.parser.print_help() def parse(self): global config testsHelp = '' for k, v in SmtpTester.testsConducted.items(): testsHelp += '\n\t{:20s} - {}'.format(k, v) if len(sys.argv) >= 2: if (sys.argv[1].lower() == '--list-tests') or \ (sys.argv[1] == '-T' and len(sys.argv) >= 3 and sys.argv[2] == 'tests') or \ (sys.argv[1] == '-T') or \ (sys.argv[1] == '--list-tests' and len(sys.argv) >= 3 and sys.argv[2] == 'tests'): print('Available tests:{}'.format(testsHelp)) sys.exit(0) args = self.parser.parse_args() if args.testsHelp: print('Available tests:{}'.format(testsHelp)) sys.exit(0) self.domain = args.hostname self.userslist = args.userslist self.selectors = args.selectors self.maildomain = args.maildomain self.attack = args.attack if args.fromAddr: self.fromAddr = args.fromAddr if args.toAddr: self.toAddr = args.toAddr if ':' in args.hostname: self.domain, self.port = args.hostname.split(':') self.port = int(self.port) if args.verbose >= 1: config['verbose'] = True if args.verbose >= 2: config['debug'] = True if args.verbose >= 3: config['smtp_debug'] = True config['timeout'] = args.timeout config['delay'] = args.delay config['max_enumerations'] = args.tries config['dns_full'] = args.dnsenum config['always_unfolded_results'] = args.unfolded config['format'] = args.format config['colors'] = args.colors config['attack'] = args.attack if args.words: config['uncommon_words'] = args.words.split(',') if args.testToCarry: config['tests_to_carry'] = args.testToCarry.split(',') for c in config['tests_to_carry']: if c == 'all': continue if c not in SmtpTester.testsConducted.keys(): err('There is no such test as the one specified: "{}"'.format( c )) print('\nAvailable tests:{}'.format(testsHelp)) sys.exit(-1) l = list(filter(lambda x: x != 'all', config['tests_to_carry'])) if l: info('Running following tests: ' + ', '.join(l)) if args.testToSkip: config['tests_to_skip'] = args.testToSkip.split(',') for c in config['tests_to_skip']: if c == '': break if c not in SmtpTester.testsConducted.keys(): err('There is no such test as the one specified: "{}"'.format( c )) print('\nAvailable tests:{}'.format(testsHelp)) sys.exit(-1) l = list(filter(lambda x: x != '', config['tests_to_skip'])) if l: info('Skipping following tests: ' + ', '.join(l)) return True def printResults(results, auditMode): if auditMode: if config['format'] == 'json': out = json.dumps(results, indent = 4) out = out[1:-1] out = out.replace('\\n', '\n') out = out.replace('\\', '') print(out) elif config['format'] == 'text': pass else: info('Results:') if config['format'] == 'json': out = json.dumps(results, indent = 4) out = out[1:-1] out = out.replace('\\n', '\n') out = out.replace('\\', '') print(out) else: for found in results: print(found) if not config['verbose'] and not config['debug']: sys.stderr.write('\n---\nFor more detailed output, consider enabling verbose mode.\n') def main(argv): opts = ParseOptions(argv) domain = opts.domain port = opts.port userslist = opts.userslist selectors = opts.selectors if config['format'] == 'text': sys.stderr.write(''' :: SMTP configuration Audit / Penetration-testing tool Intended to be used as a black-box tool revealing security state of SMTP. Mariusz B. / mgeeky, '17-19 v{} '''.format(VERSION)) prev = datetime.datetime.now() info('SMTP Audit started at: [{}], on host: "{}"'.format( prev.strftime('%Y.%m.%d, %H:%M:%S'), socket.gethostname() )) info('Running against target: {}{}{}'.format( opts.domain, ':'+str(opts.port) if opts.port != None else '', ' (...@' + opts.maildomain + ')' if opts.maildomain != '' else '', toOutLine = True)) results = {} tester = SmtpTester( domain, port, dkimSelectorsList = selectors, userNamesList = userslist, openRelayParams = (opts.fromAddr, opts.toAddr), mailDomain = opts.maildomain ) try: if opts.attack: results = tester.runAttacks() else: results = tester.runTests() except KeyboardInterrupt: err('USER HAS INTERRUPTED THE PROGRAM.') if tester: tester.stop() after = datetime.datetime.now() info('Audit finished at: [{}], took: [{}]'.format( after.strftime('%Y.%m.%d, %H:%M:%S'), str(after - prev) ), toOutLine = True) if config['verbose'] and config['format'] != 'text': sys.stderr.write('\n' + '-' * 50 + '\n\n') printResults(results, not opts.attack) if __name__ == '__main__': main(sys.argv)