elFinder - A Case Study of Web File Manager Vulnerabilities

by thomas chauchefoin|

Web file manager compromise by an unauthenticated attacker

An application’s interaction with the file system is always highly security sensitive, since minor functional bugs can easily be the source of exploitable vulnerabilities. This observation is especially true in the case of web file managers, whose role is to replicate the features of a complete file system and expose it to the client’s browser in a transparent way.

elFinder is a popular web file manager often used in CMS and frameworks, such as WordPress plugins (wp-file-manager) or Symfony bundles, to allow easy operations on both local and remote files. In the past, elFinder has been part of active in-the-wild attacks targeting unsafe configuration or actual code vulnerabilities. Thus, elFinder is published with a safe default configuration to prevent any malicious use by attackers.

As part of our regular assessment of widely deployed open-source projects, we discovered multiple new code vulnerabilities in elFinder. In the following case study of common code vulnerabilities in web file managers, we describe five different vulnerability chains and demonstrate how they could be exploited to gain control of the underlying server and its data. We will also discuss some of the patches that were later implemented by the vendor to show how to prevent them in your own code.

Impact

We worked on the development branch, commit f9c906d. Findings were also confirmed on release 2.1.57; all affect the default configuration (unless specified otherwise in this article) and do not require prior authentication. As we mentioned, the exploitation of these vulnerabilities can let an attacker execute arbitrary PHP code on the server where elFinder is installed, ultimately leading to its compromise. 

The findings we discuss in this blog post (all assigned to CVE-2021-32682) and successfully exploited to gain code execution are: 

  • Deleting Arbitrary Files
  • Moving Arbitrary Files
  • Uploading PHP Files
  • Argument Injection
  • Race Condition

All these bug classes are very common in software that exposes filesystems to users, and are likely to impact a broad range of products, not only elFinder. 

elFinder released version 2.1.59 to address all the bugs we responsibly disclosed. There is no doubt these vulnerabilities will also be exploited in the wild, because exploits targeting old versions have been publicly released and the connectors filenames are part of compilations of paths to look for when trying to compromise websites. Hence, we highly recommend that all users immediately upgrade elFinder to the latest version.

Technical Details

elFinder comes with a back end (also called connector) written in PHP and a front end written in HTML and JavaScript. The connector is the main script that dispatches the actions of the front end code to the right back end code to implement file system features. Connectors can be configured to disallow dangerous actions, restrict uploads to specific MIME types: two different ones are part of the default install. We detected vulnerabilities in the so-called “minimal” connector. It only allows image and plain text uploads and FTP is the only supported remote virtual filesystem: this is presumably the safest one and the most likely to be deployed. 

To give a better understanding of the code snippets we will use to demonstrate our findings, we will first describe how elFinder’s routing works. Like in many modern PHP applications, the connector (e.g. connector.minimal.php) is the only entry point. It declares configuration directives and closures and then instantiates both elFinder (the core) and elFinderConnector (the interface between elFinder and the transport channel, here HTTP). 

The attribute elFinder::$commands contains every valid action and the expected arguments:

php/elFinder.class.php

protected $commands = array(
  'abort' => array('id' => true),
  'archive' => array('targets' => true, 'type' => true, 'mimes' => false, 'name' => false),
  'callback' => array('node' => true, 'json' => false, 'bind' => false, 'done' => false),
  'chmod' => array('targets' => true, 'mode' => true),
  'dim' => array('target' => true, 'substitute' => false),
  'duplicate' => array('targets' => true, 'suffix' => false),
  // [...]

The user can call any of these commands by providing the cmd parameter with the required command parameter via PATH_INFO, GET, or POST. In each command handler, parameters are accessed using $args.

To allow remote filesystems (FTP, Dropbox, etc.) to be used with local ones, elFinder implements a filesystem abstraction layer (elFinderVolumeDriver) on top of which all drivers are built. Files are then referenced by their volume name (e.g. t1_ is the trash, l1_ the default local volume) and the URL-safe Base64 of their name. 

Let’s first dig into an arbitrary file deletion bug chain, composed of two distinct issues.

Deleting Arbitrary Files

The PHP core does not provide an effective way to run background threads, or perform synchronization and inter-process communication. elFinder tries to balance this by heavily using temporary files and post-request hooks. For instance, users can abort ongoing actions by calling the method of the same name:

php/elFinder.class.php

protected function abort($args = array())
{
  if (!elFinder::$connectionFlagsPath || $_SERVER['REQUEST_METHOD'] === 'HEAD') {
    return;
  }

  $flagFile = elFinder::$connectionFlagsPath . DIRECTORY_SEPARATOR . 'elfreq%s';
  if (!empty($args['makeFile'])) { 
    self::$abortCheckFile = sprintf($flagFile, $args['makeFile']); // <-- [1]
    touch(self::$abortCheckFile);
    $GLOBALS['elFinderTempFiles'][self::$abortCheckFile] = true;
    return;
  }

  $file = !empty($args['id']) ? sprintf($flagFile, $args['id']) : self::$abortCheckFile; // <-- [2]
  $file && is_file($file) && unlink($file);
}

Here, a code vulnerability is present at [1] and [2]: a user-controlled parameter is concatenated into a full path without prior checks. For [1], it can end up creating an empty file with a fully controllable name, and in [2] it can be used to remove an arbitrary file. SonarCloud issues for both bugs are available: [1] and [2].

There is a catch: the filename resulting from [1] will be prefixed by elfreq. In a path traversal attack, POSIX systems will fail path resolution if any predecessor in the path does not exist or is not a directory. For instance, resolving /tmp/i_do_not_exist/../ or /tmp/i_am_a_file/../ will respectively fail with ENOENT and ENOTDIR. This prerequisite makes the exploitation of these two vulnerabilities impossible as-is, and will require another bug, such as the ability to create an arbitrary directory.

An attacker could then look into the command mkdir and discover a primitive that allows this exact behaviour. Here is its top-level handler, before it goes through the filesystem abstraction layer:

php/elFinder.class.php

function mkdir($args)
{
  $target = $args['target'];
  $name = $args['name'];
  $dirs = $args['dirs'];
            // [...]
  if (($volume = $this->volume($target)) == false) {
    return array('error' => $this->error(self::ERROR_MKDIR, $name, self::ERROR_TRGDIR_NOT_FOUND, '#' . $target));
  }
    // [...]
  return ($dir = $volume->mkdir($target, $name)) == false
            ? array('error' => $this->error(self::ERROR_MKDIR, $name, $volume->error()))
            : array('added' => array($dir));
    }
}

A generic implementation is present in elFinderVolumeDriver to handle both the volume and path that should be created. It will call the volume-specific implementation at [1] with the volume absolute path on the filesystem as the first parameter and the target name as the second parameter: 

php/elFinderVolumeDriver.class.php

public function mkdir($dsthash, $name)
{
  // [...]
  $path = $this->decode($dsthash);
  // [...]
  $dst = $this->joinPathCE($path, $name);
  // v--- [1]
  $mkpath = $this->convEncOut($this->_mkdir($this->convEncIn($path),      $this->convEncIn($name)));
    if ($mkpath) {
        $this->clearstatcache();
        $this->updateSubdirsCache($path, true);
        $this->updateSubdirsCache($mkpath, false);
    }

    return $mkpath ? $this->stat($mkpath) : false;
}

It is defined as follows:

php/elFinderVolumeLocalFileSystem.class.php

protected function _joinPath($dir, $name)
{
  return rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
}

protected function _mkdir($path, $name)
{
  $path = $this->_joinPath($path, $name);

  if (mkdir($path)) {
    chmod($path, $this->options['dirMode']);
    return $path;
  }
 
  return false;
}

elFinderVolumeLocalFileSystem::_joinPath() is doing a mere concatenation of the two values, leading to a path traversal vulnerability. This gives a primitive to create arbitrary, empty folders on the local filesystem. While not being a vulnerability in itself, it will allow the exploitation of the aforementioned behaviour. 

It is also worth noting the presence of a full path disclosure in the rm command, disclosing the absolute path of a given file on the local filesystem:

php/elFinderVolumeDriver.class.php

protected function remove($path, $force = false)
{
  $stat = $this->stat($path);

  if (empty($stat)) {
    return $this->setError(elFinder::ERROR_RM, $path, elFinder::ERROR_FILE_NOT_FOUND);
  }

The impact of this vulnerability is quite dependent on the environment: it could be chained with other elFinder bugs, used to trigger interesting behaviors in other applications (e.g. remove WordPress’ wp-config.php file to gain code execution) or used to affect existing security measures (e.g. removing .htaccess files).

This vulnerability has been fixed by improving the implementation of elFinderVolumeLocalFileSystem::_joinPath() to assert that the final path won’t be outside of the base one. Several calls to basename() across the codebase were also added as a hardening measure.

Moving Arbitrary Files

This same elFinderVolumeLocalFileSystem::_joinPath() method is used in other actions, such as rename: it combines a volume base directory and a user-provided destination name. It is thus vulnerable to the bug we just described. 

The following snippet is the actual implementation of elFinderVolumeLocalFileSystem::rename(), after executing all the code responsible for decoding the paths and ensuring that the destination extension is allowed:

php/elFinderVolumeLocalFileSystem.class.php

protected function _move($source, $targetDir, $name)
{
  $mtime = filemtime($source);
  $target = $this->_joinPath($targetDir, $name);
  if ($ret = rename($source, $target) ? $target : false) {
    isset($this->options['keepTimestamp']['move']) && $mtime && touch($target, $mtime);
  }
  return $ret;
}

While the destination extension is still strictly limited by MIME checks, this primitive can be enough for an unauthenticated attacker to gain command execution on the server, depending on the environment, by overriding files like authorized_keys, composer.json, etc. This bug has been fixed with the same patch as the previous bug we discussed.

Uploading PHP Files

As for most PHP applications, the biggest threat faced by elFinder is that an attacker could be able to upload PHP scripts to the server, since nothing (except quite a hardened web server configuration) would prevent them from accessing it directly to execute its contents. The maintainers initially tried to defend against that by crafting a block-list that associated dangerous MIME types to the relevant extensions:

php/elFinderVolumeDriver.class.php

'staticMineMap' => array(
  'php:*' => 'text/x-php',
  'pht:*' => 'text/x-php',
  'php3:*' => 'text/x-php',
  'php4:*' => 'text/x-php',
  'php5:*' => 'text/x-php',
  'php7:*' => 'text/x-php',
  'phtml:*' => 'text/x-php',
  // [...]

In our test environment (Apache HTTP 2.4.46-1ubuntu1 on Ubuntu 20.10), the default configuration declares that .phar files should be treated as application/x-httpd-php ([1]) and be interpreted:

$ cat /etc/apache2/mods-available/php7.4.conf
<FilesMatch ".+\.ph(ar|p|tml)$">         
    SetHandler application/x-httpd-php  # <-- [1]
</FilesMatch>                           
<FilesMatch ".+\.phps$">
    SetHandler application/x-httpd-php-source
    # Deny access to raw php sources by default
    # To re-enable it's recommended to enable access to the files
    # only in specific virtual host or directory
    Require all denied
</FilesMatch>
# Deny access to files without filename (e.g. '.php')
<FilesMatch "^\.ph(ar|p|ps|tml)$">
    Require all denied
</FilesMatch>
// [...]

This configuration was also observed on Debian’s stable release. While another pass of MIME type detection is performed on the contents of the file, this can be easily circumvented as the PHP interpreter allows statements anywhere in the interpreted files (e.g. <?php can be placed after some dummy data).

The fix is straightforward: it declares that .phar files are associated with the MIME text/x-php, which are disallowed by default. 

Argument Injection

Among the default features that make elFinder so powerful, users can select multiple files and archive them using external tools such as zip, rar, and 7z. This functionality is exposed under the action named archive:

php/elFinder.class.php

public function archive($args)
{
  $targets = isset($args['targets']) && is_array($args['targets']) ? $args['targets'] : array();
  $name = isset($args['name']) ? $args['name'] : '';

  if (($volume = $this->volume($targets[0])) == false) {
    return $this->error(self::ERROR_ARCHIVE, self::ERROR_TRGDIR_NOT_FOUND);
  }

  foreach ($targets as $target) {
    $this->itemLock($target);
  }

  return ($file = $volume->archive($targets, $args['type'], $name))
        ? array('added' => array($file))
        : array('error' => $this->error(self::ERROR_ARCHIVE, $volume->error()));
}

Note that users can create archives even if their upload is forbidden, by calling the archive command on existing files. The implementation is specific to the virtual filesystem in use. We will focus solely on the default one, since it is inherited by elFinderVolumeLocalFileSystem which crafts the full command line ([1]) and executes it with the default shell ([2]):

php/elFinderVolumeLocalFileSystem.class.php

protected function makeArchive($dir, $files, $name, $arc)
{
// [...]
    $cwd = getcwd();
    if (chdir($dir)) {
      foreach ($files as $i => $file) {
        $files[$i] = '.' . DIRECTORY_SEPARATOR . basename($file);
      }
      $files = array_map('escapeshellarg', $files);

      $cmd = $arc['cmd'] . ' ' . $arc['argc'] . ' ' . escapeshellarg($name) . ' ' . implode(' ', $files); // <-- [1]
      $this->procExec($cmd, $o, $c);                // <-- [2]
// [...]

Here, the value of $name comes from the user-controlled parameter $_GET['name']. While properly escaped with escapeshellarg() to prevent the use of command substitution sequences, the program will try to parse this value as a flag (--foo=bar) and then as a positional argument. It is also worth noting that the user's value is suffixed with .zip in the case in which the ZIP archiver is selected.

The command zip implements an integrity test feature (-T) that can be used along with -TT to specify the test command to run. In the present case, it gives the attacker a way to execute arbitrary commands using this parameter injection.

To be able to exploit this vulnerability, the attacker needs to create a dummy file (e.g. a.txt), archive it to create a.zip and then invoke the archive action with both the original file and the archive as targets, using a name like -TmTT="$(id>out.txt)foooo".

The resulting command line will be zip -r9 -q '-TmTT="$(id>out.txt)foooo".zip' './a.zip' './a.txt', thus executing id and logging its standard output into out.txt — this file will be available with the other documents in elFinder’s interface.

When it came time to fix this bug, zip wasn't very friendly. The usual method based on POSIX’s -- (see our previous article about a parameter injection in Composer for an in-depth explanation) can’t be applied here, since zip will exit with the following error:

zip error: Invalid command arguments (can't use -- before archive name)

The maintainers then decided to prefix the archive name with ./ to prevent any risk of parameter injection. They also decided to harden the calls to the other archivers (7z, rar, etc.) in the same patch. 

Quarantine and Race Condition

Let’s have a look at our last finding of this case study. While this vulnerability in the quarantine feature cannot be exploited in the default configuration since archives can’t be uploaded; the feature could have been responsible for future security issues because of its design. 

The rationale behind the quarantine is that archives may contain unwanted files (mostly PHP scripts) that should not be extracted in the current folder without first running security checks (e.g. with MIME validation). So instead, elFinder chose to extract archives into a folder named .quarantine, placed under the files/ folder, and  elFinderVolumeLocalFileSystem::_extract() generates a random directory name for each archive extraction (at [1]):

php/elFinderVolumeLocalFileSystem.class.php

protected function _extract($path, $arc)
{
  if ($this->quarantine) {
    $dir = $this->quarantine . DIRECTORY_SEPARATOR . md5(basename($path) . mt_rand()); // <-- [1]
    $archive = (isset($arc['toSpec']) || $arc['cmd'] === 'phpfunction') ? '' : $dir . DIRECTORY_SEPARATOR . basename($path);
// [...]

This can be confirmed dynamically thanks to strace or the inotify suite, for instance here with an archive containing a PHP file:

$ inotifywait -m -r .
./ CREATE,ISDIR efbf975ccbac8727f434574610a0f1b6
./ OPEN,ISDIR efbf975ccbac8727f434574610a0f1b6
]...[
./efbf975ccbac8727f434574610a0f1b6/ ATTRIB,ISDIR
./efbf975ccbac8727f434574610a0f1b6/ CREATE win.php
./efbf975ccbac8727f434574610a0f1b6/ OPEN win.php
./efbf975ccbac8727f434574610a0f1b6/ MODIFY win.php
./efbf975ccbac8727f434574610a0f1b6/ ATTRIB win.php
./efbf975ccbac8727f434574610a0f1b6/ CLOSE_WRITE,CLOSE win.php
./efbf975ccbac8727f434574610a0f1b6/ ATTRIB win.php
[...]
./efbf975ccbac8727f434574610a0f1b6/ DELETE win.php
[...]
./efbf975ccbac8727f434574610a0f1b6/ DELETE_SELF

This trace can be understood as:

  • A folder named efbf975ccbac8727f434574610a0f1b6 is created,
  • A file named win.php is created within efbf975ccbac8727f434574610a0f1b6,
  • Data is written into win.php,
  • win.php is deleted,
  • efbf975ccbac8727f434574610a0f1b6 is deleted.

If the server is configured to list directories, this behavior can easily be exploited, since dangerous files (e.g. .php) can be accessed right before the MIME validation step and their removal. The race condition window is however too small to think of an attack involving brute force if the random directory name can’t be found that way. 

An attacker could discover that the duplicate action can be used on the internal folders, like .quarantine, and copy any file regardless of its contents. While being a harmless functional bug on its own, it can be chained with the quarantine feature to duplicate the folder containing our extracted archive just before its deletion. The duplicated folder is then visible in the interface, and allows an attacker to get around the random name to access the malicious script, ultimately granting arbitrary code execution.

As a fix, the maintainers decided to move the .quarantine folder outside of files/. The elFinderVolumeLocalFileSystem abstraction layer is not aware of anything outside of this folder, preventing any unintended action on .quarantine.

Timeline

DateAction
2021-03-22These 5 issues are reported to maintainers
2021-06-10The maintainers acknowledge all our findings
2021-06-13elFinder 2.1.59 is released, fixing the bugs we reported
2021-06-13CVE-2021-32682 and CVE-2021-23394 are assigned

Summary

In this case study we looked at critical code vulnerabilities that are commonly found in web file managers. We presented several of our real-world findings in the latest version of elFinder available at the time, including their potential impact and how they were fixed by the vendor.  It allowed us to demonstrate that innocuous bugs can often be combined to gain arbitrary code execution. We believe it is important to document and report these vulnerabilities to break future bug chains and reduce the risk of similar issues.

We also learned that working with paths is not easy and that extra measures should be taken: performing additional checks in the “low-level” functions, using basename() and dirname() with confidence (and knowing their limits!) and always validating user-controlled data. Such bugs are very common in web file managers, and you should always have such bugs in mind when working with them.

While we don’t plan to release any exploits for these bugs, we would still like to bring your attention to the fact that arbitrary code execution was easily demonstrated and attackers won’t have much trouble replicating it. We urge you to immediately upgrade to elFinder 2.1.59. We also advise enforcing strong access control on the connector (e.g. basic access authentication). 

Finally, we would like to thank the maintainers of elFinder for acknowledging our advisory and fixing these vulnerabilities in a timely and professional manner.

Related Blog Posts