Search Go hack yourself with Detectify
×

An EASM blog from Detectify

Improving WordPress plugin security from both attack and defense sides

July 23, 2019

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 Detectify 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 <script> tag with malicious JavaScript in it, the code would we executed in all the visitors browsers. Why is that a problem?

Within the JavaScript, the attacker has full access to the DOM of the page generated for the victim. That means he/she can…

… access sensitive information visible on the page and send it to their server with an AJAX request,
… parse the DOM for CSRF tokens and trigger actions (like moving funds or changing passwords) on behalf of the victim with the CSRF token,
… stealing the users session cookie (in case of a missing HttpOnly flag, which makes it inaccessible in JavaScript),

and much more.

Identifying the issue is rather simple in theory. You are looking for <?=<?php echo or any other possible way of outputting text. Is that user input and is the text being transformed in any way at the exact occurrence or within the same context (function/method/file) so it influences the output? If not, a potential vector for a XSS has been found.

You would now try to find out where the data is coming from and if it might have been sanitized within a different process. This applies for stored data in the database for example; maybe it has been encoded or filtered before persisting it.

I’ve discovered a particular vulnerability several times before and in one instance it affected a plugin with over 40.000 active installs. It was a CSRF to Stored XSS vulnerability.

Let’s re-create this real life scenario:

We are running a controversial blog and want to add a plugin, which lets us define a disclaimer displayed at the beginning of every article. The disclaimer is just plain text and will not contain any HTML.

Again, an admin-ajax.php action will be registered:

add_action('wp_ajax_edit_disclaimer', 'edit_disclaimer');

function  edit_disclaimer()
{
    if (! isset($_REQUEST['content']) || ! current_user_can('administrator')) {
        wp_die();
    }

    $disclaimer = $_REQUEST['content'];

    // Save in database safely with a prepared query and show response, no encoding or sanitization
}

The plugin is very simple, it just retrieves the disclaimer from the wp_options table and prepends it in front of the content by using the the_content filter hook:

add_filter('the_content', 'prepend_disclaimer');

function prepend_disclaimer($content)
{
    global $wpdb;

    $disclaimer = $wpdb->get_row($wpdb->prepare('SELECT option_value FROM '.$wpdb->prefix.'options WHERE option_name = %s', [
        'disclaimer',
    ]));

    return $disclaimer->option_value.'<br>'.$content;
}

Alright, we know $disclaimer has been fetched out of the database and can be adjusted by a user. It is directly being added to the content without alteration. Congratulations, we discovered a Stored XSS vulnerability.
However, the next phase will determine if the issue is exploitable. So the form for editing the disclaimer text is part of the administration backend. We can’t access this as an unauthenticated attacker.

Authorization has been implemented properly, we won’t be able to pass the current_user_can('administrator') check as a guest. On top of that, action edit_disclaimer is not prefixed by nopriv_, so we would need an account with a minimum privilege of a subscriber to even reach the check.

On the other hand, there is no check_admin_referer or wp_verify_nonce in the state changing function edit_disclaimer(). Exploitation is possible with a simple CSRF to Stored XSS chain. All we need to do is trick an administrator into visiting a malicious page, which includes an AJAX request to the /wp-admin/admin-ajax.php endpoint altering the disclaimer.

While fixing CSRF vulnerabilities has been discussed above, how can XSS be prevented?

Basically that can be achieved by rather filtering out unwanted characters and/or encoding the text so the browser won’t parse it as HTML.

WordPress is utilized with a whole set of functions to use in specific scenarios again.

esc_html() for long HTML blocks.
esc_attr() for usage in HTML attributes.
esc_url() for adding links.
esc_js() for usage in inline event handlers (i.e. onhover="").

A more thorough guide on sanitizing and encoding data in WordPress is included in the documentation.

In our example, sending the disclaimer through esc_html() should be sufficient:

return esc_html($disclaimer->option_value).'<br>'.$content;

PHP Object Injection to Remote Code Execution

PHP Object Injections allow attackers to inject an arbitrary PHP object(s) […] into the application scope. In general, they are a starting point for critical vulnerabilities such as Remote Code Execution. They occur when untrusted data is being passed to unserialize()unserialize() should be avoided as often as possible. Alternatives are json_encode() in combination with json_decode().

However, the impact highly depends on the codebase and therefore the classes an attacker can create an object of. As we know, there are uncountable plugins and most blogs use multiple ones to create the portal they want. All of the plugins can contain classes, which help exploiting usages of unserialize(). Proven and demonstrated in PHP Object Injection Vulnerability in WordPress: an Analysis.

Additionally, the core contains a component, that allows to escalate these kind of issues to a RCE fairly easy. Requests_Utility_FilteredIterator will execute a given callback for each item in an array when iterating over it.

In the case of a deserialization of a cookie, this technique allowed me to gain access to blogs with a single request. Detailed information on that – again, practically demonstrated – can be found in my disclosure about the PHP Object Injection in Yet Another Stars Rating.

Shoutout to Erwan from wpscan. I have seen the class name before, but didn’t find the time to get into it. He reached out to me to tell me about it when I disclosed the YASR vulnerability. A cool example of how a little collaboration can change things kind of a bit. He also took the time to give me an early feedback on this post.

Additional resources on WordPress plugin security

With the basic concept for some of the inner workings and gotchas of plugins and core in mind, you have enough information in your equipment to apply your existing security knowledge of typical vulnerabilities to WordPress plugins. An overview over WordPress specific functionality for CSRF tokens, SQLi and more has been started by Ryan Dewhurst in his WordPress Plugin Security Testing Cheat Sheet recently.

On top of that, if you are maintaining an open source plugin, you should consider using the free offer by RIPS over at their CodeRisk platform. After verifying ownership, they will provide you the full results of their static code analysis done for your plugin via their analysis tool. RIPS is the official code analysis partner for Joomla and their team detected numerous vulnerabilities worth checking out.

 

Written by:

Paul Dannewitz
Twitter: @padannewitz
Website: https://dannewitz.ninja/


Additional resources:

Detectify collaborates with 150 handpicked white hat hackers like Paul Dannewitz to Crowdsource vulnerability research for our automated web application scanner. We offer a plethora of tests for WordPress plugin security and other CMSes. Check the security status of your websites using our test bed of 1500+ known vulnerabilities. Sign up for Detectify and start your free 14-day trial today!