├── version.txt ├── .gitignore ├── requirements.txt ├── LICENSE ├── setup.py ├── README.md └── aadoutsider.py /version.txt: -------------------------------------------------------------------------------- 1 | 1.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | aadoutsider.egg-info/ 3 | build/ 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnspython==2.6.1 2 | requests==2.31.0 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Synacktiv 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from setuptools import setup 3 | 4 | here = path.abspath(path.dirname(__file__)) 5 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 6 | long_description = f.read() 7 | 8 | with open(path.join(here, 'requirements.txt'), encoding='utf-8') as f: 9 | requirements = [line.strip() for line in f.readlines()] 10 | 11 | with open(path.join(here, 'version.txt'), encoding='utf-8') as f: 12 | version = f.read().strip() 13 | 14 | setup( 15 | name='aadoutsider', 16 | version='1.0', 17 | description='AADOutsider-py', 18 | long_description=long_description, 19 | long_description_content_type='text/markdown', 20 | url='https://github.com/synacktiv/AADOutsider-py', 21 | author='Synacktiv', 22 | classifiers=[ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Intended Audience :: Information Technology', 25 | 'Programming Language :: Python :: 3', 26 | 'Programming Language :: Python :: 3.7', 27 | 'Programming Language :: Python :: 3.8', 28 | 'Programming Language :: Python :: 3.9', 29 | ], 30 | keywords='aadoutsider-py aadoutsider aadinternals azure', 31 | python_requires='>=3.5, <4', 32 | install_requires=requirements, 33 | entry_points={ 34 | 'console_scripts': ['aadoutsider = aadoutsider:main'], 35 | }, 36 | project_urls={ 37 | 'Apply!': 'https://www.synacktiv.com', 38 | 'Source': 'https://github.com/synacktiv/AADOutsider-py', 39 | }, 40 | ) 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AADOutsider-py 2 | 3 | > [!CAUTION] 4 | > The `Invoke-AADIntReconAsOutsider` has now been patched and will not return the other domains registered in the tenant. 5 | > 6 | > See [Microsoft blogpost](https://techcommunity.microsoft.com/blog/exchange/important-update-to-the-get-federationinformation-cmdlet-in-exchange-online/4410095). 7 | 8 | ## Intro 9 | 10 | This tool is a rewrite of the recon as outsider part of AADInternals. 11 | 12 | It reimplements the following killchains functions of AADInternals and all their submethods: 13 | - Invoke-AADIntReconAsOutsider 14 | - Invoke-AADIntUserEnumerationAsOutsider 15 | 16 | ## Install 17 | 18 | ``` 19 | pip3 install git+https://github.com/synacktiv/AADOutsider-py 20 | ``` 21 | 22 | OR 23 | 24 | ``` 25 | python3 -m venv venv 26 | source venv/bin/activate 27 | pip3 install -r requirements.txt 28 | ``` 29 | 30 | ## Usage 31 | 32 | ### Global 33 | ``` 34 | $ python3 aadoutsider.py -h 35 | usage: aadoutsider.py [-h] [--dns-tcp] [--dns DNS] [-v] {recon,user_enum} ... 36 | 37 | AADInternals-Recon.py - The Python equivalent of AADInternals recon as outsider 38 | 39 | positional arguments: 40 | {recon,user_enum} cmdlet to call 41 | recon ReconAsOutsider 42 | user_enum UserEnumerationAsOutsider 43 | 44 | options: 45 | -h, --help show this help message and exit 46 | --dns-tcp Use TCP instead of UDP for DNS requests 47 | --dns DNS Use this specific DNS (can be used multiple times) 48 | -v, --verbose 49 | ``` 50 | 51 | ### Recon 52 | > [!CAUTION] 53 | > Patched, see first README.md note. 54 | ``` 55 | $ python3 aadoutsider.py recon -h 56 | usage: aadoutsider.py recon [-h] [-d DOMAIN] [-u USERNAME] [-s] [-r] [-o OUTPUT] [-of {json,csv,pretty}] 57 | 58 | options: 59 | -h, --help show this help message and exit 60 | -d DOMAIN, --domain DOMAIN 61 | targeted domain 62 | -u USERNAME, --username USERNAME 63 | targeted username 64 | -s, --single only perform advanced checks for the targeted domain 65 | -r, --relayingparties 66 | retrieve relaying parties of STSs 67 | -o OUTPUT, --output OUTPUT 68 | output file 69 | -of {json,csv,pretty}, --output-form {json,csv,pretty} 70 | output format 71 | 72 | $ python3 aadoutsider.py -d microsoft.com 73 | INFO: Found 297 domains! 74 | [===============================] 75 | INFO: Tenant brand: Microsoft 76 | INFO: Tenant name: MicrosoftAPC.onmicrosoft.com 77 | INFO: Tenant id: 72f988bf-86f1-41af-91ab-2d7cd011db47 78 | INFO: Tenant region: WW 79 | INFO: DesktopSSO enabled: False 80 | 81 | Name DNS MX SPF DMARC DKIM MTA-STS Type STS 82 | ---- --- -- --- ----- ---- ------- ---- --- 83 | 008.mgd.microsoft.com True False False False False False Managed 84 | 064d.mgd.microsoft.com True False False False False False Federated msft.sts.microsoft.com 85 | 2hatsecurity.com True True True False False False Managed 86 | acompli.com True True True True False False Managed 87 | adagencytrainings.microsoft.com True True False False False False Federated msft.sts.microsoft.com 88 | adxstudio.com True True True True False False Managed 89 | affirmedNetworks.com True True True True False False Managed 90 | [...] 91 | Xoxco.com True True True True False False Managed 92 | yammer-inc.com True True False True False False Managed 93 | zune.net True True False True False False Managed 94 | ``` 95 | 96 | ### User enumeration 97 | ``` 98 | $ python3 aadoutsider.py user_enum -h 99 | usage: aadoutsider.py user_enum [-h] [-m {normal,login,autologon,rst2}] [-e] [-d DOMAIN] username 100 | 101 | positional arguments: 102 | username user to test 103 | 104 | options: 105 | -h, --help show this help message and exit 106 | -m {normal,login,autologon,rst2}, --method {normal,login,autologon,rst2} 107 | enumeration method 108 | -e, --external 109 | -d DOMAIN, --domain DOMAIN 110 | 111 | $ python3 aadoutsider.py user_enum myuser@mycompany.com 112 | INFO: User myuser@mycompany.com exists 113 | ``` 114 | 115 | ## Documentation 116 | 117 | - https://aadinternals.com/aadinternals/#invoke-aadintreconasoutsider 118 | - https://aadinternals.com/aadinternals/#invoke-aadintuserenumerationasoutsider 119 | 120 | -------------------------------------------------------------------------------- /aadoutsider.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import argparse 4 | import logging 5 | import sys 6 | import shutil 7 | import re 8 | import uuid 9 | import requests 10 | import json 11 | import dns.resolver 12 | import xml.etree.ElementTree as ET 13 | 14 | DNS_TCP = False 15 | DNS_RESOLVER = None 16 | OUTPUT = "/dev/stdout" 17 | OUTPUT_FORM = "json" 18 | LOG_LEVEL = logging.info 19 | PROGRESS_FULL_SIZE = 0 20 | PROGRESS_STEP = 0 21 | PROGRESS_CUR_STEP = 0 22 | PROGRESS = 0 23 | 24 | def output_data(data): 25 | output_str = '' 26 | 27 | # Compute output string 28 | if OUTPUT_FORM == 'json': 29 | output_str = json.dumps(data) 30 | 31 | elif OUTPUT_FORM == 'csv': 32 | raise NotImplementedError 33 | 34 | elif OUTPUT_FORM == 'pretty': 35 | header = list(data[0].keys()) 36 | table_size = {} 37 | for header_column in header: 38 | max_column_size = len(header_column) 39 | for t in data: 40 | cur_column_size = len(str(t[header_column])) 41 | if cur_column_size > max_column_size: 42 | max_column_size = cur_column_size 43 | table_size[header_column] = max_column_size 44 | 45 | row_format = '\t'.join([f'{{:<{table_size[cur_header]}}}' for cur_header in header]) 46 | output_str += row_format.format(*header) + '\n' 47 | output_str += row_format.format(*['-' * len(cur_header) for cur_header in header]) + '\n' 48 | 49 | for cur_data in sorted(data, key=lambda item: item['Name'].lower()): 50 | output_str += row_format.format(*[str(cur_data[cur_header]) for cur_header in header]) + '\n' 51 | 52 | elif OUTPUT_FORM == 'list': 53 | output_str = '\n'.join(sorted([t['Name'] for t in data])) 54 | 55 | output_str += '\n' 56 | 57 | # Write string to disk 58 | with open(OUTPUT, 'w') as f: 59 | if f.isatty(): 60 | # Special case for stdout to separate output from logging info 61 | output_str = '\n' + output_str 62 | f.write(output_str) 63 | 64 | def init_progress(full_size): 65 | global PROGRESS_STEP 66 | global PROGRESS_CUR_STEP 67 | global PROGRESS 68 | 69 | if LOG_LEVEL == logging.DEBUG or not sys.stderr.isatty(): 70 | return 71 | 72 | bar_size = min(shutil.get_terminal_size(fallback=(20,0))[0], full_size + 2) 73 | 74 | PROGRESS_STEP = full_size // (bar_size - 2) # Need to account for the brackets 75 | PROGRESS_CUR_STEP = 0 76 | PROGRESS = 0 77 | 78 | sys.stderr.write(f'[{" " * (bar_size - 2)}]') 79 | sys.stderr.flush() 80 | sys.stderr.write('\b' * (bar_size-1)) 81 | 82 | def add_progress(): 83 | global PROGRESS_STEP 84 | global PROGRESS_CUR_STEP 85 | global PROGRESS 86 | 87 | if LOG_LEVEL == logging.DEBUG or not sys.stderr.isatty(): 88 | return 89 | 90 | if PROGRESS_CUR_STEP >= PROGRESS: 91 | sys.stderr.write('=') 92 | sys.stderr.flush() 93 | PROGRESS += PROGRESS_STEP 94 | 95 | PROGRESS_CUR_STEP += 1 96 | return 97 | 98 | def end_progress(): 99 | if LOG_LEVEL == logging.DEBUG or not sys.stderr.isatty(): 100 | return 101 | sys.stderr.write('\r\033[K') 102 | sys.stderr.flush() 103 | 104 | def get_dns_resolver(): 105 | resolver = dns.resolver.Resolver() 106 | if DNS_RESOLVER != None: 107 | resolver.nameservers = DNS_RESOLVER 108 | return resolver 109 | 110 | def dns_query(domain, mytype, ignore_error=False): 111 | 112 | error = False 113 | resolver = get_dns_resolver() 114 | res = [] 115 | try: 116 | full_resp = resolver.resolve(domain, mytype, tcp=DNS_TCP, raise_on_no_answer=False) 117 | if mytype == 'MX': 118 | for resp in full_resp: 119 | res.append(re.sub(r'\.*$', '', resp.exchange.to_text())) 120 | elif len(full_resp.response.answer) != 0: 121 | for resp in [item for t in full_resp.response.answer for item in t]: 122 | resp = str(resp) 123 | # Removing any trailing dots 124 | res.append(re.sub(r'\.*$', '', resp)) 125 | except dns.resolver.NXDOMAIN: 126 | # Domain explicitely not existing 127 | if ignore_error: 128 | res = [] 129 | else: 130 | raise 131 | except Exception as e: 132 | logging.warning(f'Got the following exception while querying {domain} for {mytype} type: {type(e)} - {e}') 133 | raise 134 | 135 | return res 136 | 137 | def does_exist(domain): 138 | # This function is a bit weird, in the sense that a domain could have no DNS record registered to 139 | # its name, yet it could "exist", ie. the Reply code in the DNS response is 0, aka. "No error". 140 | # This behavior is reproduced in this function to match the behavior of the Resolve-DnsName 141 | # powershell cmdlet and how it is used in AADInternals. 142 | 143 | res = [] 144 | error = False 145 | 146 | try: 147 | res += dns_query(domain, 'A') 148 | except Exception: 149 | error = True 150 | try: 151 | res += dns_query(domain, 'AAAA') 152 | except Exception: 153 | error = True 154 | 155 | return not (error and len(res) == 0) 156 | 157 | def get_tenant_id(domain=None, username=None, accesstoken=None): 158 | if accesstoken is not None: 159 | raise NotImplementedError("Tenant ID from access token retrieval is not yet implemented") 160 | 161 | if domain is None: 162 | domain = username.split('@')[-1] 163 | 164 | try: 165 | resp = requests.get(f'https://odc.officeapps.live.com/odc/v2.1/federationprovider?domain={domain}').text 166 | formated_resp = json.loads(resp) 167 | 168 | if 'tenantId' not in formated_resp: 169 | return None 170 | 171 | return formated_resp['tenantId'] 172 | 173 | except Exception: 174 | raise RuntimeError('Cannot query/parse remote service') 175 | 176 | def get_credential_type(username=None, flowtoken=None, originalrequest=None, subscope=None): 177 | body = { 178 | "username": username, 179 | "isOtherIdpSupported": True, 180 | "checkPhones": True, 181 | "isRemoteNGCSupported": False, 182 | "isCookieBannerShown": False, 183 | "isFidoSupported": False, 184 | "originalRequest": originalrequest, 185 | "flowToken": flowtoken 186 | } 187 | 188 | if originalrequest is not None: 189 | body['isAccessPassSupported'] = True 190 | 191 | return json.loads(requests.post(f'{get_tenant_login_url(subscope)}/common/GetCredentialType', headers={'ContentType': 'application/json; charset=UTF-8'}, data=json.dumps(body)).text) 192 | 193 | def has_cba(username, subscope=None): 194 | try: 195 | return get_credential_type(username=username, subscope=subscope)['Credentials']['HasCertAuth'] == True 196 | except KeyError: 197 | return False 198 | 199 | def has_desktop_sso(domain, subscope=None): 200 | try: 201 | return get_credential_type(f'nn@{domain}', subscope=subscope)['EstsProperties']['DesktopSsoEnabled'] == True 202 | except KeyError: 203 | return False 204 | 205 | def has_cloud_mx(domain, subscope): 206 | if subscope == 'DOD': 207 | myfilter = '.protection.office365.us' 208 | elif subscope == 'DODCON': 209 | myfilter = '.protection.office365.us' 210 | else: 211 | myfilter = '.mail.protection.outlook.com' 212 | 213 | return any([t.endswith(myfilter) for t in dns_query(domain, 'MX', ignore_error=True)]) 214 | 215 | def has_cloud_spf(domain, subscope): 216 | if subscope == 'DOD': 217 | myfilter = 'include:spf.protection.office365.us' 218 | elif subscope == 'DODCON': 219 | myfilter = 'include:spf.protection.office365.us' 220 | else: 221 | myfilter = 'include:spf.protection.outlook.com' 222 | 223 | return any([myfilter in t for t in dns_query(domain, 'TXT', ignore_error=True)]) 224 | 225 | def has_cloud_dmarc(domain): 226 | # DMARC TXT record are double quoted, hence the " 227 | return any([t.startswith('"v=DMARC1') for t in dns_query(f'_dmarc.{domain}', 'TXT', ignore_error=True)]) 228 | 229 | def has_cloud_dkim(domain, subscope=None): 230 | if subscope == 'DOD': 231 | myfilter = r'.*_domainkey\..*\.onmicrosoft\.us.*' 232 | elif subscope == 'DODCON': 233 | myfilter = r'.*_domainkey\..*\.onmicrosoft\.us.*' 234 | else: 235 | myfilter = r'.*_domainkey\..*\.onmicrosoft\.com.*' 236 | 237 | domains = [f'selector1._domainkey.{domain}', f'selector2._domainkey.{domain}'] 238 | for check_domain in domains: 239 | for resp in dns_query(check_domain, 'CNAME', ignore_error=True): 240 | if re.match(myfilter, resp) is not None: 241 | return True 242 | return False 243 | 244 | def has_cloud_mtasts(domain, subscope=None): 245 | if subscope == 'DOD': 246 | myfilter = r'.*_domainkey\..*\.onmicrosoft\.com.*' 247 | elif subscope == 'DODCON': 248 | myfilter = r'.*mx: .*\.mail\.protection\.office365\.us.*' 249 | else: 250 | myfilter = r'.*mx: .*\.mail\.protection\.outlook\.com.*' 251 | 252 | url = f'https://mta-sts.{domain}/.well-known/mta-sts.txt' 253 | mta_sts_found = False 254 | outlook_mx_found = False 255 | 256 | try: 257 | for line in requests.get(url, timeout=5).text.splitlines(): 258 | if line == "version: STSv1": 259 | mta_sts_found = True 260 | if re.match(r'.*mx: .*\.mail\.protection\.outlook\.com.*', line) is not None: 261 | outlook_mx_found = True 262 | except Exception: 263 | mta_sts_found = False 264 | outlook_mx_found = False 265 | 266 | return mta_sts_found and outlook_mx_found 267 | 268 | 269 | def get_openid_configuration(domain=None, username=None): 270 | if domain is None: 271 | domain = username.split('@')[-1] 272 | 273 | resp = requests.get(f'https://login.microsoftonline.com/{domain}/.well-known/openid-configuration').text 274 | return json.loads(resp) 275 | 276 | def get_tenant_subscope(domain=None, openid_config=None): 277 | if not openid_config: 278 | openid_config = get_openid_configuration(domain) 279 | 280 | try: 281 | return openid_config['tenant_region_sub_scope'] 282 | except Exception: 283 | return None 284 | 285 | def get_tenant_login_url(subscope=None): 286 | if subscope == "DOD": 287 | return 'https://login.microsoftonline.us' 288 | elif subscope == "DODCON": 289 | return 'https://login.microsoftonline.us' 290 | else: 291 | return 'https://login.microsoftonline.com' 292 | 293 | def get_user_realm_v2(username, subscope=None): 294 | return json.loads(requests.get(f'{get_tenant_login_url(subscope)}/GetUserRealm.srf?login={username}').text) 295 | 296 | def get_mdi_instance(tenant): 297 | tenant = tenant.split('.')[0] 298 | 299 | logging.debug(f'Getting MDI instance for {tenant}') 300 | 301 | domains = [f'{tenant}.atp.azure.com', f'{tenant}-onmicrosoft-com.atp.azure.com'] 302 | for domain in domains: 303 | if does_exist(domain): 304 | return domain 305 | 306 | return None 307 | 308 | def get_tenant_domains(domain, subscope=None): 309 | if not subscope: 310 | subscope = get_tenant_subscope(domain=domain) 311 | 312 | if subscope == 'DOD': 313 | uri = 'https://autodiscover-s-dod.office365.us/autodiscover/autodiscover.svc' 314 | elif subscope == 'DODCON': 315 | uri = 'https://autodiscover-s.office365.us/autodiscover/autodiscover.svc' 316 | else: 317 | uri = 'https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc' 318 | 319 | domains = [] 320 | body = f""" 321 | 322 | 323 | http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetFederationInformation 324 | {uri} 325 | 326 | http://www.w3.org/2005/08/addressing/anonymous 327 | 328 | 329 | 330 | 331 | 332 | {domain} 333 | 334 | 335 | 336 | """ 337 | headers = { 338 | 'Content-Type': 'text/xml; charset=utf-8', 339 | 'SOAPAction': '"http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetFederationInformation"', 340 | 'User-Agent': 'AutodiscoverClient' 341 | } 342 | namespaces = {'s': 'http://schemas.xmlsoap.org/soap/envelope/', 'a': 'http://www.w3.org/2005/08/addressing'} 343 | xpath_query = './s:Body/{http://schemas.microsoft.com/exchange/2010/Autodiscover}GetFederationInformationResponseMessage/{http://schemas.microsoft.com/exchange/2010/Autodiscover}Response/{http://schemas.microsoft.com/exchange/2010/Autodiscover}Domains/{http://schemas.microsoft.com/exchange/2010/Autodiscover}Domain' 344 | 345 | resp = requests.post(uri, headers=headers, data=body).text 346 | root = ET.fromstring(resp) 347 | for domain_elt in root.findall(xpath_query, namespaces=namespaces): 348 | domains.append(domain_elt.text) 349 | 350 | if domain not in domains: 351 | domains.append(domain) 352 | 353 | return sorted(domains) 354 | 355 | def does_user_exist(user, method="normal", subscope=None): 356 | method = method.lower() 357 | allowed_methods = ['normal', 'login', 'autologon', 'rst2'] 358 | if method not in allowed_methods: 359 | raise RuntimeError(f'Parameter "method" for function "does_user_exist" invalid, should be one of {allowed_methods} but got "{method}"') 360 | 361 | # Will stay None if the method was not able to confirm or not 362 | # that the account exists 363 | exists = None 364 | 365 | if not subscope: 366 | subscope = get_tenant_subscope(user.split('@')[-1]) 367 | 368 | if method == "normal": 369 | cred_type = get_credential_type(user, subscope=subscope) 370 | if cred_type['ThrottleStatus'] == 1: 371 | logging.warning('Request throttled!') 372 | else: 373 | exists = cred_type['IfExistsResult'] == 0 or cred_type['IfExistsResult'] == 6 374 | 375 | elif method == 'login': 376 | random_guid = uuid.uuid4() 377 | body = { 378 | 'resource': str(random_guid), 379 | 'client_id': str(random_guid), 380 | 'grant_type': 'password', 381 | 'username': user, 382 | 'password': 'none', 383 | 'scope': 'openid' 384 | } 385 | response = requests.post(f'{get_tenant_login_url(subscope)}/common/oauth2/token', headers={'ContentType': 'application/x-www-form-urlencoded', 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.1 (KHTML, like Gecko) Chrome/13.0.782.112 Safari/535.1'}, data=body) 386 | parsed_resp = json.loads(response.text) 387 | if 'The user account {EUII Hidden} does not exist in the' in parsed_resp['error_description']: 388 | exists = False 389 | elif 'Error validating credentials due to invalid username or password.' in parsed_resp['error_description']: 390 | exists = True 391 | 392 | elif method == 'autologon' or method == 'rst2': 393 | raise NotImplementedError('Method Autologon and RST2 are not yet implemented') 394 | 395 | return exists 396 | 397 | def recon_as_outsider(domain_name=None, username=None, single=False, get_relaying_parties=False): 398 | if domain_name is None and username is None: 399 | logging.warning('No domain nor username was provided') 400 | return 401 | 402 | if domain_name is None: 403 | domain_name = username.split('@')[-1] 404 | tenant_cba = has_cba(username) 405 | 406 | tenant_id = get_tenant_id(domain_name) 407 | if tenant_id is None: 408 | logging.warning(f'Domain {domain_name} is not registered to Azure') 409 | return 410 | 411 | openid_config = get_openid_configuration(domain=domain_name) 412 | 413 | tenant_name = None 414 | tenant_brand = None 415 | tenant_region = openid_config['tenant_region_scope'] 416 | tenant_subscope = get_tenant_subscope(openid_config=openid_config) 417 | tenant_sso = None 418 | tenant_cba = None 419 | 420 | domains_info = [] 421 | domains = get_tenant_domains(domain_name, subscope=tenant_subscope) 422 | logging.info(f'Found {len(domains)} domains!') 423 | 424 | if OUTPUT_FORM == 'list': 425 | # Special case with list where there is no need for any data except 426 | # the domain list 427 | output_data([{'Name': t} for t in domains]) 428 | return 429 | 430 | init_progress(len(domains)) 431 | 432 | for domain in domains: 433 | exists = False 434 | has_cloud_MX = False 435 | has_cloud_SPF = False 436 | has_cloud_DMARC = False 437 | has_cloud_DKIM = False 438 | has_cloud_MTASTS = False 439 | auth_url = "" 440 | 441 | add_progress() 442 | 443 | if tenant_name is None and re.match(r'^[^.]*\.onmicrosoft\.(com|us)$', domain.lower()) is not None: 444 | tenant_name = domain 445 | 446 | if tenant_sso is None: 447 | tenant_sso = has_desktop_sso(domain=domain, subscope=tenant_subscope) 448 | 449 | resolver = get_dns_resolver() 450 | if not single or (single and domain_name.lower() == domain.lower()): 451 | exists = does_exist(domain) 452 | if exists: 453 | has_cloud_MX = has_cloud_mx(domain, subscope=tenant_subscope) 454 | has_cloud_SPF = has_cloud_spf(domain, subscope=tenant_subscope) 455 | has_cloud_DMARC = has_cloud_dmarc(domain) 456 | has_cloud_DKIM = has_cloud_dkim(domain, subscope=tenant_subscope) 457 | has_cloud_MTASTS = has_cloud_mtasts(domain, subscope=tenant_subscope) 458 | 459 | realm_info = get_user_realm_v2(f'nn@{domain}', subscope=tenant_subscope) 460 | if tenant_brand is None: 461 | try: 462 | tenant_brand = realm_info['FederationBrandName'] 463 | except KeyError: 464 | pass 465 | 466 | relaying_parties = [] 467 | 468 | try: 469 | auth_url = realm_info['AuthURL'] 470 | except KeyError: 471 | pass 472 | else: 473 | if get_relaying_parties: 474 | idp_url = auth_url.rpartition('/')[0] + '/idpinitiatedsignon.aspx' 475 | try: 476 | page = requests.get(idp_url) 477 | except Exception: 478 | logging.warning(f'Cannot query idp_url: {idp_url}') 479 | logging.debug(f'Getting relaying parties for {domain} from {idp_url}') 480 | try: 481 | page_root = ET.fromstring(page.text) 482 | res = page_root.findall(".//select[@id='idp_RelyingPartyDropDownList']/option") 483 | logging.debug(f'Got {len(res)} relaying parties from {idp_url}') 484 | except Exception: 485 | pass 486 | else: 487 | for cur_option in res: 488 | relaying_parties.append(cur_option.text) 489 | 490 | auth_url = auth_url.split('/')[2] 491 | 492 | attributes = { 493 | 'Name': domain, 494 | 'DNS': exists, 495 | 'MX': has_cloud_MX, 496 | 'SPF': has_cloud_SPF, 497 | 'DMARC': has_cloud_DMARC, 498 | 'DKIM': has_cloud_DKIM, 499 | 'MTA-STS': has_cloud_MTASTS, 500 | 'Type': realm_info['NameSpaceType'], 501 | 'STS': auth_url 502 | } 503 | 504 | if get_relaying_parties: 505 | attributes['RPS'] = relaying_parties 506 | 507 | domains_info.append(attributes) 508 | 509 | end_progress() 510 | 511 | logging.info(f'Tenant brand: {tenant_brand}') 512 | logging.info(f'Tenant name: {tenant_name}') 513 | logging.info(f'Tenant id: {tenant_id}') 514 | logging.info(f'Tenant region: {tenant_region}') 515 | 516 | if tenant_subscope: 517 | logging.info(f'Tenant sub region: {tenant_subscope}') 518 | 519 | if not single or tenant_sso: 520 | logging.info(f'DesktopSSO enabled: {tenant_sso}') 521 | 522 | if tenant_name is not None: 523 | tenant_mdi = get_mdi_instance(tenant_name) 524 | if tenant_mdi is not None: 525 | logging.info(f'MDI instance: {tenant_mdi}') 526 | 527 | if does_user_exist(f'ADToAADSyncServiceAccount@{tenant_name}'): 528 | logging.info(f'Uses cloud sync: True') 529 | 530 | if tenant_cba is not None: 531 | logging.info(f'CBA enabled: {tenant_cba}') 532 | 533 | output_data(domains_info) 534 | 535 | def user_enumeration_as_outsider(username, method='normal', external=False, domain=None): 536 | tenant_subscope = get_tenant_subscope(username.split('@')[-1]) 537 | 538 | if method == 'normal' and external: 539 | if not domain: 540 | logging.error('Required domain parameter not given') 541 | exit(1) 542 | # User is external, we need to change its email address 543 | username = f'{username.replace("@", "_")}#EXT#@{domain}' 544 | 545 | exists = does_user_exist(username, method=method, subscope=tenant_subscope) 546 | if exists: 547 | logging.info(f'User {username} exists') 548 | elif exists is None: 549 | logging.info(f'Could not determine if {username} exists') 550 | else: 551 | logging.info(f'User {username} does not exist') 552 | 553 | def main(): 554 | global DNS_TCP 555 | global DNS_RESOLVER 556 | global OUTPUT 557 | global OUTPUT_FORM 558 | global LOG_LEVEL 559 | 560 | parser = argparse.ArgumentParser(description='AADInternals-Recon.py - The Python equivalent of AADInternals recon as outsider') 561 | parser.add_argument('--dns-tcp', action='store_true', help='Use TCP instead of UDP for DNS requests', default=False) 562 | parser.add_argument('--dns', action='append', help='Use this specific DNS (can be used multiple times)', default=[]) 563 | parser.add_argument('-v', '--verbose', action='store_true', default=False) 564 | 565 | # subparsers 566 | subparsers = parser.add_subparsers(help='cmdlet to call', dest='cmdlet') 567 | 568 | # cmdlet ReconAsOutsider 569 | parser_a = subparsers.add_parser('recon', help='ReconAsOutsider') 570 | parser_a.add_argument('-d', '--domain', type=str, help='targeted domain') 571 | parser_a.add_argument('-u', '--username', type=str, help='targeted username') 572 | parser_a.add_argument('-s', '--single', action='store_true', help='only perform advanced checks for the targeted domain', default=False) 573 | parser_a.add_argument('-r', '--relayingparties', action='store_true', help='retrieve relaying parties of STSs', default=False) 574 | parser_a.add_argument('-o', '--output', help='output file', default='/dev/stdout') 575 | parser_a.add_argument('-of', '--output-form', help='output format', default='pretty', choices=['json','csv','pretty','list']) 576 | 577 | # cmdlet UserEnumerationAsOutsider 578 | parser_enum = subparsers.add_parser('user_enum', help='UserEnumerationAsOutsider') 579 | parser_enum.add_argument('username', help='user to test') 580 | parser_enum.add_argument('-m', '--method', choices=['normal','login','autologon','rst2'], help='enumeration method', default='normal') 581 | parser_enum.add_argument('-e', '--external', action='store_true') 582 | parser_enum.add_argument('-d', '--domain', type=str, default=None) 583 | args = parser.parse_args() 584 | 585 | # Simple logging configuration 586 | LOG_LEVEL = logging.INFO 587 | if args.verbose: 588 | LOG_LEVEL = logging.DEBUG 589 | logging.basicConfig(format='%(levelname)s: %(message)s', level=LOG_LEVEL) 590 | 591 | # DNS configuration 592 | DNS_TCP = args.dns_tcp 593 | if len(args.dns) > 0: 594 | DNS_RESOLVER = args.dns 595 | 596 | if args.cmdlet == 'recon': 597 | OUTPUT = args.output 598 | OUTPUT_FORM = args.output_form 599 | recon_as_outsider(domain_name=args.domain, username=args.username, single=args.single, get_relaying_parties=args.relayingparties) 600 | elif args.cmdlet == 'user_enum': 601 | user_enumeration_as_outsider(username=args.username, method=args.method, external=args.external, domain=args.domain) 602 | else: 603 | parser.print_help() 604 | exit(1) 605 | 606 | if __name__ == "__main__": 607 | main() 608 | 609 | --------------------------------------------------------------------------------