Patching an Arbitrary File Download Vulnerability in wsm-downloader

Published 8 November 2022
Updated 12 July 2023
Robert Rowley
Author at Patchstack
Table of Contents

Welcome to Patchstack's "Last Patch". This is a short series of blog posts where we will be discussing and patching unpatched security bugs in open source projects. With an initial focus on plugins found in the WordPress.org plugin repository

The troubling truth is some open source projects do not receive patches when security bugs are found in their code. The primary cause of this is abandonment by the developer. Sometimes life gets reprioritized and the project's developers have moved on, and that is OK.

We do not fault the developers for the lack of a patch, but we want to help. Help the site owners, help other developers, or help hosting providers with rules to protect their customers.

In each of these posts we will be discussing risks, and how to patch unpatched security bugs.

WordPress site owners and agencies can benefit from being aware of the risk of the unpatched bug. Maybe even follow along with how to temporarily patch their sites if they're running insecure code.

Hosting providers can be able to follow along and see how to write their own WAF (Web Application Firewall) rules. Protecting their customers en-masse.

Developers are the intended audience though. We all can learn from someone else's mistake and learn how to code defensively. In today's last patch, I will be using a simple allow list. Allow lists are an easy way to check a value and if the value does not match what we expect we will stop the execution of the code.

TL;DR Just Share the Patch!

For site owners

You should disable/replace this plugin as it is not actively maintained. If you would like to secure your site while you look for a replacement here is what you can do:

Open the file wsm-download/include/download.php with your preferred file editor, and find the function "wsmd_start_download_remote_file", this should be located around line 7. Look for where $dl is set (line 9) and add the following codes from Line 10 through 14 (lines 10 and 14 are optional comments) to the file.

If you would like more about how I went about finding it then read the rest of this post.

7   function wsmd_start_download_remote_file(){
8   if(isset($_POST['dl'])){
9     $dl = base64_decode(sanitize_text_field($_POST['dl']));
10    // The last patch, provided by Patchstack 
11    if (!((stripos($dl, 'http://') === 0) || (stripos($dl, 'https://') === 0))) { 
12      wp_die();
13    }
14    // The last patch end

For hosting providers

If you would like to protect websites using a WAF or if editing files is not an option. Then here is a mod_security rule that should work. I explain it in more detail later on in this post.

SecRule ARGS_POST:dl "!@rx ^http(s)?://" "t:base64Decode,t:lowercase,deny,id:'101'"

Last Patch: wsm-downloader

Now, let's talk about an unpatched security bug which should receive one last patch. An unauthenticated arbitrary file download vulnerability in wsm-downloader.

This is an attractive security bug for attackers. It requires no authentication, only a single request is needed, and the leaked data can be highly sensitive.

The security impact is remote attackers can read any file on the web server readable and accessible to the website. This may include files like /etc/passwd or wp-config.php which would expose database credentials and other secrets.

A stealthy attack leading to secrets being exposed sounds like it is worthy of receiving at least one last patch. So let's do it.

Verifying the exploitability

The proof of concept for this exploit has been public for some time and is easy to reproduce.

# curl -v -d "dl=L2V0Yy9wYXNzd2Q=&size=200&type=text/plain"  http://target/

It is noted that the "L2V0Yy9wYXNzd2Q=" string is a base64 encoded value of "/etc/passwd"

This proof of concept can be pointed at any page a WordPress site running on. If the site has the wsm-downloader plugin active, it will respond with the content of the web server's /etc/passwd file.

I verified it as working with both /etc/passwd as well as the website's wp-config.php. I can use the proof of concept to find the bug in the code, write a patch, and confirm it prevents the attack.

Finding what to patch

The proof of concept shows us the POST variable named "dl" contains a base64 encoded string which is the path of the file being exposed. This is what I want to look for first.

I hunted down every instance where $_POST['dl'] or $_POST["dl"] is found in the code base and spotted only one file referencing this variable. The file's name is: wsm-downloader/include/download.php

$ grep -R "POST\[.dl.\]" wsm-downloader/ 
wsm-downloader/include/download.php:if(isset($_POST['dl'])){
wsm-downloader/include/download.php:    $dl = sanitize_text_field(base64_decode($_POST['dl']));

Reviewing the code

Here are the relevant lines of wsm-download/include/download.php

...
6    add_action('wp' , 'wsmd_start_download_remote_file');
7    function wsmd_start_download_remote_file(){
8    if(isset($_POST['dl'])){
9        $dl = base64_decode(sanitize_text_field($_POST['dl']));
...
43        readfile($dl, "", stream_context_create($context_options));

Deciphering the code above:

add_action('wp', 'wsmd_start_download_remote_file');

This first call to add_action('wp', 'wsmd_start_download_remote_file') tells us that WordPress will call the function wsmd_start_download_remote_file() every time the 'wp' hook is run. Since the 'wp' hook runs when the WordPress environment has been set up this function is called with every WordPress page load.

This explains why the proof of concept request's URL can be any WordPress URL.

Now, we can move on to inspecting the wsmd_start_download_remote_file function. Which immediately checks to ensure if $_POST['dl'] has a value, if it does it will sanitize and then base64 decodes the value storing it as $dl.

A semi important side note here: The order of sanitizing then base64 decoding is backwards. The code should decode the base64 string first and then sanitize the decoded string.

$dl = base64_decode(sanitize_text_field($_POST['dl']));

Should be:

$dl = sanitize_text_field(base64_decode($_POST['dl']));

The code later passes the value of $dl to readfile() which is a PHP function used for outputting the contents of a file. It just so happens readfile() also supports URLs, which is how the plugin author intended to use it here.

The documentation for the wsm-downloader plugin appears to only discuss that it's purpose is to download or share media from URLs. So, the usage of readfile() here is awkward and cURL would be more apropos, but since readfile() supports URLs this is acceptable.

Writing the patch

We need to disallow file paths to be passed to readfile() or in other words: only allow URLs to be sent to readfile() (specifically HTTP and HTTPS URLs only)

To do this, I add an allow-list or logical gate to stop execution of the script if $dl does not start with http:// or https://.

I will add this allow list right after $dl is set and fix the order of sanitize_text_field and base64_decode for good measure.

7   function wsmd_start_download_remote_file(){
8   if(isset($_POST['dl'])){
9     $dl = sanitize_text_field(base64_decode($_POST['dl']));
10    // The last patch, provided by Patchstack
11    if (!((stripos($dl, 'http://') === 0) || (stripos($dl, 'https://') === 0))) {
12      wp_die();
13    }
14    // The last patch end

The allow list code is simple and rudimentary but it works. We use an if conditional which calls stripos() twice. stripos() will find the position of the first occurrence of a needle ("http://" or "https://") in a haystack (value of $dl) (and is case insensitive.) If the needle is not at the start of the haystack then we will call wp_die() which will stop the execution of the code and return a WordPress error page.

With this change made I tested the same proof of concept and was presented with a WordPress error page. This shows me I am hitting that wp_die() call on line 12 and the plugin's code will no longer return values of any local files on the web server. A successful yet simple patch.

Writing a WAF Rule (mod_security)

Editing the code directly may not be an option for everyone, for those cases a web application firewall rule will do the trick.

If you have the popular mod_security firewall configured here is an example rule you can use.

This rule will deny any requests with a POST variable named "dl" that has a value starting with "https://" or "http://" using regex. This value needs to be base64decoded, so use t:bas64Decode and also transform the string to lowercase with t:lowercase. You may want to choose a unique rule id number for your local set up, I chose 101 out of convenience.

SecRule ARGS_POST:dl "!@rx ^http(s)?://" "t:base64Decode,t:lowercase,deny,id:'101'"

Conclusions

The patch this code needed was simple. A basic allow list either in PHP or in the WAF sites can be all the protection you need.

Site owners running the wsm-downloader plugin should still look for an alternative. This last patch or firewall rule may protect your sites against this specific threat but with no developer maintaining the code for wsm-downloader your sites are running unsupported software which could contain more security bugs. In fact, there is another unpatched security bug in wsm-downloader which I did not address: a Domain Name Restriction Bypass vulnerability.

Perhaps next time I can inspect and show you all how to patch that bug, but that is all the time I have for this for now.

Stay tuned to Patchstack for future installments of The Last Patch. If you have a plugin with an unpatched security bug in it that you would like to see patched, feel free to reach out. I look forward to sharing with you some secure development best practices and tackling some future unpatched security bugs.

The latest in Security Advice

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu