3880 lines
143 KiB
Python
3880 lines
143 KiB
Python
#!/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)
|