Patching an Arbitrary Plugin Disablement Bug in the "webmaster-tools-verification" Plugin

Published 29 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

This post will review the webmaster-tools-verification plugin. This plugin was first created in 2009 and is extremely simple consisting of just 4 files: 1 PHP, 1 CSS, 1 image (png), and a readme.txt file. The developer's last commit was made in 2013.

The plugin was disabled in 2022, 9 years after the last update from the developer. It was disabled temporarily, but likely permanently due to the Unauthenticated Arbitrary Plugin Deactivation bug reported in the plugin around the same time.

The troubling truth is some open-source projects get abandoned. Life's priorities can change, and it looks like the project's developers have moved on. This is OK. We shouldn't expect a lifetime of servitude in exchange for free code. Instead, we can embrace the ethos of open source, and contribute back when and where we can.

So that is what I will do today. We are here to help. Help the site owners, help other developers, or help hosting providers with rules to protect their customers.

TL;DR Just Share the Patch

For site owners

Open the webmaster-tools-verification/webmaster-tools-verification.php file and edit two sections:

First update wmtv_uninstall() function, remove the old function and replace it with:

/**
 * Last Patch provided by Patchstack (RR)
 **/
function wmtv_uninstall() {
  require_once( ABSPATH .'wp-includes/pluggable.php' );
  require_once( ABSPATH . 'wp-admin/includes/plugin.php' );

  if( isset($_POST['uninstall_nonce'])
    && wp_verify_nonce($_POST['uninstall_nonce'], 'wmtv_uninstall') 
    && current_user_can('delete_plugins') ) {
      deactivate_plugins('/webmaster-tools-verification/webmaster-tools-verification.php');
    } else {
      wp_die('Current user can not delete_plugins or failed nonce.');
    }
}

Then go to the bottom of the file and find the function wmtv_postbox_uninstall() and make this function's first few lines look like the following. Note I commented out one $output value and added a new line storing a nonce in a hidden field.

   294    function wmtv_postbox_uninstall() {
   295        
   296        $output  = '<form action="" method="post">';
   297        //$output .= '<input type="hidden" name="plugin" id="plugin" value="webmaster-tools/webmaster-tools.php" />';
   298        $output .= '<input type="hidden" name="uninstall_nonce" value="'.wp_create_nonce('wmtv_uninstall').'" />';

For hosting providers

The following mod_security rule will function to block requests that may disable arbitrary plugins on the website:

SecRule ARGS_POST:wmtv_uninstall "1" "deny,id:1234,chain"
  SecRule ARGS_POST:wmtv_uninstall_confirm "1" "chain"
  SecRule ARGS_NAMES "plugin"

The rule will block requests and prevent the usage of the wmtv_uninstall feature. It will also be a breaking change for websites that have not updated the code to address this issue. I will explain more about why in the blog post before.

To learn more about how this patch and mod_security rule were created, please read on.

Last Patch: webmaster-tools-verification

Verifying the vulnerability

With most security bugs, a proof of concept or "PoC" is included and eventually, these PoCs are publicly released. In this case, the webmaster-tools-verification PoC is easy to read and understand.

A single POST request with the right payload will trigger the disablement of installed plugins. A potentially high risk as this could disable existing security plugins that protect the website.

Here is the example PoC I found that has already been posted online.

# curl -X POST --data "wmtv_uninstall=1&wmtv_uninstall_confirm=1&plugin=anyplugun" https://example.com

When testing the above curl command I found plugins were being disabled in alphabetical order, even when the value of "plugin" was gibberish. This shows me that the name of the plugin is not required and this appears to be a coding error within the webmaster-tools-verification plugin's codebase.

Finding what to patch

Finding what to patch starts with a search, looking for those POST variables wmtv_uninstall_confirm and wmtv_uninstall is where I will start. Since this plugin is just one PHP file, I know where to look: webmaster-tools-verification.php

# grep -R "POST\[.wmtv_uninstall" .
./webmaster-tools-verification.php:if ( isset( $_POST['wmtv_uninstall'], $_POST['wmtv_uninstall_confirm'] ) ) { wmtv_uninstall(); }
./webmaster-tools-verification.php:    if ( isset( $_POST['wmtv_uninstall'] ) && ! isset( $_POST['wmtv_uninstall_confirm'] ) ) {

Deciphering the code

The code is simple, but let's walk through it:

Line 44 is where the code checks if the POST values are set, and then calls the wmtv_uninstall() function.

    44    if ( isset( $_POST['wmtv_uninstall'], $_POST['wmtv_uninstall_confirm'] ) ) { wmtv_uninstall(); }

The wmtv_uninstall() function is down on lines 246 - 256 and contains the unsafe code.

   246     // On uninstall all Block Spam By Math Reloaded options will be removed from database
   247    function wmtv_uninstall() {
   248    
   249        delete_option( 'wmtv_version' );
   250        delete_option( 'wmtv_options' );
   251    
   252        $current = get_option('active_plugins');
   253        array_splice($current, array_search( $_POST['plugin'], $current), 1 ); // Array-function!
   254        update_option('active_plugins', $current);
   255        header('Location: plugins.php?deactivate=true');
   256    }

Did you spot the problem code? Perhaps some WP-CLI debugging can help.

Using the wp shell command I can debug this function manually.

First, we set $current just as the code does on line 252

# wp shell
wp> $current = get_option('active_plugins');
=> array(3) {
  [0]=>
  string(26) "member-hero/memberhero.php"
  [1]=>
  string(61) "webmaster-tools-verification/webmaster-tools-verification.php"
  [2]=>
  string(33) "wsm-downloader/wsm_downloader.php"
}

Then we assign $current using array_splice() just like line 253, and fill in the value for $_POST['plugin'] to a static string of our choosing. When we do this, we can spot the problem in the code. When the static string is a valid plugin name, it returns an empty array but when it is an invalid plugin name (which does not exist) the function array_splice() function returns an array with the first value of $current (e.g.. the alphabetically first plugin.)

wp> array_splice($current, array_search('wsm-downloader/wsm_downloader.php', $current), 1);
=> array(0) {
}
wp> array_splice($current, array_search('foo', $current), 1);
=> array(1) {
  [0]=>
  string(26) "member-hero/memberhero.php"
}

It appears the developer was intending to remove the entry in the array that is the plugin to be removed but did not test this code sufficiently. It may have worked in their testing, but that is likely because they only had one plugin installed.

In practice, this function would disable all the plugins on the site or disable the wrong one in most cases. Not ideal, so let's fix it.

Writing the patch

This code is doing things that WordPress core handles for you. A single call to deactivate_plugins() is what this developer should have used instead of manually manipulating the options table in the database.

Furthermore, disabling a plugin is a privileged action, we need to add some authorization checks and CSRF protection. So, we start by creating a nonce near lines 293 and 294 right after the wmtv_postbox_uninstall() function is defined.

function wmtv_postbox_uninstall() {

    $output  = '<form action="" method="post">';
    //$output .= '<input type="hidden" name="plugin" id="plugin" value="webmaster-tools/webmaster-tools.php" />';
    $output .= '<input type="hidden" name="uninstall_nonce" value="'.wp_create_nonce('wmtv_uninstall').'" />'; 

We can and should comment out line 293 because the code will no longer use the POST variable 'plugin' to choose which plugin to disable. This will also come in handy later with the mod_security rule.

We then update wmtv_uninstall() function to look like:

/**
 * Last Patch provided by Patchstack (RR)
 **/
function wmtv_uninstall() {
  require_once( ABSPATH .'wp-includes/pluggable.php' );
  require_once( ABSPATH . 'wp-admin/includes/plugin.php' );

  if( isset($_POST['uninstall_nonce'])
    && wp_verify_nonce($_POST['uninstall_nonce'], 'wmtv_uninstall') 
    && current_user_can('delete_plugins') ) {
      deactivate_plugins('/webmaster-tools-verification/webmaster-tools-verification.php');
    } else {
      wp_die('Current user can not delete_plugins or failed nonce.');
    }
}

Note we have to require a few WordPress specific files so we can call WordPress specific functions like wp_verify_nonce() and deactivate_plugins().

The code then goes on to check the nonce and verify the currently logged in user can delete_plugins. Once that check is passed, we tell WordPress to deactivate the webmaster-tools-verification plugin. The plugin name is hard-coded because we do not want to use user input to decide which plugin(s) to disable, instead, it's simply better to only allow this plugin to disable itself.

Writing the WAF rule (mod_security)

For web hosting providers, here is a simple mod_security rule that will block any requests that have POST variables "wmtv_uninstall" and "wmtv_uninstall_confirm" set to 1, and "plugin" if it is any value.

SecRule ARGS_POST:wmtv_uninstall "1" "deny,id:1234,chain"
  SecRule ARGS_POST:wmtv_uninstall_confirm "1" "chain"
  SecRule ARGS_NAMES "plugin"

This will be a breaking change for websites, they will no longer be able to utilize this form to disable the plugin. As noted above since the code was not functioning as intended, it would disable all plugins on the website. It is safest to block these requests outright.

Plugins that have been updated with the code examples provided in this blog post will not be blocked by the above request. Remember, we commented out the section that sets the value of "plugin".

Conclusions

The lesson learned here is that using built-in functionality helps developers avoid making mistakes. There is no need to reinvent the wheel when writing code. It leaves you too prone to making a mistake. The developer also accepted user input for the plugin name to be disabled and missed the fact that data in POST fields can in fact be edited by the browser.

Fixing this code included multiple security-oriented changes, including:

  • Use built-in functions, don't re-write the wheel.
  • Never trust user input, and hard-code values when possible.
  • Add authorization checks before taking privileged actions.
  • Prevent CSRF concerns with a nonce.

I hope it was helpful to you and shows that it does not take much effort to turn insecure code into secure code sometimes.

The latest in Security Advice

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