#!/usr/bin/python3 # # This tool connects to the given Exchange's hostname/IP address and then # by collects various internal information being leaked while interacting # with different Exchange protocols. Exchange may give away following helpful # during OSINT or breach planning stages insights: # - Internal IP address # - Internal Domain Name (ActiveDirectory) # - Exchange Server Version # - support for various SMTP User Enumeration techniques # - Version of underlying software such as ASP.NET, IIS which # may point at OS version indirectly # # This tool will be helpful before mounting social engieering attack against # victim's premises or to aid Password-Spraying efforts against exposed OWA # interface. # # OPSEC: # All of the traffic that this script generates is not invasive and should # not be picked up by SOC/Blue Teams as it closely resembles random usual traffic # directed at both OWA, or Exchange SMTP protocols/interfaces. The only potentially # shady behaviour could be observed on one-shot attempts to perform SMTP user # enumeration, however it is unlikely that single commands would trigger SIEM use cases. # # TODO: # - introduce some fuzzy logic for Exchange version recognition # - Extend SMTP User Enumeration validation capabilities # # Requirements: # - pyOpenSSL # - packaging # # Author: # Mariusz B. / mgeeky, '19, # import re import sys import ssl import time import base64 import struct import string import socket import smtplib import urllib3 import requests import argparse import threading import collections import packaging.version from urllib.parse import urlparse import OpenSSL.crypto as crypto VERSION = '0.2' config = { 'debug' : False, 'verbose' : False, 'timeout' : 6.0, } found_dns_domain = '' urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) class Logger: @staticmethod def _out(x): if config['verbose'] or config['debug']: sys.stdout.write(x + '\n') @staticmethod def out(x): Logger._out('[.] ' + x) @staticmethod def info(x): Logger._out('[.] ' + x) @staticmethod def dbg(x): if config['debug']: Logger._out('[DEBUG] ' + x) @staticmethod def err(x): sys.stdout.write('[!] ' + x + '\n') @staticmethod def fail(x): Logger._out('[-] ' + x) @staticmethod def ok(x): Logger._out('[+] ' + x) def hexdump(data): s = '' n = 0 lines = [] tableline = '-----+' + '-' * 24 + '|' \ + '-' * 25 + '+' + '-' * 18 + '+\n' if isinstance(data, str): data = data.encode() if len(data) == 0: return '' for i in range(0, len(data), 16): line = '' line += '%04x | ' % (i) n += 16 for j in range(n-16, n): if j >= len(data): break line += '%02x' % (data[j] & 0xff) if j % 8 == 7 and j % 16 != 15: line += '-' else: line += ' ' line += ' ' * (3 * 16 + 7 - len(line)) + ' | ' for j in range(n-16, n): if j >= len(data): break c = data[j] if not (data[j] < 0x20 or data[j] > 0x7e) else '.' line += '%c' % c line = line.ljust(74, ' ') + ' |' lines.append(line) return tableline + '\n'.join(lines) + '\n' + tableline class NtlmParser: # # Based on: # https://gist.github.com/aseering/829a2270b72345a1dc42 # VALID_CHRS = set(string.ascii_letters + string.digits + string.punctuation) flags_tbl_str = ( (0x00000001, "Negotiate Unicode"), (0x00000002, "Negotiate OEM"), (0x00000004, "Request Target"), (0x00000008, "unknown"), (0x00000010, "Negotiate Sign"), (0x00000020, "Negotiate Seal"), (0x00000040, "Negotiate Datagram Style"), (0x00000080, "Negotiate Lan Manager Key"), (0x00000100, "Negotiate Netware"), (0x00000200, "Negotiate NTLM"), (0x00000400, "unknown"), (0x00000800, "Negotiate Anonymous"), (0x00001000, "Negotiate Domain Supplied"), (0x00002000, "Negotiate Workstation Supplied"), (0x00004000, "Negotiate Local Call"), (0x00008000, "Negotiate Always Sign"), (0x00010000, "Target Type Domain"), (0x00020000, "Target Type Server"), (0x00040000, "Target Type Share"), (0x00080000, "Negotiate NTLM2 Key"), (0x00100000, "Request Init Response"), (0x00200000, "Request Accept Response"), (0x00400000, "Request Non-NT Session Key"), (0x00800000, "Negotiate Target Info"), (0x01000000, "unknown"), (0x02000000, "unknown"), (0x04000000, "unknown"), (0x08000000, "unknown"), (0x10000000, "unknown"), (0x20000000, "Negotiate 128"), (0x40000000, "Negotiate Key Exchange"), (0x80000000, "Negotiate 56") ) def __init__(self): self.output = {} self.flags_tbl = NtlmParser.flags_tbl_str self.msg_types = collections.defaultdict(lambda: "UNKNOWN") self.msg_types[1] = "Request" self.msg_types[2] = "Challenge" self.msg_types[3] = "Response" self.target_field_types = collections.defaultdict(lambda: "UNKNOWN") self.target_field_types[0] = ("TERMINATOR", str) self.target_field_types[1] = ("Server name", str) self.target_field_types[2] = ("AD domain name", str) self.target_field_types[3] = ("FQDN", str) self.target_field_types[4] = ("DNS domain name", str) self.target_field_types[5] = ("Parent DNS domain", str) self.target_field_types[7] = ("Server Timestamp", int) def flags_lst(self, flags): return [desc for val, desc in self.flags_tbl if val & flags] def flags_str(self, flags): return ['%s' % s for s in self.flags_lst(flags)] def clean_str(self, st): return ''.join((s if s in NtlmParser.VALID_CHRS else '?') for s in st) class StrStruct(object): def __init__(self, pos_tup, raw): length, alloc, offset = pos_tup self.length = length self.alloc = alloc self.offset = offset self.raw = raw[offset:offset+length] self.utf16 = False if len(self.raw) >= 2 and self.raw[1] == 0: try: self.string = self.raw.decode('utf-16') except: self.string = ''.join(filter(lambda x: str(x) != str('\0'), self.raw)) self.utf16 = True else: self.string = self.raw def __str__(self): return ''.join((s if s in NtlmParser.VALID_CHRS else '?') for s in self.string) def parse(self, data): st = base64.b64decode(data) if st[:len('NTLMSSP')].decode() == "NTLMSSP": pass else: raise Exception("NTLMSSP header not found at start of input string") ver = struct.unpack(" timeout: break elif time.time() - begin > timeout * 2: break wait = 0 try: data = the_socket.recv(4096).decode() if data: total_data.append(data) begin = time.time() data = '' wait = 0 else: time.sleep(0.1) except: pass result = ''.join(total_data) return result def send(self, data, dontReconnect = False): Logger.dbg(f"================= [SEND] =================\n{data}\n") try: self.socket.send(data.encode()) except Exception as e: Logger.fail(f"Could not send data: {e}") if not self.reconnect < ExchangeRecon.MAX_RECONNECTS and not dontReconnect: self.reconnect += 1 Logger.dbg("Reconnecing...") if self.connect(self.hostname, self.port): return self.send(data, True) else: Logger.err("Could not reconnect with remote host. Failure.") sys.exit(-1) out = ExchangeRecon.recvall(self.socket, config['timeout']) if not out and self.reconnect < ExchangeRecon.MAX_RECONNECTS and not dontReconnect: Logger.dbg("No data returned. Reconnecting...") self.reconnect += 1 if self.connect(self.hostname, self.port): return self.send(data, True) else: Logger.err("Could not reconnect with remote host. Failure.") sys.exit(-1) Logger.dbg(f"================= [RECV] =================\n{out}\n") return out def http(self, method = 'GET', url = '/', host = None, httpver = 'HTTP/1.1', data = None, headers = None, followRedirect = False, redirect = 0 ): hdrs = ExchangeRecon.HEADERS.copy() if headers: hdrs.update(headers) if host: hdrs['Host'] = host headersstr = '' for k, v in hdrs.items(): headersstr += f'{k}: {v}\r\n' if data: data = f'\r\n{data}' else: data = '' packet = f'{method} {url} {httpver}\r\n{headersstr}{data}\r\n\r\n' raw = self.send(packet) resp = ExchangeRecon.response(raw) if resp['code'] in [301, 302, 303] and followRedirect: Logger.dbg(f'Following redirect. Depth: {redirect}...') location = urlparse(resp['headers']['location']) port = 80 if location.scheme == 'http' else 443 host = location.netloc if not host: host = self.hostname if ':' in location.netloc: port = int(location.netloc.split(':')[1]) host = location.netloc.split(':')[0] if self.connect(host, port): pos = resp['headers']['location'].find(location.path) return self.http( method = 'GET', url = resp['headers']['location'][pos:], host = host, data = '', headers = headers, followRedirect = redirect < ExchangeRecon.MAX_REDIRECTS, redirect = redirect + 1) return resp, raw @staticmethod def response(data): resp = { 'version' : '', 'code' : 0, 'message' : '', 'headers' : {}, 'data' : '' } num = 0 parsed = 0 for line in data.split('\r\n'): parsed += len(line) + 2 line = line.strip() if not line: break if num == 0: splitted = line.split(' ') resp['version'] = splitted[0] resp['code'] = int(splitted[1]) resp['message'] = ' '.join(splitted[2:]) num += 1 continue num += 1 pos = line.find(':') name = line[:pos] val = line[pos+1:].strip() if name in resp['headers'].keys(): if isinstance(resp['headers'][name], str): old = resp['headers'][name] resp['headers'][name] = [old] if val not in resp['headers'][name]: try: resp['headers'][name].append(int(val)) except ValueError: resp['headers'][name].append(val) else: try: resp['headers'][name] = int(val) except ValueError: resp['headers'][name] = val if parsed > 0 and parsed < len(data): resp['data'] = data[parsed:] if 'content-length' in resp['headers'].keys() and len(resp['data']) != resp['headers']['content-length']: Logger.fail(f"Received data is not of declared by server length ({len(resp['data'])} / {resp['headers']['content-length']})!") return resp def inspect(self, resp): global found_dns_domain if resp['code'] == 0: return for k, v in resp['headers'].items(): vals = [] if isinstance(v, str): vals.append(v) elif isinstance(v, int): vals.append(str(v)) else: vals.extend(v) lowervals = [x.lower() for x in vals] kl = k.lower() if kl == 'x-owa-version': ver = ExchangeRecon.parseVersion(v) if ver: self.results[ExchangeRecon.owaVersionInHttpHeader] += '\n\t({})'.format(str(ver)) elif kl == 'www-authenticate': realms = list(filter(lambda x: 'basic realm="' in x, lowervals)) if len(realms): Logger.dbg(f"Got basic realm.: {str(realms)}") m = re.search(r'([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})', realms[0]) if m: self.results[ExchangeRecon.leakedInternalIp] = m.group(1) negotiates = list(filter(lambda x: 'Negotiate ' in x, vals)) if len(negotiates): val = negotiates[0][len('Negotiate '):] Logger.dbg('NTLM Message hex dump:\n' + hexdump(base64.b64decode(val))) parsed = NtlmParser().parse(val) Logger.dbg(f"Parsed NTLM Message:\n{str(parsed)}") foo = '' for k2, v2 in parsed.items(): if isinstance(v2, str): try: foo += f'\t{k2}:\n\t\t{v2}\n' except: pass elif isinstance(v2, dict): foo += f'\t{k2}:\n' for k3, v3 in v2.items(): if k3 == 'DNS domain name': found_dns_domain = v3 try: foo += f"\t\t{k3: <18}:\t{v3}\n" except: pass elif isinstance(v2, list): try: foo += f'\t{k2}:\n\t\t- ' + '\n\t\t- '.join(v2) + '\n' except: pass self.results[ExchangeRecon.leakedInternalDomainNTLM] = foo[1:] if kl == 'server': self.results[ExchangeRecon.iisVersion] = vals[0] if kl == 'x-aspnet-version': self.results[ExchangeRecon.aspVersion] = vals[0] if kl not in ExchangeRecon.usualHeaders: l = f'{k}: {v}' if ExchangeRecon.unusualHeaders not in self.results.keys() or \ l not in self.results[ExchangeRecon.unusualHeaders]: Logger.info("Came across unusual HTTP header: " + l) if ExchangeRecon.unusualHeaders not in self.results: self.results[ExchangeRecon.unusualHeaders] = set() self.results[ExchangeRecon.unusualHeaders].add(l) for name, rex in ExchangeRecon.htmlregexes.items(): m = re.search(rex, resp['data']) if m: self.results[name] = m.group(1) if 'Outlook Web App version leaked' in name: ver = ExchangeRecon.parseVersion(m.group(1)) if ver: self.results[name] += '\n\t({})'.format(str(ver)) @staticmethod def parseVersion(lookup): # Try strict matching for ver in ExchangeRecon.exchangeVersions: if ver.version == lookup: return ver lookupparsed = packaging.version.parse(lookup) # Go with version-wise comparison to fuzzily find proper version name sortedversions = sorted(ExchangeRecon.exchangeVersions) for i in range(len(sortedversions)): if sortedversions[i].version.startswith(lookup): sortedversions[i].name = 'fuzzy match: ' + sortedversions[i].name return sortedversions[i] for i in range(len(sortedversions)): prevver = packaging.version.parse('0.0') nextver = packaging.version.parse('99999.0') if i > 0: prevver = packaging.version.parse(sortedversions[i-1].version) thisver = packaging.version.parse(sortedversions[i].version) if i + 1 < len(sortedversions): nextver = packaging.version.parse(sortedversions[i+1].version) if lookupparsed >= thisver and lookupparsed < nextver: sortedversions[i].name = 'fuzzy match: ' + sortedversions[i].name return sortedversions[i] return None def verifyExchange(self): # Fetching these paths as unauthorized must result in 401 verificationPaths = ( # (path, redirect, sendHostHeader /* HTTP/1.1 */) ('/owa', True, True), ('/autodiscover/autodiscover.xml', True, False), ('/Microsoft-Server-ActiveSync', True, False), ('/EWS/Exchange.asmx', True, False), ('/ecp/?ExchClientVer=15', False, False), ) definitiveMarks = ( 'Outlook Web App', '', 'Set-Cookie: exchangecookie=', 'Set-Cookie: OutlookSession=', '/owa/auth/logon.aspx?url=https://', '{57A118C6-2DA9-419d-BE9A-F92B0F9A418B}', 'To use Outlook Web App, browser settings must allow scripts to run. For ' +\ 'information about how to allow scripts' ) otherMarks = ( 'Location: /owa/', 'Microsoft-IIS/', 'Negotiate TlRM', 'WWW-Authenticate: Negotiate', 'ASP.NET' ) score = 0 definitive = False for path, redirect, sendHostHeader in verificationPaths: if not sendHostHeader: resp, raw = self.http(url = path, httpver = 'HTTP/1.0', followRedirect = redirect) else: r = requests.get(f'https://{self.hostname}{path}', verify = False, allow_redirects = True) resp = { 'version' : 'HTTP/1.1', 'code' : r.status_code, 'message' : r.reason, 'headers' : r.headers, 'data' : r.text } raw = r.text Logger.info(f"Got HTTP Code={resp['code']} on access to ({path})") if resp['code'] in [301, 302]: loc = f'https://{self.hostname}/owa/auth/logon.aspx?url=https://{self.hostname}/owa/&reason=0' if loc in raw: definitive = True score += 2 if resp['code'] == 401: score += 1 for mark in otherMarks: if mark in str(raw): score += 1 for mark in definitiveMarks: if mark in str(raw): score += 2 definitive = True self.inspect(resp) Logger.info(f"Exchange scored with: {score}. Definitively sure it's an Exchange? {definitive}") return score > 15 or definitive def tryToTriggerNtlmAuthentication(self): verificationPaths = ( '/autodiscover/autodiscover.xml', ) for path in verificationPaths: auth = { 'Authorization': 'Negotiate TlRMTVNTUAABAAAAB4IIogAAAAAAAAAAAAAAAAAAAAAGAbEdAAAADw==', 'X-Nego-Capability': 'Negotiate, Kerberos, NTLM', 'X-User-Identity': 'john.doe@example.com', 'Content-Length': '0', } resp, raw = self.http(method = 'POST', host = self.hostname, url = path, headers = auth) self.inspect(resp) def process(self): for port in ExchangeRecon.COMMON_PORTS: if self.connect(self.hostname, port): self.port = port Logger.ok(f"Connected with {self.hostname}:{port}\n") break if not self.port: Logger.err(f"Could not contact {self.hostname}. Failure.\n") return False print("[.] Probing for Exchange fingerprints...") if not self.verifyExchange(): Logger.err("Specified target hostname is not an Exchange server.") return False print("[.] Triggering NTLM authentication...") self.tryToTriggerNtlmAuthentication() print("[.] Probing support for legacy mail protocols and their capabilities...") self.legacyMailFingerprint() def legacyMailFingerprint(self): self.socket.close() self.socket = None for port in (25, 465, 587): try: Logger.dbg(f"Trying smtp on port {port}...") if self.smtpInteract(self.hostname, port, _ssl = False): break else: Logger.dbg(f"Trying smtp SSL on port {port}...") if self.smtpInteract(self.hostname, port, _ssl = True): break except Exception as e: Logger.dbg(f"Failed fetching SMTP replies: {e}") raise continue @staticmethod def _smtpconnect(host, port, _ssl): server = None try: if _ssl: server = smtplib.SMTP_SSL(host = host, port = port, local_hostname = 'smtp.gmail.com', timeout = config['timeout']) else: server = smtplib.SMTP(host = host, port = port, local_hostname = 'smtp.gmail.com', timeout = config['timeout']) if config['debug']: server.set_debuglevel(True) return server except Exception as e: Logger.dbg(f"Could not connect to SMTP server on SSL={_ssl} port={port}. Error: {e}") return None def smtpInteract(self, host, port, _ssl): server = ExchangeRecon._smtpconnect(host, port, _ssl) if not server: return None capabilities = [] with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.connect((host, port)) banner = ExchangeRecon.recvall(sock) Logger.info(f"SMTP server returned following banner:\n\t{banner}") capabilities.append(banner.strip()) try: code, msg = server.ehlo() except Exception: server = ExchangeRecon._smtpconnect(host, port, _ssl) if not server: return None code, msg = server.ehlo() msg = msg.decode() for line in msg.split('\n'): capabilities.append(line.strip()) try: server.starttls() code, msg = server.ehlo() except Exception: server = ExchangeRecon._smtpconnect(host, port, _ssl) if not server: return None server.ehlo() server.starttls() code, msg = server.ehlo() msg = msg.decode() Logger.info(f"SMTP server banner & capabilities:\n-------\n{msg}\n-------\n") for line in msg.split('\n'): capabilities.append(line.strip()) try: msg = server.help() except Exception: server = ExchangeRecon._smtpconnect(host, port, _ssl) if not server: return None server.ehlo() try: server.starttls() server.ehlo() except: pass msg = server.help() msg = msg.decode() for line in msg.split('\n'): capabilities.append(line.strip()) skipThese = ( '8BITMIME', 'STARTTLS', 'PIPELINING', 'AUTH', 'CHUNKING', 'SIZE ', 'ENHANCEDSTATUSCODES', 'SMTPUTF8', 'DSN', 'BINARYMIME', 'HELP', 'QUIT', 'DATA', 'EHLO', 'HELO', #'GSSAPI', #'X-EXPS', #'X-ANONYMOUSTLS', 'This server supports the following commands' ) unfiltered = set() for line in capabilities: skip = False for n in skipThese: if n in line: skip = True break if not skip: unfiltered.add(line) if len(unfiltered): self.results[ExchangeRecon.legacyMailCapabilities] = \ '\t- ' + '\n\t- '.join(unfiltered) try: server.quit() except: pass self.verifyEnumerationOpportunities(host, port, _ssl) return True def verifyEnumerationOpportunities(self, host, port, _ssl): def _reconnect(host, port, _ssl): server = ExchangeRecon._smtpconnect(host, port, _ssl) server.ehlo() try: server.starttls() server.ehlo() except: pass return server Logger.info("Examining potential methods for SMTP user enumeration...") server = ExchangeRecon._smtpconnect(host, port, _ssl) if not server: return None ip = '[{}]'.format(socket.gethostbyname(self.hostname)) if found_dns_domain: ip = found_dns_domain techniques = { f'VRFY root' : None, f'EXPN root' : None, f'MAIL FROM:' : None, f'RCPT TO:' : None, } server = _reconnect(host, port, _ssl) likely = 0 for data in techniques.keys(): for i in range(3): try: code, msg = server.docmd(data) msg = msg.decode() techniques[data] = f'({code}, "{msg}")' Logger.dbg(f"Attempted user enumeration using: ({data}). Result: {techniques[data]}") if code >= 200 and code <= 299: Logger.ok(f"Method {data} may allow SMTP user enumeration.") likely += 1 else: Logger.fail(f"Method {data} is unlikely to allow SMTP user enumeration.") break except Exception as e: Logger.dbg(f"Exception occured during SMTP User enumeration attempt: {e}") server.quit() server = _reconnect(host, port, _ssl) continue out = '' for k, v in techniques.items(): code = eval(v)[0] c = '?' if code >= 200 and code <= 299: c = '+' if code >= 500 and code <= 599: c = '-' out += f'\n\t- [{c}] {k: <40} returned: {v}' self.results["Results for SMTP User Enumeration attempts"] = out[2:] def parseOptions(argv): global config print(''' :: Exchange Fingerprinter Tries to obtain internal IP address, Domain name and other clues by talking to Exchange Mariusz B. / mgeeky '19, v{} '''.format(VERSION)) parser = argparse.ArgumentParser(prog = argv[0], usage='%(prog)s [options] ') parser.add_argument('hostname', metavar='', type=str, help='Hostname of the Exchange server (or IP address).') parser.add_argument('-v', '--verbose', action='store_true', help='Display verbose output.') parser.add_argument('-d', '--debug', action='store_true', help='Display debug output.') args = parser.parse_args() if not 'hostname' in args: Logger.err('You must specify a hostname to launch!') return False config['verbose'] = args.verbose config['debug'] = args.debug return args def output(hostname, out): print("\n======[ Leaked clues about internal environment ]======\n") print(f"\nHostname: {hostname}\n") for k, v in out.items(): if not v: continue if isinstance(v, str): print(f"*) {k}:\n\t{v.strip()}\n") elif isinstance(v, list) or isinstance(v, set): v2 = '\n\t- '.join(v) print(f"*) {k}:\n\t- {v2}\n") def main(argv): opts = parseOptions(argv) if not opts: Logger.err('Options parsing failed.') return False recon = ExchangeRecon(opts.hostname) try: t = threading.Thread(target = recon.process) t.setDaemon(True) t.start() while t.is_alive(): t.join(3.0) except KeyboardInterrupt: Logger.fail("Interrupted by user.") if len(recon.results) > 1: output(opts.hostname, recon.results) if __name__ == '__main__': main(sys.argv)