[CVE-2021-28379] Abusing file uploads to get an SSH backdoor

[CVE-2021-28379] Abusing file uploads to get an SSH backdoor

Note: To fully understand the exploit you will need to fully understand how ssh keys are setup, so you will need to read this article.

I was looking at the scope for SSD Secure Disclosure and I noticed one of the targets is VestaCP, I decided to take a look at the source code to see if I would be able to find anything interesting.

I started by looking at the upload functionality as it's often misused and usually have security issues, the source code for VestaCP can be found Here.

To install VestaCP follow the instructions at https://vestacp.com/install/ on an Ubuntu VM.

The upload functionality is implemented under /web/upload let's take a look at the index.php file to see how it's implemented.

 * jQuery File Upload Plugin PHP Example 5.14
 * https://github.com/blueimp/jQuery-File-Upload
 * Copyright 2010, Sebastian Tschan
 * https://blueimp.net
 * Licensed under the MIT license:
 * http://www.opensource.org/licenses/MIT

error_reporting(E_ALL | E_STRICT);
$upload_handler = new UploadHandler();

As seen in the above code all what the code does is creating an instance of the UploadHandler class this probably means that the class constructor should handle the upload process, so let's take a look at the constructor of that class, the class is implemented in /web/upload/UploadHandler.php file, at the beginning of the constructor code you will find the following code.

    function __construct($options = null, $initialize = true, $error_messages = null) {
        $this->options = array(
            'script_url' => $this->get_full_url().'/',
            //Code removed for simplification.
            'accept_file_types' => '/.+$/i',

The line that seemed interesting the line that sets the accept_file_types in the $options array, this option is set to /.+$/i this is a regular expression that allows anything since .+ means accept any number of characters, If you're not familiar with Regex you can use https://regex101.com/ to confirm that this regex accepts any value.

My first thought was of-course to upload a PHP file to get RCE, however we will see later that while any file type is accepted this will not work because the files are not uploaded to the web root which means they will not get executed.

Anyways let's continue reading the __construct function to see what happens after initializing the $options array, at the end of the constructor code you will see the following call.

        if ($initialize) {

It seems like the constructor calls the initialize(); function after setting the upload options, let's take a look at the initialize function source code.

protected function initialize() {
        switch ($this->get_server_var('REQUEST_METHOD')) {
            case 'OPTIONS':
            case 'HEAD':
            case 'GET':
            case 'PATCH':
            case 'PUT':
            case 'POST':
            case 'DELETE':
                $this->header('HTTP/1.1 405 Method Not Allowed');

The initialize function simply checks the request method and call another function depending on the method used, usually PUT and POST methods are the ones used for uploading files, so this means we will be interested in the post() function, if you read the post() function code you will find that it does some checks and eventually it calls handle_file_upload() function which the function that performs the actual upload functionality, the following is the source code of that function.

    protected function handle_file_upload($uploaded_file, $name, $size, $type, $error,
        $index = null, $content_range = null) {

        $file = new \stdClass();
//        $file->name = $this->get_file_name($uploaded_file, $name, $size, $type, $error,
//            $index, $content_range);

        $file->name = $this->trim_file_name($uploaded_path, $name, $size, $type, $error, $index, $content_range);
        $file->name = $this->fix_file_extension($uploaded_path, $name, $size, $type, $error, $index, $content_range);

        $file->size = $this->fix_integer_overflow(intval($size));
        $file->type = $type;
        if ($this->validate($uploaded_file, $file, $error, $index)) {
            $this->handle_form_data($file, $index);
            $upload_dir = $this->get_upload_path();
            if (!is_dir($upload_dir)) {
                mkdir($upload_dir, $this->options['mkdir_mode'], true);
            $file_path = $this->get_upload_path($file->name);
            $append_file = $content_range && is_file($file_path) &&
                $file->size > $this->get_file_size($file_path);
            if ($uploaded_file && is_uploaded_file($uploaded_file)) {
                chmod($uploaded_file, 0644);
                exec (VESTA_CMD . "v-copy-fs-file ". USERNAME ." ".$uploaded_file." ".escapeshellarg($file_path), $output, $return_var);
                $error = check_return_code($return_var, $output);
                if ($return_var != 0) {
                    $file->error = 'Error while saving file ';
            $file_size = $this->get_file_size($file_path, $append_file);

            if ($file_size === $file->size) {
                $file->url = $this->get_download_url($file->name);
                // uncomment if images also need to be resized
                //if ($this->is_valid_image_file($file_path)) {
                //    $this->handle_image_file($file_path, $file);
            } else {
                //$file->size = $file_size;
                //if (!$content_range && $this->options['discard_aborted_uploads']) {
                //    unlink($file_path);
                //    $file->error = $this->get_error_message('abort');
        return $file;

The first thing you see is a call to trim_file_name() let's examine this function to see how it modified the file name, the following the code of that function.

protected function trim_file_name($file_path, $name, $size, $type, $error,
            $index, $content_range) {
        // Remove path information and dots around the filename, to prevent uploading
        // into different directories or replacing hidden system files.
        // Also remove control characters and spaces (\x00..\x20) around the filename:
        $name = trim(basename(stripslashes($name)), ".\x00..\x20");
        // Use a timestamp for empty filenames:
        if (!$name) {
            $name = str_replace('.', '-', microtime(true));
        return $name;

The function starts by calling stripslashes($name) this effectivly removes all the slashes from the file name, this means if you pass a filename like tes\t.php it will be converted to test.php , next the function passes the result to the basename() function, the base name function removes anything from the path and only keeps the name of the file, for example if you pass /../test/asdas/file.php to that function, it will be converted to file.php and only the file name will remain.

After that the file name is passed to the trim function, if you take a look at the function call you can see the second argument is ".\x00..\x20", this means trim any characters in the range of 0x00 to 0x20, if you look at an ASCII table you will notice that this range includes control characters and spaces, so trim will remove any control characters from the beginning and the end of file name.

To recap this is what `trim_file_name` function does.

  • Remove any slashes from the file name.
  • Make sure only the file name exists without any path information.
  • Remove any control characters from the beginning and the end of the file name.

What this does is effectively preventing ../ attacks, after that the handle_file_upload function calls fix_file_extension and the fix_integer_overflow functions, the two functions are irrelevant to this analysis so I am not gonna bother explaining them.

The next lines we are interested in from the handle_file_upload function are the following.

            $upload_dir = $this->get_upload_path();
            if (!is_dir($upload_dir)) {
                mkdir($upload_dir, $this->options['mkdir_mode'], true);

As you can see the lines tries to get the upload directory which is the directory the file will be uploaded to, next if the directory does not exist, it will be created!!!

The fact that the non existing directory is created is crucial for our final exploit so keep this information in the back of your head as it's very important.

The second important information is that we can control the directory name, if you look at the beginning of the get_upload_path function you will find the following line.

$relocate_directory = $_GET['dir'];

This means we can pass dir get parameter and set it to any directory we want.

My first thought was to use the dir parameter to point to the root directory of the server and upload php file which will result in RCE, that didn't work.

The reason it didn't work is that VestaCP doesn't call move_uploaded_file to copy the file, instead you will find the following line that creates the file (this is also called in the handle_file_upload function).

exec (VESTA_CMD . "v-copy-fs-file ". USERNAME ." ".$uploaded_file." ".escapeshellarg($file_path), $output, $return_var);

VESTA_CMD variable simply calls sudo so it's not that important to us, the important one the v-copy-fs-file this is bash script used by VestaCP to copy files, if you look at the source code of that bash script which can be found at /bin/v-copy-fs-file you will find the following check.

# Checking destination path
rpath=$(readlink -f "$dst_file")
if [ -z "$(echo $rpath |egrep "^/tmp|^$homedir")" ]; then
    echo "Error: ivalid destination path $dst_file"
    exit 2

The first thing the script does when verifying the destination path (where the file will be copied) is calling readlink -f "$dst_file" the readlink command is used to print resolved symbolic links or canonical file names, i.e it effectively remove any relative paths, for example readlink -f /tmp/../etc/passwd will be output /etc/passwd so simply you can't use ../ attacks here, the next thing the script does is that it checks if the path starts with /tmp/ or /home/admin which is the value stored in the $homedir, this means the only directories we can write files to are /tmp/ or /home/admin.

To recap all what we can do is to upload files to /tmp/ or /home/admin/ and their sub directories, if a directory doesn't exist then it will be created.

The question is how we can use that to our advantage, well it's simple, I noticed that VestaCP is installing openssh-server by default, so I decided to use the file upload to create .ssh folder under /home/admin and upload a file named authorized_keys containing my ssh authorization key.

After that you can use your private key to connect to the server using the ssh -i command and you will be connected as admin.

One thing worth mentioning is that the upload functionality requires the user to be authenticated so I had to combine the above with CSRF to achieve RCE.

After reporting this to SSD disclosure they indicated that they're only interested in pre-auth RCEs so I didn't get a bounty on this finding.

I reported this to VestaCP and it was assigned CVE-2021-28379, the exploit can be found Here, you will need to generate your own ssh keys since exploit-db decided not to include the private key with the exploit.

If you liked this article consider following me on twitter @fady_othman or subscribe to my blog updates.

See you next time :)