Improving WordPress plugin security from both attack and defense sides – 10 minute mail

Paul is a front- & backend developer with a passion in security, who creates designs occasionally. After starting out with WordPress plugin vulnerabilities, he joined the bug bounty world and now also a white hat hacker in the Disposable mail Crowdsource community. As he has acquired his knowledge through community resources himself and wants to make the internet a safer place, he shares his know-how to give something back and in this case tips on WordPress plugin security.

TL;DR: This article aims to be a useful resource for hackers, which would like to learn about functions specific to WordPress plugin security, but also for plugin developers, who might not know about common vulnerabilities like XSS. As a result, vulnerability types are being briefly explained and decorated with links to additional resources to learn more about them before going into WordPress related details for each of them. Let’s improve the WordPress ecosystem security together from both sides, attack and defense.

With a market share of more than 50 percent, WordPress is the most popular Content Management System available. It currently powers 32 percent of the web and WordPress plugin security should always be on top-of-mind. The popularity is at least partly caused by the extensive ecosystem including over 55.000 free open source plugins only tracked in the official repository. On top of that, each installation can easily be individualized with all kind of different themes. Vulnerabilities, especially in plugins and themes, are being discovered regularly. Currently, there are 14.000 vulnerabilities from the core, plugins and themes being categorized in the WordPress Vulnerability Database.

Plugin and theme usage can be mostly fingerprinted in a reliable way by analyzing the generated source code. Therefore a blackbox pentest can be more or less made a whitebox one of. This is why I’d like to write about a few WordPress related aspects – such as vulnerability specific functions – and gotchas that helped me finding several vulnerabilities in WordPress plugins in the past.

WordPress Hooks

WordPress is built to be extendable and flexible in order for plugin developers to be able to ‘hook into’ the rest of WordPress.

There are two different types of hooks: Actions and Filters.

Actions can be thought of as event points, at which you can execute custom PHP code. For example, a developer can hook into the send_headers event in order to send an additional header:

add_action('send_headers','add_cors_header');

function add_cors_header ()
{
    header('Access-Control-Allow-Origin: https://dannewitz.ninja');
}

Next time a response from WordPress is sent, CORS will be allowed exclusively for https://dannewitz.ninja.

Filters allow developers to alter input or output. For example, before displaying a post and after retrieving it from the database, it will be passed to the the_content filter. Again, the second parameter for adding a filter is a function. That function will receive the content as its first parameter. The data returned by the function will then be displayed on the post page. Let’s show You have been hacked. instead of the actual content for all posts:

add_filter('the_content', 'overwrite_post_content');

function overwrite_post_content ($content)
{
    return 'You have been hacked.';
}

With this information in mind, it is a good idea to search for all add_action and add_filteroccurrences. Understanding points at which specific code is being executed helps you getting a first impression of some of the plugins features. Sometimes this is enough to spot a vulnerability.

WordPress has a comprehensive list showing all the WordPress hook actions with a description on when they are triggered and a link to the subpage for the specific action including a more comprehensive reference. Especially hooks like admin_init, which is being triggered first for admin page calls, are interesting starting points, because they usually lack sufficient validation and can be accessed by guests aswell. You will learn about that in a moment.

Special action hooks

WordPress has two special endpoints: /wp-admin/admin-ajax.php and /wp-admin/admin-post.php. Even though they are both located in the wp-admin folder, non-administrative users and also guests can send requests to them. But the location will play a key role later on.

/wp-admin/admin-ajax.php basically is an endpoint for custom AJAX requests from within anywhere in your blog. Want to send a form asynchronously? This is the way to go.

/wp-admin/admin-post.php acts in a similar way, but is not generally meant for AJAX requests.

The handlers can be added just as any other action prefixed by wp_ajax_ and admin_post_respectively. Everything else will be the action name. Request parameter action will differ between all the handlers registered for the endpoints. Normally, only authenticated users (which includes low-privileged users such as subscribers) can send requests to them. Adding nopriv_ to the prefix allows unauthenticated requests for both AJAX and admin post calls.

Let’s create an AJAX action which will return the current date for guests:

add_action('wp_ajax_nopriv_retrieve_date', 'current_date');

function current_date ()
{
   echo date('d.m.Y');
    wp_die();
}

Retrieving the data works like the following:

$.post('https://dannewitz.ninja/wp-admin/admin-ajax.php', {
    'action': 'retrieve_date',
});

is_admin() is not is_admin()

Auto completion and suggestions from a developers IDE can be dangerous. Starting to write something like is_ might result in is_admin() being suggested. What does is_admin() do? Checking the current users role, pretty obvious, right? It is not.

ìs_admin() checks for the current endpoint and will return true if the URL being accessed is in the admin section. You probably already see where this ends. is_admin() returns true for both of the endpoints explained above. I honestly wouldn’t have thought of this nor knew about it without reading the documentation.

In fact, this nearly removed the need for exploiting a Cross-Site Request Forgery (CSRF) in order to achieve a Remote Code Execution in a vulnerability I discovered recently in a plugin with 300.000 active installs. A practical deep dive into the vulnerable code including hooks and is_admin() can be found in the disclosure of Widget Logic CSRF to RCE.

Cross-Site-Request-Forgery (CSRF)

How does a website know a request originates from the current website and was willingly fired by the user, who sent it? What would happen if an attacker recreates the form for adding an administrator on his malicious page, which it will be submitted automatically? The admin of the target WordPress blog visits the page, which results in a new administrative user being created. That was a rough outline of what CSRF is about.

WordPress ships with pre-created methods for CSRF tokens and they should always be checked before triggering a state changing behaviour.

wp nonce field() adds a hidden input to your existing form. If you are crafting your own POST AJAX requests for example, only retrieving the token itself can be achieved by calling wp_create_nonce. In a GET request, wp_nonce_url() might be the more convenient way.

On the receiving end, tokens can be verified via methods including wp_verify nonce() and check_admin_referer().

It is advised to always pass the action parameter to both the methods for creating and verifying the nonce. Tokens created for a specific action are only valid when being checked against with the same action being given to the validation method.

wp_verify_nonce() returns…

… false for invalid nonces,
… 1 in case of a valid nonce created within the past 12 hours and
… 2 for nonces older than 12 hours but still within a range of 24 hours.

check_admin_referer() will stop the application by calling die() without a valid token being passed. Important: This just happens in the prefered usage, check the documentation.

Additionally, this is – just like is_admin() – not an authorization check.

Authorization

As we now know, there are several popular ways of adding a supposed authorization middleware, which does not actually check any permissions.

Roles and capabilities can be verified with current_user_can(). It returns a boolean and needs to know which role or capability has to be available for the currently authenticated user in its first parameter.

For example, making sure only administrators can access the functionality works like the following:

if (current_user_can('administrator')) {
    // Sensitive functionality
}

WordPress provides a list of all capabilities.

SQL Injection

Even in 2019, SQL injections are some of the most common and critical types of vulnerabilities. Letting untrusted input become part of a database query without escaping or prepared statements leads to a leakage of the whole database. Just to name a possible way of abusing the issue. Again, you can read more about that in an in depth explanation about SQL injections.

Let’s say we create a custom action available via /wp-admin/admin-ajax.php, which retrieves a certain post by its id:

add_action('wp_ajax_nopriv_get_post_by_id', 'get_post_by_id');

function get_post_by_id()
{
    global $wpdb;

    if (! isset($_REQUEST['id'])) {
        wp_die();
    }

    $postId = $_REQUEST['id'];

    $post = $wpdb->get_row('SELECT * FROM '.$wpdb->prefix.'posts WHERE ID = '.$postId);
    header('Content-Type: application/json');
    echo json_encode($post);
    wp_die();
}

A pretty much simplified example of an unauthenticated SQL injection. This does happen in the wild. A GET request with a parameter ID set to a malicious extension to the existing query will retrieve the first administrators name and password hash:

/wp-admin/admin-ajax.php?action=get_post_by_id&id=1%20UNION%20SELECT%20wp_users.user_login%20AS%20post_author,%20wp_users.user_pass,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null,%20null%20FROM%20wp_users%20LIMIT%201,2

Exploiting the SQL injection

Preparing the query with prepare(), which is part of the wpdb class, is the recommended way of achieving a safe execution. The syntax is similar to sprintf. First parameter receives the raw query with unquoted placeholders:

%s for strings,
%d for integers,
%f for floats.

Every parameter after that will replace the placeholders in the query in their respective order. Alternatively an array can be used for the second parameter.

So how do we refactor our insecure code?

$post = $wpdb->get_row($wpdb->prepare('SELECT * FROM '.$wpdb->prefix.'posts WHERE ID = %d', $postId));

Of course, there are much more functions crafted for database interaction. If you are unsure, consult the WordPress documentation page for the specific method or class to make sure it is rather safe to use with user input or any special sanitization needs to be done before supplying the data.

Cross-Site-Scripting

One of the most common vulnerabilities is Cross-Site-Scripting (XSS). In WordPress plugins, it can often be found within a small chain of vulnerabilities – CSRF to XSS. But what is XSS about? XSS happens when displaying user input without sanitizing it, extremely simplified. For example, if the malicious input is not a comment for the website but a

Temp Mails (https://tempemail.co/) is a new free temporary email addresses service. This service provide you random 10 minutes emails addresses. It is also known by names like: temporary mail, disposable mail, throwaway email, one time mail, anonymous email address… All emails received by Tempmail servers are displayed automatically in your online browser inbox.